Compare commits

...

33 Commits

Author SHA1 Message Date
Leonardo
d088a89fb7 Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização 2026-03-30 14:08:19 -03:00
Leonardo
0658e2e9bf Adicionada compressão Brotli/Gzip, auto-import de Vue e PrimeVue, e análise visual do bundle para otimização de produção e Remove AppLayout duplicado de cada área (therapist, admin, configuracoes, account, supervisor, billing, features) e consolida sob um único pai no router/index.js. Adiciona RouterPassthrough para grupos de rota sem layout intermediário. Remove debug ativo (console.trace em router.push e queries Supabase em todo watch de rota) que degradava performance para todos os usuários. 2026-03-25 12:14:43 -03:00
Leonardo
bfe148ef12 safe point before auto-import cleanup 2026-03-25 09:11:05 -03:00
Leonardo
3f1786c9bf + Menu Hover no Layout Rail, Twilio, Sms, Email, Templates, LNovo Layout Configurações 2026-03-25 08:39:45 -03:00
Leonardo
53a4980396 Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras 2026-03-24 21:26:58 -03:00
Leonardo
a89d1f5560 Copyright, Financeiro, Lançamentos, aprimoramentos de ui 2026-03-21 08:05:40 -03:00
Leonardo
29ed349cf2 Agenda google, avisos globais, feriados + avisos globais, templates de email, configuracoes empresa, preview empresa. 2026-03-18 15:47:37 -03:00
Leonardo
d6d2fe29d1 carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline 2026-03-18 09:26:09 -03:00
Leonardo
66f67cd40f Layout 100%, Notificações, SetupWizard 2026-03-17 21:08:14 -03:00
Leonardo
84d65e49c0 Sistema de Suporte , Documentação 2026-03-16 09:41:18 -03:00
Leonardo
f66f6f3fde Ajuste Layout, Dashboard Terapeuta, Timeline, Suporte técnico, Documentação e FAQ 2026-03-15 19:46:06 -03:00
Leonardo
ee09b30987 Setup Wizard 2026-03-14 19:09:44 -03:00
Leonardo
587079e414 Ajuste Convenios e Particular 2026-03-13 21:09:34 -03:00
Leonardo
06fb369beb Preficicação, Convenio, Ajustes Agenda, Configurações Excessões 2026-03-13 16:03:08 -03:00
Leonardo
f4b185ae17 Agenda, Agendador, Configurações 2026-03-12 08:58:36 -03:00
Leonardo
f733db8436 ZERADO 2026-03-06 06:37:13 -03:00
Leonardo
d58dc21297 Ajuste rotas, Menus, Layout, Permissãoes UserRoleGuard 2026-02-24 12:04:59 -03:00
Leonardo
b1c0cb47c0 Ajuste usuarios - Inicio agenda 2026-02-23 18:57:40 -03:00
Leonardo
89b4ecaba1 Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda 2026-02-22 17:56:01 -03:00
Leonardo
6eff67bf22 route 2026-02-19 13:09:44 -03:00
Leonardo
62e79e243a assets 2026-02-19 13:03:15 -03:00
Leonardo
3a671b1e9e assets 2026-02-19 12:59:03 -03:00
Leonardo
b3bb817e3f commit 2026-02-19 12:53:21 -03:00
Leonardo
676042268b first commit 2026-02-18 22:36:45 -03:00
Cagatay Civici
ec6b6ef53a set version as 5 2026-02-02 21:49:03 +03:00
tugcekucukoglu
76a3b60333 cchore: update PrimeVue version 2026-01-30 17:51:18 +03:00
tugcekucukoglu
410c08d693 chore: layout config updates 2025-12-25 10:03:32 +03:00
tugcekucukoglu
a4b2c96b0d submodule added 2025-12-25 09:09:55 +03:00
tugcekucukoglu
a47200fdf7 remove assets 2025-12-25 09:07:45 +03:00
tugcekucukoglu
7c32ae1f6f chore: remove sass warnings 2025-12-09 14:05:01 +03:00
Atakan
db99863fac update transitions & dependencies 2025-12-08 14:36:22 +03:00
Atakan
c2ef85fcab update for tw v4 2025-12-04 12:26:25 +03:00
Atakan
deea8861f8 update 2025-11-18 05:16:32 +03:00
477 changed files with 321009 additions and 5974 deletions

View File

@@ -0,0 +1,53 @@
{
"permissions": {
"allow": [
"Bash(node:*)",
"Bash(powershell:*)",
"Bash(grep:*)",
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && sed -i \\\\\n 's/console\\\\.time\\(tlabel\\)/const _perfEnd = logPerf\\('\\\\''router.guard'\\\\'', tlabel\\)/g' \\\\\n src/router/guards.js && echo \"console.time substituído\")",
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && sed -i \\\\\n 's/console\\\\.timeEnd\\(tlabel\\)/_perfEnd\\(\\)/g' \\\\\n src/router/guards.js && echo \"console.timeEnd substituído\")",
"Bash(cd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && npm install --save-dev vitest @vitest/ui 2>&1 | tail -5)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | head -10)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | xargs grep -l \"agenda_eventos\" | head -3)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS/2026-03-11\" -name \"*.sql\" -type f 2>/dev/null | head -3)",
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai\" -type f -name \"*.sql\" 2>/dev/null | xargs grep -l \"agenda_eventos\" 2>/dev/null | head -5)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src -name \"*[Pp]ricing*\" -o -name \"*[Pp]reco*\" -o -name \"*[Vv]alor*\" 2>/dev/null | head -20)",
"Bash(where python:*)",
"Bash(cd \"/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport zipfile\nimport xml.etree.ElementTree as ET\n\nfor fname in ['spec-wizard.docx', 'spec-v2.docx']:\n print\\('=== ' + fname + ' ==='\\)\n try:\n with zipfile.ZipFile\\(fname, 'r'\\) as z:\n with z.open\\('word/document.xml'\\) as f:\n tree = ET.parse\\(f\\)\n root = tree.getroot\\(\\)\n texts = []\n for para in root.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'\\):\n parts = []\n for t in para.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'\\):\n if t.text:\n parts.append\\(t.text\\)\n line = ''.join\\(parts\\)\n texts.append\\(line\\)\n print\\('\\\\n'.join\\(texts\\)\\)\n except Exception as e:\n print\\('Error: ' + str\\(e\\)\\)\n print\\(\\)\n\")",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nwith open\\('/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql', 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nprint\\(f'Total lines: {len\\(lines\\)}'\\)\n\" 2>&1)",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(npx vite:*)",
"Bash(powershell -Command \"$content = [System.IO.File]::ReadAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', [System.Text.Encoding]::UTF8\\); $content = $content -replace [char]0x201C, ''\"\"'' -replace [char]0x201D, ''\"\"''; [System.IO.File]::WriteAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', $content, [System.Text.Encoding]::UTF8\\)\")",
"Bash(xargs cat:*)",
"Bash(xxd \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-21/schema.sql\")",
"Bash(iconv -f UTF-16LE -t UTF-8 \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-21/schema.sql\")",
"Bash(mkdir -p docs/billing docs/planos docs/subscription-health docs/estrategia docs/specs)",
"Bash(mkdir -p database/backups database/migrations database/seeds database/fixes database/snippets)",
"Bash(cd:*)",
"Bash(mv disparando-whatsapp-local.md docs/whatsapp.md)",
"Bash(mv comandos.txt docs/)",
"Bash(mv dados-padrões-da-agenda.txt docs/)",
"Bash(mv USER_ARCHETYPES.html docs/)",
"Bash(mv Novo-DB/migration_*.sql database/migrations/)",
"Bash(mv Novo-DB/seed_*.sql database/seeds/)",
"Bash(mv Novo-DB/fix_*.sql database/fixes/)",
"Bash(npm install:*)",
"Bash(cp:*)",
"Bash(npm uninstall:*)",
"Bash(rm:*)",
"Bash(docker ps:*)",
"Bash(docker exec:*)",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/schema.sql\")",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/data.sql\")",
"Bash(\"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/database-novo/backups/2026-03-23/full_dump.sql\")",
"Bash(wc:*)",
"Bash(python _wizard_patch.py)",
"Bash(rm _wizard_patch.py)",
"Bash(npm ls:*)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src/features/patients -type f \\\\\\(-name *.vue -o -name *.js \\\\\\))"
]
}
}

2
.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH

4
.env.local Normal file
View File

@@ -0,0 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
VITE_QA_MODE=true
VITE_QA_PASS=123Mudar@

8
.gitignore vendored
View File

@@ -5,10 +5,14 @@ coverage
.nitro .nitro
.cache .cache
.output .output
.env # .env
dist dist/
dist-*/
.DS_Store .DS_Store
.idea .idea
.eslintcache .eslintcache
api-generator/typedoc.json api-generator/typedoc.json
**/.DS_Store **/.DS_Store
Dev-documentacao/
supabase/
evolution-api/

15
.hintrc Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": [
"development"
],
"hints": {
"compat-api/css": [
"default",
{
"ignore": [
"background-color: color-mix(in srgb, var(--primary-color) 50%, transparent)"
]
}
]
}
}

31
ARCHITECTURE_NOTES.md Normal file
View File

@@ -0,0 +1,31 @@
### Observação sobre `tenant_admin` com UUID coincidente
Foi identificado que o registro de `tenant_members` possui:
- `tenant_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
- `user_id = 816b24fe-a0c3-4409-b79b-c6c0a6935d03`
- `role = tenant_admin`
À primeira vista pode parecer inconsistência, mas não é.
Verificação realizada:
O UUID `816b24fe-a0c3-4409-b79b-c6c0a6935d03` existe em `auth.users`
(email: admin@agenciapsi.com.br).
Portanto:
- `tenant_members.user_id` referencia corretamente `auth.users.id`
- Não há violação de integridade referencial
- O registro é válido
Trata-se de um caso em que:
- O usuário administrador principal possui um UUID específico
- O tenant foi criado com o mesmo UUID
- O administrador é `tenant_admin` desse próprio tenant
Esse padrão não quebra a arquitetura multi-tenant e é funcionalmente válido.
A coincidência entre `tenant_id` e `user_id` é apenas estrutural, não conceitual.
Conclusão:
Nenhuma correção estrutural é necessária.

274
AUDITORIA.md Normal file
View File

@@ -0,0 +1,274 @@
# Auditoria Técnica — AgenciaPsi MVP
**Data:** 2026-03-11
**Stack:** Vue 3 · PrimeVue · Supabase · PostgreSQL · FullCalendar
**Modelo:** claude-sonnet-4-6
---
## 1. Visão Geral Arquitetural
**Pontos fortes:**
- Estrutura feature-based bem definida (`features/agenda`, `features/patients`)
- Separação correta: repository → composable → page
- Multi-tenancy é first-class: `tenant_id` em todos os queries críticos
- Sistema de guards robusto: RBAC + entitlements + tenantFeatures + session race-condition handling
- `useRecurrence` é bem arquitetado: virtual occurrences no frontend + exceções no banco (sem N linhas futuras)
---
## 2. Bugs Críticos
### ✅ [RESOLVIDO] `useRecurrence.js` — variável `occurrenceCount` não declarada
**Bug original:** branches `custom_weekdays`, `monthly` e `yearly` usavam `occurrenceCount` sem declará-la → `ReferenceError` em runtime. Nenhum dos três contava ocorrências anteriores ao range, então `max_occurrences` nunca funcionava corretamente.
**Correção (Sessão 2):** Cada branch ganhou `let occurrenceCount = 0` + fase de pré-contagem de `ruleStart` até `effStart`.
**Resolvido em:** Sessão 2 — 2026-03-11
---
### ✅ [RESOLVIDO] Exceção de remarcação fora do range não aparece
**Bug original:** `loadExceptions` só buscava `original_date` no range. Se `original_date` estivesse fora mas `new_date` caísse dentro, a sessão remarcada não aparecia.
**Correção (Sessão 3):**
- `loadExceptions`: duas queries paralelas — `q1` (original_date no range) + `q2` (reschedule com new_date no range). Mescladas e deduplicadas por `id`.
- `expandRules` post-pass: itera exceções não consumidas (`handledExIds`), injeta inbound reschedules com `buildOccurrence(rule, newDate, ex.original_date, ex)`.
**Resolvido em:** Sessão 3 — 2026-03-11
---
## 3. Segurança
### ✅ [RESOLVIDO] SQL dumps no repositório
**Arquivos:** `schema.sql`, `backup.sql`, `data_dump.sql`, `full_dump.sql`
Verificado via `git log --all --full-history` — arquivos **nunca foram commitados**. Movidos para pasta externa ao repositório. Nenhuma purga de histórico necessária.
**Resolvido em:** Sessão 3 — 2026-03-11
---
### ✅ [RESOLVIDO] `useAgendaEvents` — sem `tenant_id` em nenhuma operação
**Correção (Sessão 2):** `tenant_id` injetado em `create()`, `loadMyRange()`, `update()`, `remove()`, `removeSeriesFrom()`, `removeAllSeries()`. Helpers `assertTenantId()` e `getUid()` adicionados.
**Resolvido em:** Sessão 2 — 2026-03-11
---
### ✅ [RESOLVIDO] `loadRules` em `useRecurrence` sem filtro `tenant_id`
**Correção (Sessão 2):** `loadRules` e `loadAndExpand` aceitam `tenantId` opcional e aplicam `.eq('tenant_id', tenantId)`. Call site em `AgendaTerapeutaPage._reloadRange` passa `tenantStore.activeTenantId`.
**Resolvido em:** Sessão 2 — 2026-03-11
---
### ✅ [RESOLVIDO] `console.log` expõe dados de pacientes no browser
**Correção (Sessão 2):** Todos os `console.*` substituídos pelo `supportLogger`. Logs só aparecem quando modo suporte está ativo (token válido no banco).
**Resolvido em:** Sessão 2 — 2026-03-11
**Arquivos criados:** `src/support/supportLogger.js`, `src/support/supportDebugStore.js`
---
### 🟡 [ABERTO] `window.__guardsBound` / `window.__supabaseAuthListenerBound`
Usar `window.*` para controle de listeners é frágil em hot-reload.
**Solução:** Gerenciar via módulo singleton ou `app.config.globalProperties`.
---
### ✅ [RESOLVIDO] `globalRole` do `profiles` sem cache no guard
Adicionados `globalRoleCacheUid` + `globalRoleCache` no `guards.js`.
Cache invalida em: uid change, SIGNED_OUT, SIGNED_IN com user diferente.
Query ao banco ocorre apenas na primeira navegação por sessão.
**Resolvido em:** Sessão 4 — 2026-03-11
---
## 4. Duplicações e Inconsistências
### ✅ [RESOLVIDO] Dois composables para a mesma entidade
`src/composables/useAgendaEvents.js` era código morto (sem imports). Deletado.
O autoritativo `src/features/agenda/composables/useAgendaEvents.js` permanece.
**Resolvido em:** Sessão 3 — 2026-03-11
---
### ✅ [RESOLVIDO] Dois mappers para agenda
`src/features/agenda/domain/agenda.mappers.js` estava vazio e sem imports. Deletado.
`src/features/agenda/domain/agenda.types.js` também sem imports. Deletado. Diretório `domain/` removido.
O autoritativo `src/features/agenda/services/agendaMappers.js` permanece.
**Resolvido em:** Sessão 4 — 2026-03-11
---
### ✅ [RESOLVIDO] N+1 Query — migração `paciente_id` → `patient_id`
**Resolvido em:** Sessão 3 — 2026-03-11
**Migration executada:** `migrations/unify_patient_id.sql`
- UPDATE copiou `paciente_id``patient_id` onde null (resultado: 0 órfãos — todos já tinham `patient_id`)
- `ALTER TABLE agenda_eventos DROP COLUMN paciente_id` executado com sucesso
**Código atualizado:**
- `useAgendaEvents.js`: `paciente_id` removido do `BASE_SELECT`; `create()`/`update()` stripam `paciente_id` do payload
- `agendaRepository.js`: workaround N+1 de orphan ids removido
- `agendaMappers.js`: `paciente_id` agora é alias de `patient_id` (UI only)
- `AgendaTerapeutaPage.vue` + `AgendaClinicaPage.vue`: `pickDbFields` usa `patient_id`
- `AgendamentosRecebidosPage.vue`: `dbFields` removeu `paciente_id`
- `PatientsListPage.vue` + `AgendaEventDialog.vue`: `.or()``.eq('patient_id', id)`
---
## 5. Limpeza Necessária
### ✅ [RESOLVIDO] Template Sakai removido — bundle de produção
**Resolvido em:** Sessão 3 — 2026-03-11
**Removidos:** `src/views/uikit/` (15 arquivos), `src/views/utilities/Blocks.vue`, `src/components/BlockViewer.vue`, `src/components/FloatingConfigurator.vue`, `src/views/pages/Documentation.vue`, `src/assets/demo/`, `src/navigation/menus/sakai.demo.menu.js`, `src/router/routes.demo.js`, `src/assets/styles.scss` (@use demo removido)
**Referências limpas:** `package.json` renomeado para `agenciapsi`, demoRoutes e sakaiDemoMenu removidos dos index files, `FloatingConfigurator` removido de Login, NotFound, Access, Error, ResetPasswordPage.
---
### 🟡 [PARCIAL] Arquivos obsoletos no projeto
**Deletados (Sessão 4):**
- `src/layout/ConfiguracoesPage-old.vue`
- `src/features/agenda/domain/` (diretório inteiro — 2 arquivos não usados)
**Ainda presentes:**
- `src/layout/ConfiguracoesPage - Copia.vue` — verificar se está no git (staged como D)
- `src/views/pages/public/Landingpage-v1 - bkp.vue`
- `comandos.txt` (na raiz)
---
### ✅ [RESOLVIDO] Logs excessivos em produção
`console.time/timeLog/timeEnd/warn/error` em `guards.js` substituídos por `logGuard()`, `logError()`, `logPerf()`.
**Resolvido em:** Sessão 2 — 2026-03-11
---
## 6. Status das Features do MVP
| Feature | Status | Observação |
|---|---|---|
| Agenda de sessões | ✅ Implementado | FullCalendar + composables |
| Cadastro de pacientes | ✅ Implementado | CRUD completo |
| Recorrência de sessões | ✅ Corrigido | Bugs de occurrenceCount e cross-range resolvidos |
| Sessões presenciais/online | ✅ Implementado | campo `modalidade` |
| Controle de faltas | ✅ Implementado | `exception_type = 'patient_missed'` |
| Remarcação | ✅ Corrigido | Bug cross-range resolvido (Sessão 3) |
| Bloqueio de agenda | ✅ Implementado | `BloqueioDialog.vue` |
| Agendamento online | ✅ Implementado | `AgendadorPublicoPage.vue` |
| Prontuário | ✅ Integrado | Seção "Sessões" adicionada ao `PatientProntuario.vue` |
| Notificações/lembretes | ❌ Não implementado | Sem trigger/edge function |
| Financeiro/faturamento | ⚠️ Parcial | Páginas de plano mas sem sessão→pagamento |
| Relatórios | ✅ Implementado | `RelatoriosPage.vue` — terapeuta — sessões, faltas, taxa, gráfico |
---
## 7. Backlog Técnico
- [ ] Cache de `globalRole` no guard (reduzir queries por navegação)
- [ ] Implementar notificações: WhatsApp/Email via Supabase Edge Functions
- [ ] Integração prontuário ↔ sessões
- [ ] Integração sessão ↔ pagamento (financeiro)
- [ ] Relatórios básicos: sessões realizadas, faltas, receita
- [ ] Migrar domínio de recorrência para TypeScript
- [ ] Consolidar dois mappers de agenda (`agendaMappers.js` vs `domain/agenda.mappers.js`)
- [ ] Remover arquivos obsoletos (ConfiguracoesPage-old, Landingpage-v1 bkp, etc.)
---
## 8. Prioridades de Ação
### ✅ Fazer AGORA — todos concluídos
1. `[x]` ~~Remover dumps SQL~~ → nunca commitados, movidos para fora do repo
2. `[x]` ~~Corrigir bug `occurrenceCount`~~ → pré-contagem em todos os branches
3. `[x]` ~~Adicionar `tenant_id` ao `useAgendaEvents` e `loadRules`~~ → injetado em todas as operações
4. `[x]` ~~Remover `console.log` com dados de pacientes~~`supportLogger`
### ✅ Fazer em seguida — todos concluídos
5. `[x]` ~~Corrigir bug remarcação cross-range~~ → 2 queries + post-pass em `expandRules`
6. `[x]` ~~Consolidar dois `useAgendaEvents`~~ → legado deletado
7. `[x]` ~~Unificar `paciente_id` + `patient_id`~~ → migration executada + código limpo
8. `[x]` ~~Remover Sakai de demo~~ → removido + menu SaaS limpo
### Backlog
9. `[x]` Cache de `globalRole` no guard — `globalRoleCacheUid/globalRoleCache` em guards.js
10. `[ ]` Notificações (WhatsApp/Email via Edge Functions) ← próximo
11. `[x]` Integração prontuário ↔ sessões — seção "Sessões" em `PatientProntuario.vue`
12. `[x]` Relatórios básicos — `RelatoriosPage.vue` em /therapist/relatorios
13. `[x]` Consolidar mappers de agenda — `domain/` deletado, `agendaMappers.js` é único
---
## 9. Sistema de Suporte Técnico SaaS
Sistema seguro para admins SaaS acessarem a agenda de terapeutas em modo debug.
| Arquivo | Responsabilidade |
|---|---|
| `migrations/support_sessions.sql` | Tabela, índices, RLS, RPCs (token gerado via `gen_random_uuid()` duplo — sem pgcrypto) |
| `src/support/supportLogger.js` | Logger centralizado — silencioso fora do modo suporte |
| `src/support/supportDebugStore.js` | Store Pinia — valida token via RPC `validate_support_session` |
| `src/support/supportSessionService.js` | CRUD de sessões de suporte (criar/listar/revogar) |
| `src/support/components/SupportDebugBanner.vue` | Banner fixo na agenda com painel de logs filtráveis |
| `src/views/pages/saas/SaasSupportPage.vue` | Painel SaaS para gerenciar sessões de suporte |
**RPCs no banco:**
- `create_support_session(p_tenant_id, p_ttl_minutes)``{ token, expires_at, session_id }`
- `validate_support_session(p_token)``{ valid, tenant_id }`
- `revoke_support_session(p_token)``boolean`
---
## 10. Histórico de Sessões
### Sessão 1 — 2026-03-11
- Auditoria técnica completa gerada
- Nenhum item resolvido
### Sessão 2 — 2026-03-11
- Sistema de suporte técnico SaaS implementado (migration + 5 arquivos criados)
- Bug `occurrenceCount` corrigido (itens 2 e 4)
- `tenant_id` adicionado ao `useAgendaEvents` e `loadRules` (item 3)
- `console.*` substituídos por `supportLogger`
### Sessão 4 — 2026-03-11
- Cache `globalRole` adicionado ao guard (item 9) — sem query ao banco por navegação
- Integração prontuário ↔ sessões (item 11) — painel "Sessões" em `PatientProntuario.vue`
- `RelatoriosPage.vue` criada em `/therapist/relatorios` (item 12) — cards, gráfico Chart.js, tabela DataTable
- Consolidação mappers (item 13) — `domain/agenda.mappers.js` vazio deletado + `agenda.types.js` + dir `domain/`
- `ConfiguracoesPage-old.vue` deletado (limpeza)
### Sessão 3 — 2026-03-11
- Bug remarcação cross-range resolvido (item 5)
- `logPerf is not defined` em guards.js corrigido
- `pgcrypto` → substituído por `gen_random_uuid()` duplo no support_sessions
- Sakai demo removido completamente (item 8) + `styles.scss` corrigido
- `useAgendaEvents` legado deletado (item 6)
- `paciente_id` unificado em `patient_id` — migration executada (item 7)
- SQL dumps confirmados como nunca commitados (item 1 encerrado)
---
*Para retomar: devolva este arquivo ao início da conversa e indique qual item quer atacar.*

Binary file not shown.

Binary file not shown.

View File

@@ -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 #### `profiles`
- Migrate sass from @import to @use - ✅ 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 #### `plans`
- Add border to overlay menu - Adicionado `patient` como valor válido em `target`
- Animation for mobile mask - ✅ Inserido plano `patient_free` (gratuito, target=patient)
- Fixed chart colors
## 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. ### O que precisa ser aplicado no banco
- Update assets style files
- Remove code highlight
**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 ## Futuro — registrado mas não implementado
- Upgrade to vite 4.2.1
### 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*

232
TESTES.md Normal file
View File

@@ -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 */)
})
```

View File

@@ -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` |

View File

@@ -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 |

View File

@@ -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`)

119
database-novo/README.md Normal file
View File

@@ -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

733
database-novo/db.cjs Normal file
View File

@@ -0,0 +1,733 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Database CLI
// =============================================================================
// Uso: node db.cjs <comando> [opcoes]
//
// Comandos:
// setup Instalação do zero (schema + seeds)
// backup Exporta backup com data atual
// restore [data] Restaura de um backup (ex: 2026-03-23)
// migrate Aplica migrations pendentes
// seed [grupo] Roda seeds (all|users|system|test_data)
// status Mostra estado atual do banco
// diff Compara schema atual vs último backup
// reset Reseta o banco e reinstala tudo
// verify Verifica integridade dos dados essenciais
// help Mostra ajuda
// =============================================================================
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const ROOT = __dirname;
const CONFIG = JSON.parse(fs.readFileSync(path.join(ROOT, 'db.config.json'), 'utf8'));
const CONTAINER = CONFIG.container;
const DB = CONFIG.database;
const USER = CONFIG.user;
// ---------------------------------------------------------------------------
// Colors (sem dependências externas)
// ---------------------------------------------------------------------------
const c = {
reset: '\x1b[0m',
bold: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m'
};
function log(msg) {
console.log(msg);
}
function info(msg) {
log(`${c.cyan}${c.reset} ${msg}`);
}
function ok(msg) {
log(`${c.green}${c.reset} ${msg}`);
}
function warn(msg) {
log(`${c.yellow}${c.reset} ${msg}`);
}
function err(msg) {
log(`${c.red}${c.reset} ${msg}`);
}
function title(msg) {
log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`);
}
function step(msg) {
log(`${c.gray}${c.reset} ${msg}`);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function dockerRunning() {
try {
const result = spawnSync('docker', ['inspect', '-f', '{{.State.Running}}', CONTAINER], {
encoding: 'utf8',
timeout: 10000,
stdio: ['pipe', 'pipe', 'pipe']
});
return result.stdout.trim() === 'true';
} catch {
return false;
}
}
function psql(sql, opts = {}) {
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} ${opts.tuples ? '-t' : ''} ${opts.quiet ? '-q' : ''} -c "${sql.replace(/"/g, '\\"')}"`;
return execSync(cmd, { encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } }).trim();
}
function psqlFile(filePath) {
const absPath = path.resolve(filePath);
const content = fs.readFileSync(absPath, 'utf8');
// Prepend SET client_encoding to ensure UTF-8 inside the session
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
return execSync(cmd, { input: utf8Content, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } });
}
function pgDump(args) {
const cmd = `docker exec -e PGCLIENTENCODING=UTF8 ${CONTAINER} pg_dump -U ${USER} -d ${DB} ${args}`;
return execSync(cmd, { encoding: 'utf8', timeout: 120000, maxBuffer: 50 * 1024 * 1024 });
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function fileHash(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function requireDocker() {
if (!dockerRunning()) {
err(`Container "${CONTAINER}" não está rodando.`);
log(`\n Inicie o Supabase primeiro:`);
log(` ${c.cyan}npx supabase start${c.reset}\n`);
process.exit(1);
}
}
function listBackups() {
const dir = path.join(ROOT, 'backups');
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
}
// ---------------------------------------------------------------------------
// Migration tracking table
// ---------------------------------------------------------------------------
function ensureMigrationTable() {
psql(`
CREATE TABLE IF NOT EXISTS _db_migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'migration',
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
}
function getAppliedMigrations() {
ensureMigrationTable();
const result = psql('SELECT filename, hash, category, applied_at::text FROM _db_migrations ORDER BY id;', { tuples: true });
if (!result) return [];
return result
.split('\n')
.filter(Boolean)
.map((line) => {
const [filename, hash, category, applied_at] = line.split('|').map((s) => s.trim());
return { filename, hash, category, applied_at };
});
}
function recordMigration(filename, hash, category) {
psql(`INSERT INTO _db_migrations (filename, hash, category) VALUES ('${filename}', '${hash}', '${category}') ON CONFLICT (filename) DO UPDATE SET hash = EXCLUDED.hash, applied_at = now();`);
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
const commands = {};
// ---- SETUP ----
commands.setup = function () {
title('Setup — Instalação do zero');
requireDocker();
// 1. Schema
const schemaFile = path.join(ROOT, CONFIG.schema);
if (!fs.existsSync(schemaFile)) {
err(`Schema não encontrado: ${schemaFile}`);
process.exit(1);
}
info('Aplicando schema...');
psqlFile(schemaFile);
ok('Schema aplicado');
// 2. Fixes
info('Aplicando fixes...');
for (const fix of CONFIG.fixes) {
const fixPath = path.join(ROOT, 'fixes', fix);
if (fs.existsSync(fixPath)) {
step(fix);
psqlFile(fixPath);
}
}
ok(`${CONFIG.fixes.length} fixes aplicados`);
// 3. Seeds
commands.seed('all');
// 4. Migration table
ensureMigrationTable();
// 5. Record seeds as applied
const allSeeds = [...CONFIG.seeds.users, ...CONFIG.seeds.system];
for (const seed of allSeeds) {
const seedPath = path.join(ROOT, 'seeds', seed);
if (fs.existsSync(seedPath)) {
recordMigration(seed, fileHash(seedPath), 'seed');
}
}
for (const fix of CONFIG.fixes) {
const fixPath = path.join(ROOT, 'fixes', fix);
if (fs.existsSync(fixPath)) {
recordMigration(fix, fileHash(fixPath), 'fix');
}
}
ok('Setup completo!');
log('');
// 6. Auto-backup
info('Criando backup pós-setup...');
commands.backup();
// 7. Verify
commands.verify();
};
// ---- BACKUP ----
commands.backup = function () {
title('Backup');
requireDocker();
const date = today();
const dir = path.join(ROOT, 'backups', date);
ensureDir(dir);
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics'];
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
step('Exportando schema...');
const schema = pgDump('--schema-only --no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'schema.sql'), schema);
step('Exportando dados...');
const data = pgDump(`--data-only --no-owner --no-privileges ${excludeFlags}`);
fs.writeFileSync(path.join(dir, 'data.sql'), data);
step('Exportando dump completo...');
const full = pgDump('--no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'full_dump.sql'), full);
const sizes = ['schema.sql', 'data.sql', 'full_dump.sql'].map((f) => {
const stat = fs.statSync(path.join(dir, f));
return `${f}: ${(stat.size / 1024).toFixed(0)}KB`;
});
ok(`Backup salvo em backups/${date}/`);
sizes.forEach((s) => step(s));
// Cleanup old backups
cleanupBackups();
};
function cleanupBackups() {
const backups = listBackups();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - CONFIG.backupRetentionDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
let removed = 0;
for (const b of backups) {
if (b < cutoffStr) {
const dir = path.join(ROOT, 'backups', b);
fs.rmSync(dir, { recursive: true, force: true });
removed++;
}
}
if (removed > 0) {
info(`${removed} backup(s) antigo(s) removido(s) (>${CONFIG.backupRetentionDays} dias)`);
}
}
// ---- RESTORE ----
commands.restore = function (dateArg) {
title('Restore');
requireDocker();
const backups = listBackups();
if (backups.length === 0) {
err('Nenhum backup encontrado.');
process.exit(1);
}
const date = dateArg || backups[0];
const dir = path.join(ROOT, 'backups', date);
if (!fs.existsSync(dir)) {
err(`Backup não encontrado: ${date}`);
log(`\n Backups disponíveis:`);
backups.forEach((b) => log(` ${c.cyan}${b}${c.reset}`));
process.exit(1);
}
const fullDump = path.join(dir, 'full_dump.sql');
const schemaFile = path.join(dir, 'schema.sql');
const dataFile = path.join(dir, 'data.sql');
// Safety backup before restore
info('Criando backup de segurança antes do restore...');
try {
commands.backup();
} catch {
warn('Não foi possível criar backup de segurança (banco pode estar vazio)');
}
if (fs.existsSync(fullDump)) {
info(`Restaurando de backups/${date}/full_dump.sql ...`);
// Drop and recreate public schema
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando full dump...');
psqlFile(fullDump);
} else if (fs.existsSync(schemaFile) && fs.existsSync(dataFile)) {
info(`Restaurando de backups/${date}/ (schema + data)...`);
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando schema...');
psqlFile(schemaFile);
step('Aplicando dados...');
psqlFile(dataFile);
} else {
err(`Backup incompleto em ${date}/`);
process.exit(1);
}
ok(`Banco restaurado de backups/${date}/`);
// Verify
commands.verify();
};
// ---- MIGRATE ----
commands.migrate = function () {
title('Migrate');
requireDocker();
ensureMigrationTable();
const migrationsDir = path.join(ROOT, 'migrations');
if (!fs.existsSync(migrationsDir)) {
info('Nenhuma pasta migrations/ encontrada.');
return;
}
const files = fs
.readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort();
if (files.length === 0) {
info('Nenhuma migration encontrada.');
return;
}
const applied = getAppliedMigrations().map((m) => m.filename);
const pending = files.filter((f) => !applied.includes(f));
if (pending.length === 0) {
ok('Todas as migrations já foram aplicadas.');
return;
}
// Auto-backup before migrating
info('Criando backup antes de migrar...');
commands.backup();
info(`${pending.length} migration(s) pendente(s):`);
for (const file of pending) {
step(`Aplicando ${file}...`);
const filePath = path.join(migrationsDir, file);
try {
psqlFile(filePath);
recordMigration(file, fileHash(filePath), 'migration');
ok(` ${file}`);
} catch (e) {
err(` FALHA em ${file}: ${e.message}`);
err('Migration abortada. Banco pode estar em estado parcial.');
err('Use "node db.cjs restore" para voltar ao backup.');
process.exit(1);
}
}
ok(`${pending.length} migration(s) aplicada(s)`);
};
// ---- SEED ----
commands.seed = function (group) {
const validGroups = ['all', 'users', 'system', 'test_data'];
if (!group) group = 'all';
if (!validGroups.includes(group)) {
err(`Grupo inválido: ${group}`);
log(` Grupos válidos: ${validGroups.join(', ')}`);
process.exit(1);
}
title(`Seeds — ${group}`);
requireDocker();
const groups = group === 'all' ? ['users', 'system'] : [group];
let total = 0;
for (const g of groups) {
const seeds = CONFIG.seeds[g];
if (!seeds || seeds.length === 0) continue;
info(`Grupo: ${g}`);
for (const seed of seeds) {
const seedPath = path.join(ROOT, 'seeds', seed);
if (!fs.existsSync(seedPath)) {
warn(` Arquivo não encontrado: ${seed}`);
continue;
}
step(seed);
try {
psqlFile(seedPath);
total++;
} catch (e) {
err(` FALHA em ${seed}: ${e.stderr || e.message}`);
process.exit(1);
}
}
}
ok(`${total} seed(s) aplicado(s)`);
};
// ---- STATUS ----
commands.status = function () {
title('Status');
requireDocker();
// Docker
ok(`Container: ${CONTAINER} (rodando)`);
// Backups
const backups = listBackups();
if (backups.length > 0) {
ok(`Último backup: ${backups[0]}`);
info(`Total de backups: ${backups.length}`);
} else {
warn('Nenhum backup encontrado');
}
// Migrations
try {
const applied = getAppliedMigrations();
if (applied.length > 0) {
info(`Migrations aplicadas: ${applied.length}`);
applied.slice(-5).forEach((m) => {
step(`${m.filename} ${c.gray}(${m.category}, ${m.applied_at})${c.reset}`);
});
}
// Pending
const migrationsDir = path.join(ROOT, 'migrations');
if (fs.existsSync(migrationsDir)) {
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
const pending = files.filter((f) => !applied.map((m) => m.filename).includes(f));
if (pending.length > 0) {
warn(`${pending.length} migration(s) pendente(s):`);
pending.forEach((f) => step(`${c.yellow}${f}${c.reset}`));
}
}
} catch {
info('Tabela _db_migrations não existe (rode setup primeiro)');
}
// DB counts
log('');
info('Dados no banco:');
const counts = [
['auth.users', 'SELECT count(*) FROM auth.users'],
['profiles', 'SELECT count(*) FROM profiles'],
['tenants', 'SELECT count(*) FROM tenants'],
['plans', 'SELECT count(*) FROM plans'],
['features', 'SELECT count(*) FROM features'],
['plan_features', 'SELECT count(*) FROM plan_features'],
['subscriptions', 'SELECT count(*) FROM subscriptions'],
['email_templates_global', 'SELECT count(*) FROM email_templates_global'],
['notification_templates', 'SELECT count(*) FROM notification_templates']
];
for (const [label, sql] of counts) {
try {
const count = psql(sql, { tuples: true }).trim();
const color = parseInt(count) > 0 ? c.green : c.red;
step(`${label}: ${color}${count}${c.reset}`);
} catch {
step(`${label}: ${c.gray}(tabela não existe)${c.reset}`);
}
}
};
// ---- DIFF ----
commands.diff = function () {
title('Diff — Schema');
requireDocker();
const backups = listBackups();
if (backups.length === 0) {
err('Nenhum backup para comparar. Rode "node db.cjs backup" primeiro.');
process.exit(1);
}
const lastBackup = backups[0];
const lastSchemaPath = path.join(ROOT, 'backups', lastBackup, 'schema.sql');
if (!fs.existsSync(lastSchemaPath)) {
err(`Schema não encontrado no backup ${lastBackup}`);
process.exit(1);
}
info('Exportando schema atual...');
const currentSchema = pgDump('--schema-only --no-owner --no-privileges');
const lastSchema = fs.readFileSync(lastSchemaPath, 'utf8');
// Extract table definitions for comparison
const extractTables = (sql) => {
const tables = {};
const regex = /CREATE TABLE (?:IF NOT EXISTS )?(\S+)\s*\(([\s\S]*?)\);/g;
let match;
while ((match = regex.exec(sql)) !== null) {
tables[match[1]] = match[2].trim();
}
return tables;
};
const currentTables = extractTables(currentSchema);
const lastTables = extractTables(lastSchema);
const allTables = new Set([...Object.keys(currentTables), ...Object.keys(lastTables)]);
let added = 0,
removed = 0,
changed = 0,
unchanged = 0;
for (const table of [...allTables].sort()) {
if (!lastTables[table]) {
log(` ${c.green}+ ${table}${c.reset} (nova)`);
added++;
} else if (!currentTables[table]) {
log(` ${c.red}- ${table}${c.reset} (removida)`);
removed++;
} else if (currentTables[table] !== lastTables[table]) {
log(` ${c.yellow}~ ${table}${c.reset} (alterada)`);
changed++;
} else {
unchanged++;
}
}
log('');
ok(`Comparado com backup de ${lastBackup}:`);
step(`${added} nova(s), ${changed} alterada(s), ${removed} removida(s), ${unchanged} sem mudança`);
};
// ---- RESET ----
commands.reset = function () {
title('Reset — CUIDADO');
requireDocker();
// Safety backup
info('Criando backup antes do reset...');
try {
commands.backup();
} catch {
warn('Não foi possível criar backup');
}
warn('Resetando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
ok('Schema public resetado');
// Re-run setup
commands.setup();
};
// ---- VERIFY ----
commands.verify = function () {
title('Verificação de integridade');
requireDocker();
const checks = [
{ name: 'auth.users', sql: 'SELECT count(*) FROM auth.users', min: 1 },
{ name: 'profiles', sql: 'SELECT count(*) FROM profiles', min: 1 },
{ name: 'tenants', sql: 'SELECT count(*) FROM tenants', min: 1 },
{ name: 'plans', sql: 'SELECT count(*) FROM plans', min: 7 },
{ name: 'features', sql: 'SELECT count(*) FROM features', min: 20 },
{ name: 'plan_features', sql: 'SELECT count(*) FROM plan_features', min: 50 },
{ name: 'subscriptions', sql: 'SELECT count(*) FROM subscriptions', min: 1 },
{ name: 'email_templates', sql: 'SELECT count(*) FROM email_templates_global', min: 10 }
];
let pass = 0,
fail = 0;
for (const check of checks) {
try {
const count = parseInt(psql(check.sql, { tuples: true }).trim());
if (count >= check.min) {
ok(`${check.name}: ${count} (mín: ${check.min})`);
pass++;
} else {
err(`${check.name}: ${count} (esperado ≥ ${check.min})`);
fail++;
}
} catch {
err(`${check.name}: tabela não existe`);
fail++;
}
}
// Check entitlements view
try {
const ent = psql('SELECT count(*) FROM v_tenant_entitlements;', { tuples: true }).trim();
ok(`v_tenant_entitlements: ${ent} registros`);
pass++;
} catch {
err('v_tenant_entitlements: view não existe');
fail++;
}
log('');
if (fail === 0) {
ok(`${c.bold}Todos os ${pass} checks passaram!${c.reset}`);
} else {
err(`${fail} check(s) falharam, ${pass} passaram`);
}
};
// ---- HELP ----
commands.help = function () {
log(`
${c.bold}AgenciaPsi — Database CLI${c.reset}
${c.cyan}Uso:${c.reset} node db.cjs <comando> [opções]
${c.cyan}Comandos:${c.reset}
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds)
Cria backup automático após concluir
${c.bold}backup${c.reset} Exporta banco para backups/YYYY-MM-DD/
Gera: schema.sql, data.sql, full_dump.sql
${c.bold}restore [data]${c.reset} Restaura de um backup
Sem data = último backup disponível
Ex: node db.cjs restore 2026-03-23
${c.bold}migrate${c.reset} Aplica migrations pendentes (pasta migrations/)
Backup automático antes de aplicar
${c.bold}seed [grupo]${c.reset} Roda seeds (all, users, system, test_data)
Ex: node db.cjs seed system
${c.bold}status${c.reset} Mostra estado do banco, backups, migrations
${c.bold}diff${c.reset} Compara schema atual vs último backup
${c.bold}reset${c.reset} Reseta o banco e reinstala tudo do zero
${c.yellow}⚠ Cria backup antes de resetar${c.reset}
${c.bold}verify${c.reset} Verifica integridade dos dados essenciais
${c.bold}help${c.reset} Mostra esta ajuda
${c.cyan}Exemplos:${c.reset}
${c.gray}# Primeira vez — instala tudo${c.reset}
node db.cjs setup
${c.gray}# Backup diário${c.reset}
node db.cjs backup
${c.gray}# Perdi o banco — restaurar${c.reset}
node db.cjs restore
${c.gray}# Nova migration${c.reset}
node db.cjs migrate
${c.gray}# Ver o que tem no banco${c.reset}
node db.cjs status
`);
};
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const [, , cmd, ...args] = process.argv;
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
commands.help();
process.exit(0);
}
if (!commands[cmd]) {
err(`Comando desconhecido: ${cmd}`);
log(` Use ${c.cyan}node db.cjs help${c.reset} para ver os comandos disponíveis.`);
process.exit(1);
}
try {
commands[cmd](...args);
} catch (e) {
err(`Erro: ${e.message}`);
if (process.env.DEBUG) console.error(e);
process.exit(1);
}

View File

@@ -0,0 +1,34 @@
{
"container": "supabase_db_agenciapsi-primesakai",
"database": "postgres",
"user": "postgres",
"backupRetentionDays": 30,
"schema": "schema/00_full/schema.sql",
"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"
],
"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"
]
}

View File

@@ -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.**

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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);

View File

@@ -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()
);

View File

@@ -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 $$;

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,457 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Dashboard Generator
// =============================================================================
// Uso:
// node generate-dashboard.js → usa backup mais recente
// node generate-dashboard.js 2026-03-27 → usa backup de data específica
//
// Lê de: ./database-novo/backups/YYYY-MM-DD/schema.sql
// Gera: ./dashboard.html (na mesma pasta do script)
// =============================================================================
const fs = require('fs');
const path = require('path');
const BACKUPS_DIR = path.join(__dirname, 'backups');
const OUTPUT_FILE = path.join(__dirname, 'dashboard.html');
// ---------------------------------------------------------------------------
// Cores por domínio
// ---------------------------------------------------------------------------
const DOMAIN_COLORS = {
'SaaS / Planos': '#4f8cff',
'Tenants / Multi-tenant': '#6ee7b7',
'Pacientes': '#f472b6',
'Agenda': '#fb923c',
'Financeiro': '#a78bfa',
'Serviços / Commitments': '#34d399',
'Notificações': '#38bdf8',
'Email / Comunicação': '#fbbf24',
'SaaS Admin': '#94a3b8',
};
// ---------------------------------------------------------------------------
// Mapeamento domínio → tabelas
// Adicione novas tabelas aqui quando criar migrations
// ---------------------------------------------------------------------------
const DOMAIN_TABLES = {
'SaaS / Planos': [
'plans','plan_features','plan_prices','plan_public','plan_public_bullets',
'features','modules','module_features','subscriptions','subscription_events',
'subscription_intents_personal','subscription_intents_tenant','subscription_intents_legacy',
'addon_products','addon_credits','addon_transactions',
'tenant_features','tenant_modules','entitlements_invalidation','tenant_feature_exceptions_log',
],
'Tenants / Multi-tenant': [
'tenants','tenant_members','tenant_invites','owner_users',
'profiles','company_profiles','billing_contracts','payment_settings','support_sessions',
],
'Pacientes': [
'patients','patient_groups','patient_group_patient','patient_tags',
'patient_patient_tag','patient_discounts','patient_invites','patient_intake_requests',
],
'Agenda': [
'agenda_eventos','agenda_configuracoes','agenda_bloqueios','agenda_excecoes',
'agenda_online_slots','agenda_regras_semanais','agenda_slots_bloqueados_semanais',
'agenda_slots_regras','agendador_configuracoes','agendador_solicitacoes',
'feriados','recurrence_rules','recurrence_exceptions','recurrence_rule_services',
],
'Financeiro': [
'financial_records','financial_categories','financial_exceptions',
'therapist_payouts','therapist_payout_records',
'insurance_plans','insurance_plan_services','professional_pricing',
],
'Serviços / Commitments': [
'services','determined_commitments','determined_commitment_fields',
'commitment_services','commitment_time_logs',
],
'Notificações': [
'notifications','notification_channels','notification_templates',
'notification_queue','notification_logs','notification_preferences',
'notification_schedules','twilio_subaccount_usage',
],
'Email / Comunicação': [
'email_templates_global','email_templates_tenant','email_layout_config',
'global_notices','notice_dismissals','login_carousel_slides',
],
'SaaS Admin': [
'saas_admins','saas_docs','saas_doc_votos','saas_faq','saas_faq_itens',
'user_settings','dev_user_credentials','_db_migrations',
],
};
// ---------------------------------------------------------------------------
// 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(` Certifique-se que o script está na raiz do projeto.`);
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 database-novo/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));
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 generated = new Date().toLocaleString('pt-BR');
// Serializa dados para embutir no HTML
const jsonData = JSON.stringify({ tables, views, domains });
const jsonColors = JSON.stringify(DOMAIN_COLORS);
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:#4f8cff;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6}
*{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:240px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
.sb-i:hover{color:var(--text);background:var(--bg3)}
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(79,140,255,.08)}
.sb-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(79,140,255,.3);color:#fff;border-radius:2px}
</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>
</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 T2D={};
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
let dom=null,q='';
function gc(d){return C[d]||'#6b7280';}
function buildSB(){
let h=\`<div class="sb-h">Visão Geral</div>
<div class="sb-i \${!dom?'active':''}" onclick="sel(null)">
<div class="sb-dot" style="background:#4f8cff"></div>Todos
<span class="sb-c">\${Object.keys(D.tables).length}</span>
</div>
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
for(const[d,ts]of Object.entries(D.domains)){
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="sel(\`+JSON.stringify(d)+\`)">
<div class="sb-dot" style="background:\${gc(d)}"></div>\${d}
<span class="sb-c">\${ts.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">"\${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 {
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="sel(\`+JSON.stringify(d)+\`)">
<div class="dc-n">\${d}</div>
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
</div>\`;
}
h+='</div></div>';
}
for(const[d,ts]of Object.entries(ds)){
h+=\`<div class="section"><div class="sec-h">
<div class="sec-t" style="color:\${gc(d)}">\${d}</div>
<div class="sec-b">\${ts.length} tabelas</div>
</div><div class="tgrid">\`;
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
h+='</div></div>';
}
if(!dom){
h+=\`<div class="vsec"><div class="sec-h">
<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 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;q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function jump(name){
dom=T2D[name]||null;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='#4f8cff';
setTimeout(()=>el.style.borderColor='',2000);
},80);
}
let st;
function search(v){
clearTimeout(st);q=v.trim();
st=setTimeout(()=>{dom=null;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 DOMAIN_TABLES no script para mapeá-las.\n`);
}
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}\n`);

View File

@@ -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.';

View File

@@ -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';

View File

@@ -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)';

View File

@@ -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
-- ==========================================================================

View File

@@ -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: 08h18h.';
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
-- ==========================================================================

View File

@@ -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
-- ==========================================================================

View File

@@ -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
-- ==========================================================================

View File

@@ -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
-- ==========================================================================

View File

@@ -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
-- ==========================================================================

View File

@@ -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
-- ==========================================================================

View File

@@ -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

View File

@@ -0,0 +1,256 @@
-- =============================================================================
-- AgenciaPsi — Extensions e Schemas
-- Extraído de schema.sql (2026-03-23)
-- =============================================================================
--
-- PostgreSQL database dump
--
\restrict ABfzP9IZJ8pAzvgt6E9jKpFn1phQ3b3Lgk09BZZTle5el6ODr77nIXlXnCf1PS1
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.6
SET statement_timeout = 0;
SET lock_timeout = 0;
SET idle_in_transaction_session_timeout = 0;
SET transaction_timeout = 0;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
SELECT pg_catalog.set_config('search_path', '', false);
SET check_function_bodies = false;
SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: _realtime; Type: SCHEMA; Schema: -; Owner: postgres
--
CREATE SCHEMA _realtime;
ALTER SCHEMA _realtime OWNER TO postgres;
--
-- Name: auth; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA auth;
ALTER SCHEMA auth OWNER TO supabase_admin;
--
-- Name: pg_cron; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA pg_catalog;
--
-- Name: EXTENSION pg_cron; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_cron IS 'Job scheduler for PostgreSQL';
--
-- Name: extensions; Type: SCHEMA; Schema: -; Owner: postgres
--
CREATE SCHEMA extensions;
ALTER SCHEMA extensions OWNER TO postgres;
--
-- Name: graphql; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA graphql;
ALTER SCHEMA graphql OWNER TO supabase_admin;
--
-- Name: graphql_public; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA graphql_public;
ALTER SCHEMA graphql_public OWNER TO supabase_admin;
--
-- Name: pg_net; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
--
-- Name: EXTENSION pg_net; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_net IS 'Async HTTP';
--
-- Name: pgbouncer; Type: SCHEMA; Schema: -; Owner: pgbouncer
--
CREATE SCHEMA pgbouncer;
ALTER SCHEMA pgbouncer OWNER TO pgbouncer;
--
-- Name: realtime; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA realtime;
ALTER SCHEMA realtime OWNER TO supabase_admin;
--
-- Name: storage; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA storage;
ALTER SCHEMA storage OWNER TO supabase_admin;
--
-- Name: supabase_functions; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA supabase_functions;
ALTER SCHEMA supabase_functions OWNER TO supabase_admin;
--
-- Name: vault; Type: SCHEMA; Schema: -; Owner: supabase_admin
--
CREATE SCHEMA vault;
ALTER SCHEMA vault OWNER TO supabase_admin;
--
-- Name: btree_gist; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
--
-- Name: EXTENSION btree_gist; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION btree_gist IS 'support for indexing common datatypes in GiST';
--
-- Name: citext; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public;
--
-- Name: EXTENSION citext; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION citext IS 'data type for case-insensitive character strings';
--
-- Name: pg_graphql; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_graphql WITH SCHEMA graphql;
--
-- Name: EXTENSION pg_graphql; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_graphql IS 'pg_graphql: GraphQL support';
--
-- Name: pg_stat_statements; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA extensions;
--
-- Name: EXTENSION pg_stat_statements; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_stat_statements IS 'track planning and execution statistics of all SQL statements executed';
--
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
--
-- Name: EXTENSION pg_trgm; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pg_trgm IS 'text similarity measurement and index searching based on trigrams';
--
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA extensions;
--
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
--
-- Name: supabase_vault; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS supabase_vault WITH SCHEMA vault;
--
-- Name: EXTENSION supabase_vault; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION supabase_vault IS 'Supabase Vault Extension';
--
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
--
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner:
--
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';

View File

@@ -0,0 +1,123 @@
-- =============================================================================
-- AgenciaPsi — Types (Enums) — auth schema
-- =============================================================================
--
-- Name: aal_level; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.aal_level AS ENUM (
'aal1',
'aal2',
'aal3'
);
ALTER TYPE auth.aal_level OWNER TO supabase_auth_admin;
--
-- Name: code_challenge_method; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.code_challenge_method AS ENUM (
's256',
'plain'
);
ALTER TYPE auth.code_challenge_method OWNER TO supabase_auth_admin;
--
-- Name: factor_status; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.factor_status AS ENUM (
'unverified',
'verified'
);
ALTER TYPE auth.factor_status OWNER TO supabase_auth_admin;
--
-- Name: factor_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.factor_type AS ENUM (
'totp',
'webauthn',
'phone'
);
ALTER TYPE auth.factor_type OWNER TO supabase_auth_admin;
--
-- Name: oauth_authorization_status; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.oauth_authorization_status AS ENUM (
'pending',
'approved',
'denied',
'expired'
);
ALTER TYPE auth.oauth_authorization_status OWNER TO supabase_auth_admin;
--
-- Name: oauth_client_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.oauth_client_type AS ENUM (
'public',
'confidential'
);
ALTER TYPE auth.oauth_client_type OWNER TO supabase_auth_admin;
--
-- Name: oauth_registration_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.oauth_registration_type AS ENUM (
'dynamic',
'manual'
);
ALTER TYPE auth.oauth_registration_type OWNER TO supabase_auth_admin;
--
-- Name: oauth_response_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.oauth_response_type AS ENUM (
'code'
);
ALTER TYPE auth.oauth_response_type OWNER TO supabase_auth_admin;
--
-- Name: one_time_token_type; Type: TYPE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TYPE auth.one_time_token_type AS ENUM (
'confirmation_token',
'reauthentication_token',
'recovery_token',
'email_change_token_new',
'email_change_token_current',
'phone_change_token'
);
ALTER TYPE auth.one_time_token_type OWNER TO supabase_auth_admin;
--
-- Name: commitment_log_source; Type: TYPE; Schema: public; Owner: supabase_admin
--

View File

@@ -0,0 +1,88 @@
-- =============================================================================
-- AgenciaPsi — Types — realtime + storage schemas
-- =============================================================================
CREATE TYPE realtime.action AS ENUM (
'INSERT',
'UPDATE',
'DELETE',
'TRUNCATE',
'ERROR'
);
ALTER TYPE realtime.action OWNER TO supabase_admin;
--
-- Name: equality_op; Type: TYPE; Schema: realtime; Owner: supabase_admin
--
CREATE TYPE realtime.equality_op AS ENUM (
'eq',
'neq',
'lt',
'lte',
'gt',
'gte',
'in'
);
ALTER TYPE realtime.equality_op OWNER TO supabase_admin;
--
-- Name: user_defined_filter; Type: TYPE; Schema: realtime; Owner: supabase_admin
--
CREATE TYPE realtime.user_defined_filter AS (
column_name text,
op realtime.equality_op,
value text
);
ALTER TYPE realtime.user_defined_filter OWNER TO supabase_admin;
--
-- Name: wal_column; Type: TYPE; Schema: realtime; Owner: supabase_admin
--
CREATE TYPE realtime.wal_column AS (
name text,
type_name text,
type_oid oid,
value jsonb,
is_pkey boolean,
is_selectable boolean
);
ALTER TYPE realtime.wal_column OWNER TO supabase_admin;
--
-- Name: wal_rls; Type: TYPE; Schema: realtime; Owner: supabase_admin
--
CREATE TYPE realtime.wal_rls AS (
wal jsonb,
is_rls_enabled boolean,
subscription_ids uuid[],
errors text[]
);
ALTER TYPE realtime.wal_rls OWNER TO supabase_admin;
--
-- Name: buckettype; Type: TYPE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TYPE storage.buckettype AS ENUM (
'STANDARD',
'ANALYTICS',
'VECTOR'
);
ALTER TYPE storage.buckettype OWNER TO supabase_storage_admin;

View File

@@ -0,0 +1,137 @@
-- =============================================================================
-- AgenciaPsi — Types (Enums) — public schema
-- =============================================================================
-- commitment_log_source, determined_field_type, financial_record_type,
-- recurrence_exception_type, recurrence_type, status_agenda_serie,
-- status_evento_agenda, status_excecao_agenda, tipo_evento_agenda,
-- tipo_excecao_agenda
-- =============================================================================
CREATE TYPE public.commitment_log_source AS ENUM (
'manual',
'auto'
);
ALTER TYPE public.commitment_log_source OWNER TO supabase_admin;
--
-- Name: determined_field_type; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.determined_field_type AS ENUM (
'text',
'textarea',
'number',
'date',
'select',
'boolean'
);
ALTER TYPE public.determined_field_type OWNER TO supabase_admin;
--
-- Name: financial_record_type; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.financial_record_type AS ENUM (
'receita',
'despesa'
);
ALTER TYPE public.financial_record_type OWNER TO supabase_admin;
--
-- Name: recurrence_exception_type; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.recurrence_exception_type AS ENUM (
'cancel_session',
'reschedule_session',
'patient_missed',
'therapist_canceled',
'holiday_block'
);
ALTER TYPE public.recurrence_exception_type OWNER TO supabase_admin;
--
-- Name: recurrence_type; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.recurrence_type AS ENUM (
'weekly',
'biweekly',
'monthly',
'yearly',
'custom_weekdays'
);
ALTER TYPE public.recurrence_type OWNER TO supabase_admin;
--
-- Name: status_agenda_serie; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.status_agenda_serie AS ENUM (
'ativo',
'pausado',
'cancelado'
);
ALTER TYPE public.status_agenda_serie OWNER TO supabase_admin;
--
-- Name: status_evento_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.status_evento_agenda AS ENUM (
'agendado',
'realizado',
'faltou',
'cancelado',
'remarcar'
);
ALTER TYPE public.status_evento_agenda OWNER TO supabase_admin;
--
-- Name: status_excecao_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.status_excecao_agenda AS ENUM (
'pendente',
'ativo',
'arquivado'
);
ALTER TYPE public.status_excecao_agenda OWNER TO supabase_admin;
--
-- Name: tipo_evento_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.tipo_evento_agenda AS ENUM (
'sessao',
'bloqueio'
);
ALTER TYPE public.tipo_evento_agenda OWNER TO supabase_admin;
--
-- Name: tipo_excecao_agenda; Type: TYPE; Schema: public; Owner: supabase_admin
--
CREATE TYPE public.tipo_excecao_agenda AS ENUM (
'bloqueio',
'horario_extra'
);

View File

@@ -0,0 +1,650 @@
-- =============================================================================
-- AgenciaPsi — Functions — Agenda
-- =============================================================================
-- agenda_cfg_sync, agendador_dias_disponiveis, agendador_gerar_slug,
-- agendador_slots_disponiveis, cancel_recurrence_from,
-- cancelar_eventos_serie, fn_agenda_regras_semanais_no_overlap,
-- split_recurrence_at, sync_busy_mirror, set_updated_at_recurrence
-- =============================================================================
CREATE FUNCTION public.agenda_cfg_sync() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if new.agenda_view_mode = 'custom' then
new.usar_horario_admin_custom := true;
new.admin_inicio_visualizacao := new.agenda_custom_start;
new.admin_fim_visualizacao := new.agenda_custom_end;
else
new.usar_horario_admin_custom := false;
end if;
return new;
end;
$$;
ALTER FUNCTION public.agenda_cfg_sync() OWNER TO supabase_admin;
--
-- Name: agendador_dias_disponiveis(text, integer, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) RETURNS TABLE(data date, tem_slots boolean)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_owner_id uuid;
v_antecedencia int;
v_agora timestamptz;
v_data date;
v_data_inicio date;
v_data_fim date;
v_db_dow int;
v_tem_slot boolean;
v_bloqueado boolean;
BEGIN
SELECT c.owner_id, c.antecedencia_minima_horas
INTO v_owner_id, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_data_inicio := make_date(p_ano, p_mes, 1);
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date;
v_data := v_data_inicio;
WHILE v_data <= v_data_fim LOOP
v_db_dow := extract(dow from v_data::timestamp)::int;
-- ── Dia inteiro bloqueado? (agenda_bloqueios) ─────────────────────────
SELECT EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= v_data
AND COALESCE(b.data_fim, v_data) >= v_data
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) INTO v_bloqueado;
IF v_bloqueado THEN
v_data := v_data + 1;
CONTINUE;
END IF;
-- ── Tem slots disponíveis no dia? ─────────────────────────────────────
SELECT EXISTS (
SELECT 1 FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
AND (v_data::text || ' ' || s.time::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo'
>= v_agora + (v_antecedencia || ' hours')::interval
) INTO v_tem_slot;
IF v_tem_slot THEN
data := v_data;
tem_slots := true;
RETURN NEXT;
END IF;
v_data := v_data + 1;
END LOOP;
END;
$$;
ALTER FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) OWNER TO supabase_admin;
--
-- Name: agendador_gerar_slug(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.agendador_gerar_slug() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_slug text;
v_exists boolean;
BEGIN
-- só gera se ativou e não tem slug ainda
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
LOOP
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
SELECT EXISTS (
SELECT 1 FROM public.agendador_configuracoes
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
) INTO v_exists;
EXIT WHEN NOT v_exists;
END LOOP;
NEW.link_slug := v_slug;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.agendador_gerar_slug() OWNER TO supabase_admin;
--
-- Name: agendador_slots_disponiveis(text, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean)
CREATE FUNCTION public.agendador_gerar_slug() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_slug text;
v_exists boolean;
BEGIN
-- só gera se ativou e não tem slug ainda
IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
LOOP
v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
SELECT EXISTS (
SELECT 1 FROM public.agendador_configuracoes
WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
) INTO v_exists;
EXIT WHEN NOT v_exists;
END LOOP;
NEW.link_slug := v_slug;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.agendador_gerar_slug() OWNER TO supabase_admin;
--
-- Name: agendador_slots_disponiveis(text, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_owner_id uuid;
v_duracao int;
v_antecedencia int;
v_agora timestamptz;
v_db_dow int;
v_slot time;
v_slot_fim time;
v_slot_ts timestamptz;
v_ocupado boolean;
-- loop de recorrências
v_rule RECORD;
v_rule_start_dow int;
v_first_occ date;
v_day_diff int;
v_ex_type text;
BEGIN
SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
INTO v_owner_id, v_duracao, v_antecedencia
FROM public.agendador_configuracoes c
WHERE c.link_slug = p_slug AND c.ativo = true
LIMIT 1;
IF v_owner_id IS NULL THEN RETURN; END IF;
v_agora := now();
v_db_dow := extract(dow from p_data::timestamp)::int;
-- ── Dia inteiro bloqueado? (agenda_bloqueios sem hora) ───────────────────
-- Se sim, não há nenhum slot disponível — retorna vazio.
IF EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= p_data
AND COALESCE(b.data_fim, p_data) >= p_data
AND b.hora_inicio IS NULL -- bloqueio de dia inteiro
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) THEN
RETURN;
END IF;
FOR v_slot IN
SELECT s.time
FROM public.agenda_online_slots s
WHERE s.owner_id = v_owner_id
AND s.weekday = v_db_dow
AND s.enabled = true
ORDER BY s.time
LOOP
v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
v_ocupado := false;
-- ── Antecedência mínima ──────────────────────────────────────────────────
v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
AT TIME ZONE 'America/Sao_Paulo';
IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
v_ocupado := true;
END IF;
-- ── Bloqueio de horário específico (agenda_bloqueios com hora) ───────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_bloqueios b
WHERE b.owner_id = v_owner_id
AND b.data_inicio <= p_data
AND COALESCE(b.data_fim, p_data) >= p_data
AND b.hora_inicio IS NOT NULL
AND b.hora_inicio < v_slot_fim
AND b.hora_fim > v_slot
AND (
(NOT b.recorrente)
OR (b.recorrente AND b.dia_semana = v_db_dow)
)
) INTO v_ocupado;
END IF;
-- ── Eventos avulsos internos (agenda_eventos) ────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agenda_eventos e
WHERE e.owner_id = v_owner_id
AND e.status::text NOT IN ('cancelado', 'faltou')
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
) INTO v_ocupado;
END IF;
-- ── Recorrências ativas (recurrence_rules) ───────────────────────────────
IF NOT v_ocupado THEN
FOR v_rule IN
SELECT
r.id,
r.start_date::date AS start_date,
r.end_date::date AS end_date,
r.start_time::time AS start_time,
r.end_time::time AS end_time,
COALESCE(r.interval, 1)::int AS interval
FROM public.recurrence_rules r
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND p_data >= r.start_date::date
AND (r.end_date IS NULL OR p_data <= r.end_date::date)
AND v_db_dow = ANY(r.weekdays)
AND r.start_time::time < v_slot_fim
AND r.end_time::time > v_slot
LOOP
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
v_first_occ := v_rule.start_date
+ (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
v_day_diff := (p_data - v_first_occ)::int;
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
v_ex_type := NULL;
SELECT ex.type INTO v_ex_type
FROM public.recurrence_exceptions ex
WHERE ex.recurrence_id = v_rule.id
AND ex.original_date = p_data
LIMIT 1;
IF v_ex_type IS NULL OR v_ex_type NOT IN (
'cancel_session', 'patient_missed',
'therapist_canceled', 'holiday_block',
'reschedule_session'
) THEN
v_ocupado := true;
EXIT;
END IF;
END IF;
END LOOP;
END IF;
-- ── Recorrências remarcadas para este dia ────────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1
FROM public.recurrence_exceptions ex
JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
WHERE r.owner_id = v_owner_id
AND r.status = 'ativo'
AND ex.type = 'reschedule_session'
AND ex.new_date = p_data
AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
AND COALESCE(ex.new_end_time, r.end_time)::time > v_slot
) INTO v_ocupado;
END IF;
-- ── Solicitações públicas pendentes ──────────────────────────────────────
IF NOT v_ocupado THEN
SELECT EXISTS (
SELECT 1 FROM public.agendador_solicitacoes sol
WHERE sol.owner_id = v_owner_id
AND sol.status = 'pendente'
AND sol.data_solicitada = p_data
AND sol.hora_solicitada = v_slot
AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
) INTO v_ocupado;
END IF;
hora := v_slot;
disponivel := NOT v_ocupado;
RETURN NEXT;
END LOOP;
END;
$$;
ALTER FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) OWNER TO supabase_admin;
--
-- Name: auto_create_financial_record_from_session(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.auto_create_financial_record_from_session() RETURNS trigger
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
UPDATE public.recurrence_rules
SET
end_date = p_from_date - INTERVAL '1 day',
open_ended = false,
status = CASE
WHEN p_from_date <= start_date THEN 'cancelado'
ELSE status
END,
updated_at = now()
WHERE id = p_recurrence_id;
END;
$$;
ALTER FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) OWNER TO supabase_admin;
--
-- Name: cancel_subscription(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.cancel_subscription(p_subscription_id uuid) RETURNS public.subscriptions
CREATE FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone DEFAULT now()) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.agenda_eventos
SET status = 'cancelado',
updated_at = now()
WHERE serie_id = p_serie_id
AND inicio_em >= p_a_partir_de
AND status NOT IN ('realizado', 'cancelado');
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
ALTER FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone) OWNER TO supabase_admin;
--
-- Name: FUNCTION cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone); Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone) IS 'Cancela todos os eventos futuros de uma série a partir de p_a_partir_de (inclusive).
Não cancela eventos realizados.';
--
-- Name: change_subscription_plan(uuid, uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid) RETURNS public.subscriptions
CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger
LANGUAGE plpgsql
AS $$
declare
v_count int;
begin
if new.ativo is false then
return new;
end if;
select count(*) into v_count
from public.agenda_regras_semanais r
where r.owner_id = new.owner_id
and r.dia_semana = new.dia_semana
and r.ativo is true
and (tg_op = 'INSERT' or r.id <> new.id)
and (new.hora_inicio < r.hora_fim and new.hora_fim > r.hora_inicio);
if v_count > 0 then
raise exception 'Janela sobreposta: existe uma regra ativa nesse intervalo.';
end if;
return new;
end;
$$;
ALTER FUNCTION public.fn_agenda_regras_semanais_no_overlap() OWNER TO supabase_admin;
--
-- Name: get_financial_report(uuid, date, date, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;
ALTER FUNCTION public.set_updated_at_recurrence() OWNER TO supabase_admin;
--
-- Name: split_recurrence_at(uuid, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_old public.recurrence_rules;
v_new_id uuid;
BEGIN
-- busca a regra original
SELECT * INTO v_old
FROM public.recurrence_rules
WHERE id = p_recurrence_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id;
END IF;
-- encerra a regra antiga na data anterior
UPDATE public.recurrence_rules
SET
end_date = p_from_date - INTERVAL '1 day',
open_ended = false,
updated_at = now()
WHERE id = p_recurrence_id;
-- cria nova regra a partir de p_from_date
INSERT INTO public.recurrence_rules (
tenant_id, owner_id, therapist_id, patient_id,
determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min,
start_date, end_date, max_occurrences, open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status
)
SELECT
tenant_id, owner_id, therapist_id, patient_id,
determined_commitment_id, type, interval, weekdays,
start_time, end_time, timezone, duration_min,
p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
modalidade, titulo_custom, observacoes, extra_fields, status
FROM public.recurrence_rules
WHERE id = p_recurrence_id
RETURNING id INTO v_new_id;
RETURN v_new_id;
END;
$$;
ALTER FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) OWNER TO supabase_admin;
--
-- Name: subscription_intents_view_insert(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.subscription_intents_view_insert() RETURNS trigger
CREATE FUNCTION public.sync_busy_mirror_agenda_eventos() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
clinic_tenant uuid;
is_personal boolean;
should_mirror boolean;
begin
-- Anti-recursão: espelho não espelha
if (tg_op <> 'DELETE') then
if new.mirror_of_event_id is not null then
return new;
end if;
else
if old.mirror_of_event_id is not null then
return old;
end if;
end if;
-- Define se é pessoal e se deve espelhar
if (tg_op = 'DELETE') then
is_personal := (old.tenant_id = old.owner_id);
should_mirror := (old.visibility_scope in ('busy_only','private'));
else
is_personal := (new.tenant_id = new.owner_id);
should_mirror := (new.visibility_scope in ('busy_only','private'));
end if;
-- Se não é pessoal, não faz nada
if not is_personal then
if (tg_op = 'DELETE') then
return old;
end if;
return new;
end if;
-- DELETE: remove espelhos existentes
if (tg_op = 'DELETE') then
delete from public.agenda_eventos e
where e.mirror_of_event_id = old.id
and e.mirror_source = 'personal_busy_mirror';
return old;
end if;
-- INSERT/UPDATE:
-- Se não deve espelhar, remove espelhos e sai
if not should_mirror then
delete from public.agenda_eventos e
where e.mirror_of_event_id = new.id
and e.mirror_source = 'personal_busy_mirror';
return new;
end if;
-- Para cada clínica onde o usuário é therapist active, cria/atualiza o "Ocupado"
for clinic_tenant in
select tm.tenant_id
from public.tenant_members tm
where tm.user_id = new.owner_id
and tm.role = 'therapist'
and tm.status = 'active'
and tm.tenant_id <> new.owner_id
loop
insert into public.agenda_eventos (
tenant_id,
owner_id,
terapeuta_id,
paciente_id,
tipo,
status,
titulo,
observacoes,
inicio_em,
fim_em,
mirror_of_event_id,
mirror_source,
visibility_scope,
created_at,
updated_at
) values (
clinic_tenant,
new.owner_id,
new.owner_id,
null,
'bloqueio'::public.tipo_evento_agenda,
'agendado'::public.status_evento_agenda,
'Ocupado',
null,
new.inicio_em,
new.fim_em,
new.id,
'personal_busy_mirror',
'public',
now(),
now()
)
on conflict (tenant_id, mirror_of_event_id) where mirror_of_event_id is not null
do update set
owner_id = excluded.owner_id,
terapeuta_id = excluded.terapeuta_id,
tipo = excluded.tipo,
status = excluded.status,
titulo = excluded.titulo,
observacoes = excluded.observacoes,
inicio_em = excluded.inicio_em,
fim_em = excluded.fim_em,
updated_at = now();
end loop;
-- Limpa espelhos de clínicas onde o vínculo therapist active não existe mais
delete from public.agenda_eventos e
where e.mirror_of_event_id = new.id
and e.mirror_source = 'personal_busy_mirror'
and not exists (
select 1
from public.tenant_members tm
where tm.user_id = new.owner_id
and tm.role = 'therapist'
and tm.status = 'active'
and tm.tenant_id = e.tenant_id
);
return new;
end;
$$;
ALTER FUNCTION public.sync_busy_mirror_agenda_eventos() OWNER TO supabase_admin;
--
-- Name: sync_overdue_financial_records(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.sync_overdue_financial_records() RETURNS integer

View File

@@ -0,0 +1,93 @@
-- =============================================================================
-- AgenciaPsi — Functions — auth schema
-- auth.email(), auth.jwt(), auth.role(), auth.uid()
-- =============================================================================
--
-- Name: email(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
--
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
$$;
ALTER FUNCTION auth.email() OWNER TO supabase_auth_admin;
--
-- Name: FUNCTION email(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON FUNCTION auth.email() IS 'Deprecated. Use auth.jwt() -> ''email'' instead.';
--
-- Name: jwt(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
--
CREATE FUNCTION auth.jwt() RETURNS jsonb
LANGUAGE sql STABLE
AS $$
select
coalesce(
nullif(current_setting('request.jwt.claim', true), ''),
nullif(current_setting('request.jwt.claims', true), '')
)::jsonb
$$;
ALTER FUNCTION auth.jwt() OWNER TO supabase_auth_admin;
--
-- Name: role(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
--
CREATE FUNCTION auth.role() RETURNS text
LANGUAGE sql STABLE
AS $$
select
coalesce(
nullif(current_setting('request.jwt.claim.role', true), ''),
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')
)::text
$$;
ALTER FUNCTION auth.role() OWNER TO supabase_auth_admin;
--
-- Name: FUNCTION role(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON FUNCTION auth.role() IS 'Deprecated. Use auth.jwt() -> ''role'' instead.';
--
-- Name: uid(); Type: FUNCTION; Schema: auth; Owner: supabase_auth_admin
--
CREATE FUNCTION auth.uid() RETURNS uuid
LANGUAGE sql STABLE
AS $$
select
coalesce(
nullif(current_setting('request.jwt.claim.sub', true), ''),
(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
)::uuid
$$;
ALTER FUNCTION auth.uid() OWNER TO supabase_auth_admin;
--
-- Name: FUNCTION uid(); Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON FUNCTION auth.uid() IS 'Deprecated. Use auth.jwt() -> ''sub'' instead.';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,818 @@
-- =============================================================================
-- AgenciaPsi — Functions — Financeiro
-- =============================================================================
-- auto_create_financial_record_from_session, create_financial_record_for_session,
-- create_therapist_payout, get_financial_report, get_financial_summary,
-- list_financial_records, mark_as_paid, mark_payout_as_paid,
-- seed_default_financial_categories, sync_overdue_financial_records,
-- trg_fn_financial_records_auto_overdue, set_insurance/services_updated_at
-- =============================================================================
CREATE FUNCTION public.auto_create_financial_record_from_session() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_price NUMERIC(10,2);
v_services_total NUMERIC(10,2);
v_already_billed BOOLEAN;
BEGIN
-- ── Guards de saída rápida ──────────────────────────────────────────────
-- Só processa quando o status muda PARA 'realizado'
IF NEW.status::TEXT <> 'realizado' THEN
RETURN NEW;
END IF;
-- Só processa quando houve mudança real de status
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
RETURN NEW;
END IF;
-- Só sessões (não bloqueios, feriados, etc.)
IF NEW.tipo::TEXT <> 'sessao' THEN
RETURN NEW;
END IF;
-- Paciente obrigatório para vincular a cobrança
IF NEW.patient_id IS NULL THEN
RETURN NEW;
END IF;
-- Sessões de pacote têm cobrança gerenciada por billing_contract
IF NEW.billing_contract_id IS NOT NULL THEN
RETURN NEW;
END IF;
-- Idempotência: já existe financial_record para este evento?
SELECT billed INTO v_already_billed
FROM public.agenda_eventos
WHERE id = NEW.id;
IF v_already_billed = TRUE THEN
-- Confirma no financial_records também (dupla verificação)
IF EXISTS (
SELECT 1 FROM public.financial_records
WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL
) THEN
RETURN NEW;
END IF;
END IF;
-- ── Busca do preço ──────────────────────────────────────────────────────
v_price := NULL;
-- Prioridade 1: soma dos serviços da regra de recorrência
IF NEW.recurrence_id IS NOT NULL THEN
SELECT COALESCE(SUM(rrs.final_price), 0)
INTO v_services_total
FROM public.recurrence_rule_services rrs
WHERE rrs.rule_id = NEW.recurrence_id;
IF v_services_total > 0 THEN
v_price := v_services_total;
END IF;
-- Prioridade 2: price direto da regra (fallback se sem serviços)
IF v_price IS NULL OR v_price = 0 THEN
SELECT price INTO v_price
FROM public.recurrence_rules
WHERE id = NEW.recurrence_id;
END IF;
END IF;
-- Prioridade 3: price do próprio evento de agenda
IF v_price IS NULL OR v_price = 0 THEN
v_price := NEW.price;
END IF;
-- Sem preço → não criar registro (não é erro, apenas skip silencioso)
IF v_price IS NULL OR v_price <= 0 THEN
RETURN NEW;
END IF;
-- ── Criação do financial_record ─────────────────────────────────────────
INSERT INTO public.financial_records (
owner_id,
tenant_id,
patient_id,
agenda_evento_id,
type,
amount,
discount_amount,
final_amount,
clinic_fee_pct,
clinic_fee_amount,
status,
due_date
-- payment_method: NULL até o momento do pagamento (mark_as_paid preenche)
) VALUES (
NEW.owner_id,
NEW.tenant_id,
NEW.patient_id,
NEW.id,
'receita',
v_price,
0,
v_price,
0, -- clinic_fee_pct: sem campo de configuração global no schema atual.
0, -- clinic_fee_amount: calculado manualmente ou via update posterior.
'pending',
(NEW.inicio_em::DATE + 7) -- vencimento padrão: 7 dias após a sessão
);
-- ── Marca sessão como billed ────────────────────────────────────────────
-- UPDATE em billed (não em status) → não re-dispara este trigger
UPDATE public.agenda_eventos
SET billed = TRUE
WHERE id = NEW.id;
RETURN NEW;
EXCEPTION
WHEN OTHERS THEN
-- Log silencioso: nunca bloquear a agenda por falha financeira
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%',
NEW.id, SQLERRM;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.auto_create_financial_record_from_session() OWNER TO supabase_admin;
--
-- Name: FUNCTION auto_create_financial_record_from_session(); Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON FUNCTION public.auto_create_financial_record_from_session() IS 'Trigger que cria automaticamente um financial_record (receita, pending) quando uma sessão de agenda é marcada como realizada. Prioridade de preço: recurrence_rule_services > recurrence_rules.price > agenda_eventos.price. Skip silencioso se sem preço, pacote ou registro já existente.';
--
-- Name: can_delete_patient(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS SETOF public.financial_records
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_existing public.financial_records%ROWTYPE;
v_new public.financial_records%ROWTYPE;
BEGIN
-- Idempotência: retorna o registro existente se já foi criado
SELECT * INTO v_existing
FROM public.financial_records
WHERE agenda_evento_id = p_agenda_evento_id
AND deleted_at IS NULL
LIMIT 1;
IF FOUND THEN
RETURN NEXT v_existing;
RETURN;
END IF;
-- Cria o novo registro
INSERT INTO public.financial_records (
tenant_id,
owner_id,
patient_id,
agenda_evento_id,
amount,
discount_amount,
final_amount,
status,
due_date
) VALUES (
p_tenant_id,
p_owner_id,
p_patient_id,
p_agenda_evento_id,
p_amount,
0,
p_amount,
'pending',
p_due_date
)
RETURNING * INTO v_new;
-- Marca o evento da agenda como billed = true
UPDATE public.agenda_eventos
SET billed = TRUE
WHERE id = p_agenda_evento_id;
RETURN NEXT v_new;
END;
$$;
ALTER FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) OWNER TO supabase_admin;
--
-- Name: create_patient_intake_request(text, text, text, text, text, boolean); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text DEFAULT NULL::text, p_phone text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_consent boolean DEFAULT false) RETURNS uuid
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS public.therapist_payouts
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_payout public.therapist_payouts%ROWTYPE;
v_total_sessions INTEGER;
v_gross NUMERIC(10,2);
v_clinic_fee NUMERIC(10,2);
v_net NUMERIC(10,2);
BEGIN
-- ── Verificação de permissão ────────────────────────────────────────────
-- Apenas o próprio terapeuta ou o tenant_admin pode criar o repasse
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
END IF;
-- ── Verifica se já existe repasse para o mesmo período ─────────────────
IF EXISTS (
SELECT 1 FROM public.therapist_payouts
WHERE owner_id = p_therapist_id
AND tenant_id = p_tenant_id
AND period_start = p_period_start
AND period_end = p_period_end
AND status <> 'cancelled'
) THEN
RAISE EXCEPTION
'Já existe um repasse ativo para o período % a % deste terapeuta.',
p_period_start, p_period_end;
END IF;
-- ── Agrega os financial_records elegíveis ──────────────────────────────
-- Elegíveis: paid, receita, owner=terapeuta, tenant correto, paid_at no período,
-- não soft-deleted, ainda não vinculados a nenhum payout.
SELECT
COUNT(*) AS total_sessions,
COALESCE(SUM(amount), 0) AS gross_amount,
COALESCE(SUM(clinic_fee_amount), 0) AS clinic_fee_total,
COALESCE(SUM(net_amount), 0) AS net_amount
INTO
v_total_sessions, v_gross, v_clinic_fee, v_net
FROM public.financial_records fr
WHERE fr.owner_id = p_therapist_id
AND fr.tenant_id = p_tenant_id
AND fr.type = 'receita'
AND fr.status = 'paid'
AND fr.deleted_at IS NULL
AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (
SELECT 1 FROM public.therapist_payout_records tpr
WHERE tpr.financial_record_id = fr.id
);
-- Sem registros elegíveis → não criar payout vazio
IF v_total_sessions = 0 THEN
RAISE EXCEPTION
'Nenhum registro financeiro elegível encontrado para o período % a %.',
p_period_start, p_period_end;
END IF;
-- ── Cria o repasse ─────────────────────────────────────────────────────
INSERT INTO public.therapist_payouts (
owner_id,
tenant_id,
period_start,
period_end,
total_sessions,
gross_amount,
clinic_fee_total,
net_amount,
status
) VALUES (
p_therapist_id,
p_tenant_id,
p_period_start,
p_period_end,
v_total_sessions,
v_gross,
v_clinic_fee,
v_net,
'pending'
)
RETURNING * INTO v_payout;
-- ── Vincula os financial_records ao repasse ────────────────────────────
INSERT INTO public.therapist_payout_records (payout_id, financial_record_id)
SELECT v_payout.id, fr.id
FROM public.financial_records fr
WHERE fr.owner_id = p_therapist_id
AND fr.tenant_id = p_tenant_id
AND fr.type = 'receita'
AND fr.status = 'paid'
AND fr.deleted_at IS NULL
AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
AND NOT EXISTS (
SELECT 1 FROM public.therapist_payout_records tpr
WHERE tpr.financial_record_id = fr.id
);
RETURN v_payout;
END;
$$;
ALTER FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) OWNER TO supabase_admin;
--
-- Name: FUNCTION create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date); Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) IS 'Cria um repasse para o terapeuta com todos os financial_records paid+receita do período que ainda não estejam vinculados a outro repasse. Lança exceção se não houver registros elegíveis ou se já houver repasse ativo no período.';
--
-- Name: current_member_id(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.current_member_id(p_tenant_id uuid) RETURNS uuid
CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
-- ── Valida p_group_by antes de executar ──────────────────────────────────
-- (lança erro se valor inválido; plpgsql seria necessário para isso em SQL puro,
-- então usamos um CTE de validação com CASE WHEN para retornar vazio em vez de erro)
WITH base AS (
SELECT
fr.type,
fr.amount,
fr.final_amount,
fr.status,
fr.deleted_at,
-- Chave de agrupamento calculada conforme p_group_by
CASE p_group_by
WHEN 'month' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'YYYY-MM'
)
WHEN 'week' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'IYYY-"W"IW'
)
WHEN 'category' THEN COALESCE(fr.category_id::TEXT, fr.category, 'sem_categoria')
WHEN 'patient' THEN COALESCE(fr.patient_id::TEXT, 'sem_paciente')
ELSE NULL -- group_by inválido → group_key NULL → retorno vazio
END AS gkey,
-- Label legível (enriquecido via JOIN abaixo quando possível)
CASE p_group_by
WHEN 'month' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'YYYY-MM'
)
WHEN 'week' THEN TO_CHAR(
COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
'IYYY-"W"IW'
)
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::TEXT, 'Sem paciente')
ELSE NULL
END AS glabel
FROM public.financial_records fr
LEFT JOIN public.financial_categories fc
ON fc.id = fr.category_id
LEFT JOIN public.patients p
ON p.id = fr.patient_id
WHERE fr.owner_id = p_owner_id
AND fr.deleted_at IS NULL
AND COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE)
BETWEEN p_start_date AND p_end_date
)
SELECT
gkey AS group_key,
glabel AS group_label,
COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
AS total_receitas,
COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
AS total_despesas,
COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
- COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
AS saldo,
COALESCE(SUM(final_amount) FILTER (WHERE status = 'pending'), 0) AS total_pendente,
COALESCE(SUM(final_amount) FILTER (WHERE status = 'overdue'), 0) AS total_overdue,
COUNT(*) AS count_records
FROM base
WHERE gkey IS NOT NULL -- descarta p_group_by inválido
GROUP BY gkey, glabel
ORDER BY gkey ASC;
$$;
ALTER FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text) OWNER TO supabase_admin;
--
-- Name: FUNCTION get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text); Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text) IS 'Relatório financeiro agrupado por mês, semana ISO, categoria ou paciente. p_group_by aceita: ''month'' | ''week'' | ''category'' | ''patient''. Totais de receita/despesa consideram apenas registros com status=paid. total_pendente e total_overdue incluem todos os tipos (receita + despesa).';
--
-- Name: get_financial_summary(uuid, integer, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.get_financial_summary(p_owner_id uuid, p_year integer, p_month integer) RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT
-- Receitas pagas no período
COALESCE(SUM(amount) FILTER (
WHERE type = 'receita' AND status = 'paid'
), 0) AS total_receitas,
-- Despesas pagas no período
COALESCE(SUM(amount) FILTER (
WHERE type = 'despesa' AND status = 'paid'
), 0) AS total_despesas,
-- Tudo pendente ou vencido (receitas + despesas)
COALESCE(SUM(amount) FILTER (
WHERE status IN ('pending', 'overdue')
), 0) AS total_pendente,
-- Saldo líquido (receitas pagas despesas pagas)
COALESCE(SUM(amount) FILTER (
WHERE type = 'receita' AND status = 'paid'
), 0)
- COALESCE(SUM(amount) FILTER (
WHERE type = 'despesa' AND status = 'paid'
), 0) AS saldo_liquido,
-- Total repassado à clínica (apenas receitas pagas)
COALESCE(SUM(clinic_fee_amount) FILTER (
WHERE type = 'receita' AND status = 'paid'
), 0) AS total_repasse,
-- Contadores (excluindo soft-deleted)
COUNT(*) FILTER (WHERE type = 'receita' AND deleted_at IS NULL) AS count_receitas,
COUNT(*) FILTER (WHERE type = 'despesa' AND deleted_at IS NULL) AS count_despesas
FROM public.financial_records
WHERE owner_id = p_owner_id
AND deleted_at IS NULL
AND EXTRACT(YEAR FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year
AND EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month;
$$;
ALTER FUNCTION public.get_financial_summary(p_owner_id uuid, p_year integer, p_month integer) OWNER TO supabase_admin;
--
-- Name: get_my_email(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.get_my_email() RETURNS text
CREATE FUNCTION public.list_financial_records(p_owner_id uuid, p_year integer DEFAULT NULL::integer, p_month integer DEFAULT NULL::integer, p_type text DEFAULT NULL::text, p_status text DEFAULT NULL::text, p_patient_id uuid DEFAULT NULL::uuid, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0) RETURNS SETOF public.financial_records
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'public'
AS $$
SELECT *
FROM public.financial_records
WHERE owner_id = p_owner_id
AND deleted_at IS NULL
AND (p_type IS NULL OR type::TEXT = p_type)
AND (p_status IS NULL OR status = p_status)
AND (p_patient_id IS NULL OR patient_id = p_patient_id)
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year)
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month)
ORDER BY COALESCE(paid_at, due_date::TIMESTAMPTZ, created_at) DESC
LIMIT p_limit
OFFSET p_offset;
$$;
ALTER FUNCTION public.list_financial_records(p_owner_id uuid, p_year integer, p_month integer, p_type text, p_status text, p_patient_id uuid, p_limit integer, p_offset integer) OWNER TO supabase_admin;
--
-- Name: mark_as_paid(uuid, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.mark_as_paid(p_financial_record_id uuid, p_payment_method text) RETURNS SETOF public.financial_records
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_record public.financial_records%ROWTYPE;
BEGIN
-- Garante que o registro pertence ao usuário autenticado (RLS não aplica em SECURITY DEFINER)
SELECT * INTO v_record
FROM public.financial_records
WHERE id = p_financial_record_id
AND owner_id = auth.uid()
AND deleted_at IS NULL;
IF NOT FOUND THEN
RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.';
END IF;
IF v_record.status NOT IN ('pending', 'overdue') THEN
RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.';
END IF;
UPDATE public.financial_records
SET status = 'paid',
paid_at = NOW(),
payment_method = p_payment_method,
updated_at = NOW()
WHERE id = p_financial_record_id
RETURNING * INTO v_record;
RETURN NEXT v_record;
END;
$$;
ALTER FUNCTION public.mark_as_paid(p_financial_record_id uuid, p_payment_method text) OWNER TO supabase_admin;
--
-- Name: mark_payout_as_paid(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.mark_payout_as_paid(p_payout_id uuid) RETURNS public.therapist_payouts
CREATE FUNCTION public.mark_payout_as_paid(p_payout_id uuid) RETURNS public.therapist_payouts
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_payout public.therapist_payouts%ROWTYPE;
BEGIN
-- Busca o payout
SELECT * INTO v_payout
FROM public.therapist_payouts
WHERE id = p_payout_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id;
END IF;
-- Verifica permissão: apenas tenant_admin do tenant do repasse
IF NOT public.is_tenant_admin(v_payout.tenant_id) THEN
RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.';
END IF;
-- Verifica status
IF v_payout.status <> 'pending' THEN
RAISE EXCEPTION
'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.',
v_payout.status;
END IF;
-- Atualiza
UPDATE public.therapist_payouts
SET
status = 'paid',
paid_at = NOW(),
updated_at = NOW()
WHERE id = p_payout_id
RETURNING * INTO v_payout;
RETURN v_payout;
END;
$$;
ALTER FUNCTION public.mark_payout_as_paid(p_payout_id uuid) OWNER TO supabase_admin;
--
-- Name: FUNCTION mark_payout_as_paid(p_payout_id uuid); Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON FUNCTION public.mark_payout_as_paid(p_payout_id uuid) IS 'Marca um repasse de terapeuta como pago. Apenas o tenant_admin pode chamar. Apenas repasses com status=pending podem ser finalizados.';
--
-- Name: my_tenants(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.my_tenants() RETURNS TABLE(tenant_id uuid, role text, status text, kind text)
CREATE FUNCTION public.seed_default_financial_categories(p_user_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
INSERT INTO public.financial_categories (user_id, name, type, color, icon, sort_order)
VALUES
(p_user_id, 'Sessão', 'receita', '#22c55e', 'pi pi-heart', 1),
(p_user_id, 'Supervisão', 'receita', '#6366f1', 'pi pi-users', 2),
(p_user_id, 'Convênio', 'receita', '#3b82f6', 'pi pi-building', 3),
(p_user_id, 'Grupo terapêutico', 'receita', '#f59e0b', 'pi pi-sitemap', 4),
(p_user_id, 'Outro (receita)', 'receita', '#8b5cf6', 'pi pi-plus-circle', 5),
(p_user_id, 'Aluguel sala', 'despesa', '#ef4444', 'pi pi-home', 1),
(p_user_id, 'Plataforma/SaaS', 'despesa', '#f97316', 'pi pi-desktop', 2),
(p_user_id, 'Repasse clínica', 'despesa', '#64748b', 'pi pi-arrow-right-arrow-left', 3),
(p_user_id, 'Supervisão (custo)', 'despesa', '#6366f1', 'pi pi-users', 4),
(p_user_id, 'Outro (despesa)', 'despesa', '#94a3b8', 'pi pi-minus-circle', 5)
ON CONFLICT DO NOTHING;
END;
$$;
ALTER FUNCTION public.seed_default_financial_categories(p_user_id uuid) OWNER TO supabase_admin;
--
-- Name: seed_determined_commitments(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
CREATE FUNCTION public.set_insurance_plans_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$;
ALTER FUNCTION public.set_insurance_plans_updated_at() OWNER TO supabase_admin;
--
-- Name: set_owner_id(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.set_owner_id() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if new.owner_id is null then
new.owner_id := auth.uid();
end if;
return new;
end;
$$;
ALTER FUNCTION public.set_owner_id() OWNER TO supabase_admin;
--
-- Name: set_services_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.set_services_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
ALTER FUNCTION public.set_services_updated_at() OWNER TO supabase_admin;
--
-- Name: set_tenant_feature_exception(uuid, text, boolean, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.set_tenant_feature_exception(p_tenant_id uuid, p_feature_key text, p_enabled boolean, p_reason text DEFAULT NULL::text) RETURNS void
CREATE FUNCTION public.set_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
ALTER FUNCTION public.set_updated_at() OWNER TO supabase_admin;
--
-- Name: set_updated_at_recurrence(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
CREATE FUNCTION public.sync_overdue_financial_records() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_count integer;
BEGIN
UPDATE public.financial_records
SET
status = 'overdue',
updated_at = NOW()
WHERE status = 'pending'
AND due_date IS NOT NULL
AND due_date < CURRENT_DATE
AND deleted_at IS NULL;
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN v_count;
END;
$$;
ALTER FUNCTION public.sync_overdue_financial_records() OWNER TO supabase_admin;
--
-- Name: FUNCTION sync_overdue_financial_records(); Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON FUNCTION public.sync_overdue_financial_records() IS 'Marca como overdue todos os financial_records pendentes com due_date vencido. Pode ser chamada manualmente, via pg_cron ou via Supabase Edge Function agendada.';
--
-- Name: tenant_accept_invite(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.tenant_accept_invite(p_token uuid) RETURNS jsonb
CREATE FUNCTION public.trg_fn_financial_records_auto_overdue() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
BEGIN
IF NEW.status = 'pending'
AND NEW.due_date IS NOT NULL
AND NEW.due_date < CURRENT_DATE
THEN
NEW.status := 'overdue';
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.trg_fn_financial_records_auto_overdue() OWNER TO supabase_admin;
--
-- Name: unstick_notification_queue(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.unstick_notification_queue() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_unstuck integer;
BEGIN
UPDATE public.notification_queue
SET status = 'pendente',
attempts = attempts + 1,
last_error = 'Timeout: preso em processando por >10min',
next_retry_at = now() + interval '2 minutes'
WHERE status = 'processando'
AND updated_at < now() - interval '10 minutes';
GET DIAGNOSTICS v_unstuck = ROW_COUNT;
RETURN v_unstuck;
END;
$$;
ALTER FUNCTION public.unstick_notification_queue() OWNER TO supabase_admin;
--
-- Name: update_payment_settings_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
ALTER FUNCTION public.update_payment_settings_updated_at() OWNER TO supabase_admin;
--
-- Name: update_professional_pricing_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.update_professional_pricing_updated_at() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
ALTER FUNCTION public.update_professional_pricing_updated_at() OWNER TO supabase_admin;
--
-- Name: user_has_feature(uuid, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.user_has_feature(_user_id uuid, _feature text) RETURNS boolean

View File

@@ -0,0 +1,316 @@
-- =============================================================================
-- AgenciaPsi — Functions — infraestrutura
-- extensions.grant_pg_*, pgbouncer.get_auth, etc.
-- =============================================================================
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;
$$;
ALTER FUNCTION extensions.grant_pg_cron_access() OWNER TO supabase_admin;
--
-- Name: FUNCTION grant_pg_cron_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
--
COMMENT ON FUNCTION extensions.grant_pg_cron_access() IS 'Grants access to pg_cron';
--
-- Name: grant_pg_graphql_access(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
--
CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger
LANGUAGE plpgsql
AS $_$
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;
$_$;
ALTER FUNCTION extensions.grant_pg_graphql_access() OWNER TO supabase_admin;
--
-- Name: FUNCTION grant_pg_graphql_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
--
COMMENT ON FUNCTION extensions.grant_pg_graphql_access() IS 'Grants access to pg_graphql';
--
-- Name: grant_pg_net_access(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
--
CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger
LANGUAGE plpgsql
AS $$
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;
$$;
ALTER FUNCTION extensions.grant_pg_net_access() OWNER TO supabase_admin;
--
-- Name: FUNCTION grant_pg_net_access(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
--
COMMENT ON FUNCTION extensions.grant_pg_net_access() IS 'Grants access to pg_net';
--
-- Name: pgrst_ddl_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
--
CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger
LANGUAGE plpgsql
AS $$
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; $$;
ALTER FUNCTION extensions.pgrst_ddl_watch() OWNER TO supabase_admin;
--
-- Name: pgrst_drop_watch(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
--
CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger
LANGUAGE plpgsql
AS $$
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; $$;
ALTER FUNCTION extensions.pgrst_drop_watch() OWNER TO supabase_admin;
--
-- Name: set_graphql_placeholder(); Type: FUNCTION; Schema: extensions; Owner: supabase_admin
--
CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger
LANGUAGE plpgsql
AS $_$
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;
$_$;
ALTER FUNCTION extensions.set_graphql_placeholder() OWNER TO supabase_admin;
--
-- Name: FUNCTION set_graphql_placeholder(); Type: COMMENT; Schema: extensions; Owner: supabase_admin
--
COMMENT ON FUNCTION extensions.set_graphql_placeholder() IS 'Reintroduces placeholder function for graphql_public.graphql';
--
-- Name: get_auth(text); Type: FUNCTION; Schema: pgbouncer; Owner: supabase_admin
--
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO ''
AS $_$
begin
raise debug 'PgBouncer auth request: %', p_usename;
return query
select
rolname::text,
case when rolvaliduntil < now()
then null
else rolpassword::text
end
from pg_authid
where rolname=$1 and rolcanlogin;
end;
$_$;
ALTER FUNCTION pgbouncer.get_auth(p_usename text) OWNER TO supabase_admin;

View File

@@ -0,0 +1,776 @@
-- =============================================================================
-- AgenciaPsi — Functions — Compromissos, Suporte, SaaS
-- =============================================================================
-- seed_determined_commitments, delete_commitment_full,
-- delete_determined_commitment, guard_locked_commitment,
-- create_support_session, revoke_support_session, validate_support_session,
-- saas_votar_doc, faq_votar, notice_track_click/view,
-- sanitize_phone_br, create_clinic_tenant
-- =============================================================================
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_uid uuid;
v_tenant uuid;
v_name text;
begin
v_uid := auth.uid();
if v_uid is null then
raise exception 'Not authenticated';
end if;
v_name := nullif(trim(coalesce(p_name, '')), '');
if v_name is null then
v_name := 'Clínica';
end if;
insert into public.tenants (name, kind, created_at)
values (v_name, 'clinic', now())
returning id into v_tenant;
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
return v_tenant;
end;
$$;
ALTER FUNCTION public.create_clinic_tenant(p_name text) OWNER TO supabase_admin;
--
-- Name: financial_records; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.financial_records (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid,
type public.financial_record_type DEFAULT 'receita'::public.financial_record_type NOT NULL,
amount numeric(10,2) NOT NULL,
description text,
category text,
payment_method text,
paid_at timestamp with time zone,
due_date date,
installments smallint DEFAULT 1,
installment_number smallint DEFAULT 1,
installment_group uuid,
agenda_evento_id uuid,
patient_id uuid,
clinic_fee_pct numeric(5,2) DEFAULT 0,
clinic_fee_amount numeric(10,2) DEFAULT 0,
net_amount numeric(10,2) GENERATED ALWAYS AS ((amount - clinic_fee_amount)) STORED,
insurance_plan_id uuid,
notes text,
tags text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
deleted_at timestamp with time zone,
discount_amount numeric(10,2) DEFAULT 0 NOT NULL,
final_amount numeric(10,2) DEFAULT 0 NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
category_id uuid,
CONSTRAINT financial_records_amount_check CHECK ((amount >= (0)::numeric)),
CONSTRAINT financial_records_clinic_fee_amount_check CHECK ((clinic_fee_amount >= (0)::numeric)),
CONSTRAINT financial_records_clinic_fee_pct_check CHECK (((clinic_fee_pct >= (0)::numeric) AND (clinic_fee_pct <= (100)::numeric))),
CONSTRAINT financial_records_discount_amount_check CHECK ((discount_amount >= (0)::numeric)),
CONSTRAINT financial_records_final_amount_check CHECK ((final_amount >= (0)::numeric)),
CONSTRAINT financial_records_installments_check CHECK ((installments >= 1)),
CONSTRAINT financial_records_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'partial'::text, 'overdue'::text, 'cancelled'::text, 'refunded'::text])))
);
ALTER TABLE public.financial_records OWNER TO supabase_admin;
--
-- Name: create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS SETOF public.financial_records
CREATE FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer DEFAULT 60) RETURNS json
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_admin_id uuid;
v_role text;
v_token text;
v_expires timestamp with time zone;
v_session support_sessions;
BEGIN
-- Verifica autenticação
v_admin_id := auth.uid();
IF v_admin_id IS NULL THEN
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
END IF;
-- Verifica role saas_admin
SELECT role INTO v_role
FROM public.profiles
WHERE id = v_admin_id;
IF v_role <> 'saas_admin' THEN
RAISE EXCEPTION 'Acesso negado. Somente saas_admin pode criar sessões de suporte.'
USING ERRCODE = 'P0002';
END IF;
-- Valida TTL (1 a 120 minutos)
IF p_ttl_minutes < 1 OR p_ttl_minutes > 120 THEN
RAISE EXCEPTION 'TTL inválido. Use entre 1 e 120 minutos.'
USING ERRCODE = 'P0003';
END IF;
-- Valida tenant
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'Tenant não encontrado.'
USING ERRCODE = 'P0004';
END IF;
-- Gera token único (64 chars hex, sem pgcrypto)
v_token := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
v_expires := now() + (p_ttl_minutes || ' minutes')::interval;
-- Insere sessão
INSERT INTO public.support_sessions (tenant_id, admin_id, token, expires_at)
VALUES (p_tenant_id, v_admin_id, v_token, v_expires)
RETURNING * INTO v_session;
RETURN json_build_object(
'token', v_session.token,
'expires_at', v_session.expires_at,
'session_id', v_session.id
);
END;
$$;
ALTER FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer) OWNER TO supabase_admin;
--
-- Name: therapist_payouts; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.therapist_payouts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
total_sessions integer DEFAULT 0 NOT NULL,
gross_amount numeric(10,2) DEFAULT 0 NOT NULL,
clinic_fee_total numeric(10,2) DEFAULT 0 NOT NULL,
net_amount numeric(10,2) DEFAULT 0 NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
paid_at timestamp with time zone,
notes text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT therapist_payouts_clinic_fee_total_check CHECK ((clinic_fee_total >= (0)::numeric)),
CONSTRAINT therapist_payouts_gross_amount_check CHECK ((gross_amount >= (0)::numeric)),
CONSTRAINT therapist_payouts_net_amount_check CHECK ((net_amount >= (0)::numeric)),
CONSTRAINT therapist_payouts_period_chk CHECK ((period_end >= period_start)),
CONSTRAINT therapist_payouts_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'cancelled'::text])))
);
ALTER TABLE public.therapist_payouts OWNER TO supabase_admin;
--
-- Name: create_therapist_payout(uuid, uuid, date, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS public.therapist_payouts
CREATE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_is_native boolean;
v_fields int := 0;
v_logs int := 0;
v_parent int := 0;
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
if not exists (
select 1
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
and tm.status = 'active'
) then
raise exception 'Not allowed';
end if;
select dc.is_native
into v_is_native
from public.determined_commitments dc
where dc.tenant_id = p_tenant_id
and dc.id = p_commitment_id;
if v_is_native is null then
raise exception 'Commitment not found';
end if;
if v_is_native = true then
raise exception 'Cannot delete native commitment';
end if;
delete from public.determined_commitment_fields
where tenant_id = p_tenant_id
and commitment_id = p_commitment_id;
get diagnostics v_fields = row_count;
delete from public.commitment_time_logs
where tenant_id = p_tenant_id
and commitment_id = p_commitment_id;
get diagnostics v_logs = row_count;
delete from public.determined_commitments
where tenant_id = p_tenant_id
and id = p_commitment_id;
get diagnostics v_parent = row_count;
if v_parent <> 1 then
raise exception 'Parent not deleted (RLS/owner issue).';
end if;
return jsonb_build_object(
'ok', true,
'deleted', jsonb_build_object(
'fields', v_fields,
'logs', v_logs,
'commitment', v_parent
)
);
end;
$$;
ALTER FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) OWNER TO postgres;
--
-- Name: delete_determined_commitment(uuid, uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_is_native boolean;
v_fields_deleted int := 0;
v_logs_deleted int := 0;
v_commitment_deleted int := 0;
begin
if auth.uid() is null then
raise exception 'Not authenticated';
end if;
if not exists (
select 1
from public.tenant_members tm
where tm.tenant_id = p_tenant_id
and tm.user_id = auth.uid()
and tm.status = 'active'
) then
raise exception 'Not allowed';
end if;
select dc.is_native
into v_is_native
from public.determined_commitments dc
where dc.tenant_id = p_tenant_id
and dc.id = p_commitment_id;
if v_is_native is null then
raise exception 'Commitment not found for tenant';
end if;
if v_is_native = true then
raise exception 'Cannot delete native commitment';
end if;
delete from public.determined_commitment_fields f
where f.tenant_id = p_tenant_id
and f.commitment_id = p_commitment_id;
get diagnostics v_fields_deleted = row_count;
delete from public.commitment_time_logs l
where l.tenant_id = p_tenant_id
and l.commitment_id = p_commitment_id;
get diagnostics v_logs_deleted = row_count;
delete from public.determined_commitments dc
where dc.tenant_id = p_tenant_id
and dc.id = p_commitment_id;
get diagnostics v_commitment_deleted = row_count;
if v_commitment_deleted <> 1 then
raise exception 'Delete did not remove the commitment (tenant mismatch?)';
end if;
return jsonb_build_object(
'ok', true,
'tenant_id', p_tenant_id,
'commitment_id', p_commitment_id,
'deleted', jsonb_build_object(
'fields', v_fields_deleted,
'logs', v_logs_deleted,
'commitment', v_commitment_deleted
)
);
end;
$$;
ALTER FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) OWNER TO supabase_admin;
--
-- Name: dev_list_auth_users(integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.dev_list_auth_users(p_limit integer DEFAULT 50) RETURNS TABLE(id uuid, email text, created_at timestamp with time zone)
CREATE FUNCTION public.faq_votar(faq_id uuid) RETURNS void
LANGUAGE sql SECURITY DEFINER
AS $$
update public.saas_faq
set votos = votos + 1,
updated_at = now()
where id = faq_id
and ativo = true;
$$;
ALTER FUNCTION public.faq_votar(faq_id uuid) OWNER TO supabase_admin;
--
-- Name: fix_all_subscription_mismatches(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
r record;
begin
for r in
select distinct s.user_id as owner_id
from public.subscriptions s
where s.status = 'active'
and s.user_id is not null
loop
perform public.rebuild_owner_entitlements(r.owner_id);
end loop;
end;
$$;
ALTER FUNCTION public.fix_all_subscription_mismatches() OWNER TO supabase_admin;
--
-- Name: fn_agenda_regras_semanais_no_overlap(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger
CREATE FUNCTION public.guard_locked_commitment() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
if (old.is_locked = true) then
if (tg_op = 'DELETE') then
raise exception 'Compromisso bloqueado não pode ser excluído.';
end if;
if (tg_op = 'UPDATE') then
if (new.active = false) then
raise exception 'Compromisso bloqueado não pode ser desativado.';
end if;
-- trava renomear (mantém o "Sessão" sempre igual)
if (new.name is distinct from old.name) then
raise exception 'Compromisso bloqueado não pode ser renomeado.';
end if;
-- se quiser travar descrição também, descomente:
-- if (new.description is distinct from old.description) then
-- raise exception 'Compromisso bloqueado não pode alterar descrição.';
-- end if;
end if;
end if;
return new;
end;
$$;
ALTER FUNCTION public.guard_locked_commitment() OWNER TO supabase_admin;
--
-- Name: guard_no_change_core_plan_key(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.guard_no_change_core_plan_key() RETURNS trigger
CREATE FUNCTION public.notice_track_click(p_notice_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
begin
update public.global_notices
set clicks_count = clicks_count + 1
where id = p_notice_id;
end;
$$;
ALTER FUNCTION public.notice_track_click(p_notice_id uuid) OWNER TO supabase_admin;
--
-- Name: notice_track_view(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notice_track_view(p_notice_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
begin
update public.global_notices
set views_count = views_count + 1
where id = p_notice_id;
end;
$$;
ALTER FUNCTION public.notice_track_view(p_notice_id uuid) OWNER TO supabase_admin;
--
-- Name: notify_on_intake(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
CREATE FUNCTION public.revoke_support_session(p_token text) RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_admin_id uuid;
v_role text;
BEGIN
v_admin_id := auth.uid();
IF v_admin_id IS NULL THEN
RAISE EXCEPTION 'Não autenticado.' USING ERRCODE = 'P0001';
END IF;
SELECT role INTO v_role FROM public.profiles WHERE id = v_admin_id;
IF v_role <> 'saas_admin' THEN
RAISE EXCEPTION 'Acesso negado.' USING ERRCODE = 'P0002';
END IF;
DELETE FROM public.support_sessions
WHERE token = p_token
AND admin_id = v_admin_id;
RETURN FOUND;
END;
$$;
ALTER FUNCTION public.revoke_support_session(p_token text) OWNER TO supabase_admin;
--
-- Name: rotate_patient_invite_token(text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.rotate_patient_invite_token(p_new_token text) RETURNS uuid
CREATE FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_uid uuid := auth.uid();
v_voto_antigo boolean;
begin
if v_uid is null then
raise exception 'Não autenticado';
end if;
-- Verifica se já votou
select util into v_voto_antigo
from public.saas_doc_votos
where doc_id = p_doc_id and user_id = v_uid;
if found then
-- Já votou igual → cancela o voto (toggle)
if v_voto_antigo = p_util then
delete from public.saas_doc_votos
where doc_id = p_doc_id and user_id = v_uid;
update public.saas_docs set
votos_util = greatest(0, votos_util - (case when p_util then 1 else 0 end)),
votos_nao_util = greatest(0, votos_nao_util - (case when not p_util then 1 else 0 end)),
updated_at = now()
where id = p_doc_id;
return jsonb_build_object('acao', 'removido', 'util', null);
else
-- Mudou de voto
update public.saas_doc_votos set util = p_util, updated_at = now()
where doc_id = p_doc_id and user_id = v_uid;
update public.saas_docs set
votos_util = greatest(0, votos_util + (case when p_util then 1 else -1 end)),
votos_nao_util = greatest(0, votos_nao_util + (case when not p_util then 1 else -1 end)),
updated_at = now()
where id = p_doc_id;
return jsonb_build_object('acao', 'atualizado', 'util', p_util);
end if;
else
-- Primeiro voto
insert into public.saas_doc_votos (doc_id, user_id, util)
values (p_doc_id, v_uid, p_util);
update public.saas_docs set
votos_util = votos_util + (case when p_util then 1 else 0 end),
votos_nao_util = votos_nao_util + (case when not p_util then 1 else 0 end),
updated_at = now()
where id = p_doc_id;
return jsonb_build_object('acao', 'registrado', 'util', p_util);
end if;
end;
$$;
ALTER FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) OWNER TO supabase_admin;
--
-- Name: safe_delete_patient(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
CREATE FUNCTION public.sanitize_phone_br(raw_phone text) RETURNS text
LANGUAGE plpgsql IMMUTABLE
AS $$ DECLARE digits text;
BEGIN
digits := regexp_replace(COALESCE(raw_phone, ''), '[^0-9]', '', 'g');
IF digits = '' THEN RETURN ''; END IF;
IF length(digits) = 10 OR length(digits) = 11 THEN
digits := '55' || digits;
END IF;
RETURN digits;
END; $$;
ALTER FUNCTION public.sanitize_phone_br(raw_phone text) OWNER TO supabase_admin;
--
-- Name: seed_default_financial_categories(uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.seed_default_financial_categories(p_user_id uuid) RETURNS void
CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_id uuid;
begin
-- Sessão (locked + sempre ativa)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
end if;
-- Leitura
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if;
-- Supervisão
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
end if;
-- Aula ✅ (corrigido)
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if;
-- Análise pessoal
if not exists (
select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
) then
insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description)
values
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
end if;
-- -------------------------------------------------------
-- Campos padrão (idempotentes por (commitment_id, key))
-- -------------------------------------------------------
-- Leitura
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'book') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Supervisão
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'supervisor') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Aula
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'theme') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
-- Análise
select id into v_id
from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
limit 1;
if v_id is not null then
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'analyst') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
end if;
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
end if;
end if;
end;
$$;
ALTER FUNCTION public.seed_determined_commitments(p_tenant_id uuid) OWNER TO supabase_admin;
--
-- Name: set_insurance_plans_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.set_insurance_plans_updated_at() RETURNS trigger
CREATE FUNCTION public.validate_support_session(p_token text) RETURNS json
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_session support_sessions;
BEGIN
IF p_token IS NULL OR length(trim(p_token)) < 32 THEN
RETURN json_build_object('valid', false, 'tenant_id', null);
END IF;
SELECT * INTO v_session
FROM public.support_sessions
WHERE token = p_token
AND expires_at > now()
LIMIT 1;
IF NOT FOUND THEN
RETURN json_build_object('valid', false, 'tenant_id', null);
END IF;
RETURN json_build_object(
'valid', true,
'tenant_id', v_session.tenant_id
);
END;
$$;
ALTER FUNCTION public.validate_support_session(p_token text) OWNER TO supabase_admin;
--
-- Name: whoami(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)

View File

@@ -0,0 +1,404 @@
-- =============================================================================
-- AgenciaPsi — Functions — Notificações
-- =============================================================================
-- cancel_notifications_on_opt_out, cancel_notifications_on_session_cancel,
-- cancel_patient_pending_notifications, cleanup_notification_queue,
-- notify_on_intake, notify_on_scheduling, notify_on_session_status,
-- populate_notification_queue, unstick_notification_queue
-- =============================================================================
CREATE FUNCTION public.cancel_notifications_on_opt_out() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
-- WhatsApp opt-out
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, 'whatsapp'
);
END IF;
-- Email opt-out
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, 'email'
);
END IF;
-- SMS opt-out
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, 'sms'
);
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.cancel_notifications_on_opt_out() OWNER TO supabase_admin;
--
-- Name: cancel_notifications_on_session_cancel(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status IN ('cancelado', 'excluido')
AND OLD.status NOT IN ('cancelado', 'excluido')
THEN
PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id
);
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.cancel_notifications_on_session_cancel() OWNER TO supabase_admin;
--
-- Name: cancel_patient_pending_notifications(uuid, text, uuid); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL::text, p_evento_id uuid DEFAULT NULL::uuid) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_canceled integer;
BEGIN
UPDATE public.notification_queue
SET status = 'cancelado',
updated_at = now()
WHERE patient_id = p_patient_id
AND status IN ('pendente', 'processando')
AND (p_channel IS NULL OR channel = p_channel)
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
GET DIAGNOSTICS v_canceled = ROW_COUNT;
RETURN v_canceled;
END;
$$;
ALTER FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text, p_evento_id uuid) OWNER TO supabase_admin;
--
-- Name: cancel_recurrence_from(uuid, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
CREATE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL::text, p_evento_id uuid DEFAULT NULL::uuid) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_canceled integer;
BEGIN
UPDATE public.notification_queue
SET status = 'cancelado',
updated_at = now()
WHERE patient_id = p_patient_id
AND status IN ('pendente', 'processando')
AND (p_channel IS NULL OR channel = p_channel)
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
GET DIAGNOSTICS v_canceled = ROW_COUNT;
RETURN v_canceled;
END;
$$;
ALTER FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text, p_evento_id uuid) OWNER TO supabase_admin;
--
-- Name: cancel_recurrence_from(uuid, date); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
CREATE FUNCTION public.cleanup_notification_queue() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_deleted integer;
BEGIN
DELETE FROM public.notification_queue
WHERE status IN ('enviado', 'cancelado', 'ignorado')
AND created_at < now() - interval '90 days';
GET DIAGNOSTICS v_deleted = ROW_COUNT;
RETURN v_deleted;
END;
$$;
ALTER FUNCTION public.cleanup_notification_queue() OWNER TO supabase_admin;
--
-- Name: create_clinic_tenant(text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
AS $$
declare
v_uid uuid;
v_tenant uuid;
v_name text;
begin
v_uid := auth.uid();
if v_uid is null then
raise exception 'Not authenticated';
end if;
v_name := nullif(trim(coalesce(p_name, '')), '');
if v_name is null then
v_name := 'Clínica';
end if;
insert into public.tenants (name, kind, created_at)
values (v_name, 'clinic', now())
returning id into v_tenant;
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
return v_tenant;
end;
$$;
ALTER FUNCTION public.create_clinic_tenant(p_name text) OWNER TO supabase_admin;
--
-- Name: financial_records; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.financial_records (
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'new' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_patient',
NEW.id,
'patient_intake_requests',
jsonb_build_object(
'title', 'Novo cadastro externo',
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
'deeplink', '/therapist/patients/cadastro/recebidos',
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.notify_on_intake() OWNER TO supabase_admin;
--
-- Name: notify_on_scheduling(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notify_on_scheduling() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$ BEGIN IF NEW.status = 'pendente' THEN
INSERT INTO public.notifications ( owner_id, tenant_id, type, ref_id, ref_table, payload ) VALUES (
NEW.owner_id, NEW.tenant_id,
'new_scheduling', NEW.id, 'agendador_solicitacoes', jsonb_build_object( 'title', 'Nova solicitação de agendamento', 'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || '' || COALESCE(NEW.tipo, ''), 'deeplink', '/therapist/agendamentos-recebidos', 'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1)) ) ); END IF; RETURN NEW; END; $$;
ALTER FUNCTION public.notify_on_scheduling() OWNER TO supabase_admin;
--
-- Name: notify_on_session_status(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notify_on_session_status() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_nome text;
BEGIN
IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
SELECT nome_completo
INTO v_nome
FROM public.patients
WHERE id = NEW.patient_id
LIMIT 1;
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'session_status',
NEW.id,
'agenda_eventos',
jsonb_build_object(
'title', CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
'detail', COALESCE(v_nome, 'Paciente') || '' || to_char(NEW.inicio_em, 'DD/MM HH24:MI'),
'deeplink', '/therapist/agenda',
'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.notify_on_session_status() OWNER TO supabase_admin;
--
-- Name: on_new_user_seed_patient_groups(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.on_new_user_seed_patient_groups() RETURNS trigger
CREATE FUNCTION public.populate_notification_queue() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.notification_queue (
tenant_id, owner_id, agenda_evento_id, patient_id,
channel, template_key, schedule_key,
resolved_vars, recipient_address,
scheduled_at, idempotency_key
)
SELECT
ae.tenant_id,
ae.owner_id,
ae.id AS agenda_evento_id,
ae.patient_id,
ch.channel,
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
ns.schedule_key,
jsonb_build_object(
'nome_paciente', COALESCE(p.nome_completo, 'Paciente'),
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo', 'DD/MM/YYYY'),
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo', 'HH24:MI'),
'nome_terapeuta', COALESCE(prof.full_name, 'Terapeuta'),
'modalidade', COALESCE(ae.modalidade, 'Presencial'),
'titulo', COALESCE(ae.titulo, 'Sessão')
),
CASE ch.channel
WHEN 'whatsapp' THEN COALESCE(p.telefone, '')
WHEN 'sms' THEN COALESCE(p.telefone, '')
WHEN 'email' THEN COALESCE(p.email_principal, '')
END,
CASE
WHEN (ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)::time
< ns.allowed_time_start
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)
+ ns.allowed_time_start
WHEN (ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)::time
> ns.allowed_time_end
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)
+ ns.allowed_time_start
ELSE ae.inicio_em - (ns.offset_minutes || ' minutes')::interval
END,
ae.id::text || ':' || ns.schedule_key || ':' || ch.channel || ':'
|| ae.inicio_em::date::text
FROM public.agenda_eventos ae
JOIN public.patients p ON p.id = ae.patient_id
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id
JOIN public.notification_schedules ns
ON ns.owner_id = ae.owner_id
AND ns.is_active = true
AND ns.deleted_at IS NULL
AND ns.trigger_type = 'before_event'
AND ns.event_type = 'lembrete_sessao'
JOIN public.notification_channels nc
ON nc.owner_id = ae.owner_id
AND nc.is_active = true
AND nc.deleted_at IS NULL
CROSS JOIN LATERAL (
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel = 'whatsapp'
UNION ALL
SELECT 'email' AS channel WHERE ns.email_enabled AND nc.channel = 'email'
UNION ALL
SELECT 'sms' AS channel WHERE ns.sms_enabled AND nc.channel = 'sms'
) ch
LEFT JOIN public.notification_preferences np
ON np.patient_id = ae.patient_id
AND np.owner_id = ae.owner_id
AND np.deleted_at IS NULL
WHERE
ae.inicio_em > now()
AND ae.inicio_em <= now() + interval '48 hours'
AND ae.status NOT IN ('cancelado', 'faltou')
AND CASE ch.channel
WHEN 'whatsapp' THEN COALESCE(p.telefone, '') != ''
WHEN 'sms' THEN COALESCE(p.telefone, '') != ''
WHEN 'email' THEN COALESCE(p.email_principal, '') != ''
END
AND CASE ch.channel
WHEN 'whatsapp' THEN COALESCE(np.whatsapp_opt_in, true)
WHEN 'email' THEN COALESCE(np.email_opt_in, true)
WHEN 'sms' THEN COALESCE(np.sms_opt_in, false)
END
AND EXISTS (
SELECT 1 FROM public.profiles tp
WHERE tp.id = ae.owner_id
AND COALESCE(tp.notify_reminders, true) = true
)
ON CONFLICT (idempotency_key) DO NOTHING;
END;
$$;
ALTER FUNCTION public.populate_notification_queue() OWNER TO supabase_admin;
--
-- Name: prevent_promoting_to_system(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.prevent_promoting_to_system() RETURNS trigger
CREATE FUNCTION public.unstick_notification_queue() RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_unstuck integer;
BEGIN
UPDATE public.notification_queue
SET status = 'pendente',
attempts = attempts + 1,
last_error = 'Timeout: preso em processando por >10min',
next_retry_at = now() + interval '2 minutes'
WHERE status = 'processando'
AND updated_at < now() - interval '10 minutes';
GET DIAGNOSTICS v_unstuck = ROW_COUNT;
RETURN v_unstuck;
END;
$$;
ALTER FUNCTION public.unstick_notification_queue() OWNER TO supabase_admin;
--
-- Name: update_payment_settings_updated_at(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger

View File

@@ -0,0 +1,433 @@
-- =============================================================================
-- AgenciaPsi — Functions — Pacientes
-- =============================================================================
-- can_delete_patient, safe_delete_patient, create_patient_intake_request,
-- create_patient_intake_request_v2, rotate_patient_invite_token,
-- patients_validate_member_consistency, prevent_system_group_changes,
-- seed_default_patient_groups
-- =============================================================================
CREATE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT NOT EXISTS (
SELECT 1 FROM public.agenda_eventos WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM public.recurrence_rules WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM public.billing_contracts WHERE patient_id = p_patient_id
);
$$;
ALTER FUNCTION public.can_delete_patient(p_patient_id uuid) OWNER TO supabase_admin;
--
-- Name: cancel_notifications_on_opt_out(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.cancel_notifications_on_opt_out() RETURNS trigger
CREATE FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text DEFAULT NULL::text, p_phone text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_consent boolean DEFAULT false) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_owner uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_id uuid;
begin
select owner_id, active, expires_at, max_uses, uses
into v_owner, v_active, v_expires, v_max_uses, v_uses
from public.patient_invites
where token = p_token
limit 1;
if v_owner is null then
raise exception 'Token inválido';
end if;
if v_active is not true then
raise exception 'Link desativado';
end if;
if v_expires is not null and now() > v_expires then
raise exception 'Link expirado';
end if;
if v_max_uses is not null and v_uses >= v_max_uses then
raise exception 'Limite de uso atingido';
end if;
if p_name is null or length(trim(p_name)) = 0 then
raise exception 'Nome é obrigatório';
end if;
insert into public.patient_intake_requests
(owner_id, token, name, email, phone, notes, consent, status)
values
(v_owner, p_token, trim(p_name),
nullif(lower(trim(p_email)), ''),
nullif(trim(p_phone), ''),
nullif(trim(p_notes), ''),
coalesce(p_consent, false),
'new')
returning id into v_id;
update public.patient_invites
set uses = uses + 1
where token = p_token;
return v_id;
end;
$$;
ALTER FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text, p_phone text, p_notes text, p_consent boolean) OWNER TO supabase_admin;
--
-- Name: create_patient_intake_request_v2(text, jsonb); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $_$
declare
v_owner_id uuid;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
begin
select owner_id
into v_owner_id
from public.patient_invites
where token = p_token;
if v_owner_id is null then
raise exception 'Token inválido ou expirado';
end if;
v_birth_raw := nullif(trim(coalesce(
p_payload->>'data_nascimento',
''
)), '');
v_birth := case
when v_birth_raw is null then null
when v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' then v_birth_raw::date
when v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' then to_date(v_birth_raw, 'DD-MM-YYYY')
else null
end;
insert into public.patient_intake_requests (
owner_id,
token,
status,
consent,
nome_completo,
email_principal,
telefone,
avatar_url, -- 🔥 AQUI
data_nascimento,
cpf,
rg,
genero,
estado_civil,
profissao,
escolaridade,
nacionalidade,
naturalidade,
cep,
pais,
cidade,
estado,
endereco,
numero,
complemento,
bairro,
observacoes,
notas_internas,
encaminhado_por,
onde_nos_conheceu
)
values (
v_owner_id,
p_token,
'new',
coalesce((p_payload->>'consent')::boolean, false),
nullif(trim(p_payload->>'nome_completo'), ''),
nullif(trim(p_payload->>'email_principal'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(trim(p_payload->>'avatar_url'), ''), -- 🔥 AQUI
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
nullif(trim(p_payload->>'rg'), ''),
nullif(trim(p_payload->>'genero'), ''),
nullif(trim(p_payload->>'estado_civil'), ''),
nullif(trim(p_payload->>'profissao'), ''),
nullif(trim(p_payload->>'escolaridade'), ''),
nullif(trim(p_payload->>'nacionalidade'), ''),
nullif(trim(p_payload->>'naturalidade'), ''),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
nullif(trim(p_payload->>'pais'), ''),
nullif(trim(p_payload->>'cidade'), ''),
nullif(trim(p_payload->>'estado'), ''),
nullif(trim(p_payload->>'endereco'), ''),
nullif(trim(p_payload->>'numero'), ''),
nullif(trim(p_payload->>'complemento'), ''),
nullif(trim(p_payload->>'bairro'), ''),
nullif(trim(p_payload->>'observacoes'), ''),
nullif(trim(p_payload->>'notas_internas'), ''),
nullif(trim(p_payload->>'encaminhado_por'), ''),
nullif(trim(p_payload->>'onde_nos_conheceu'), '')
)
returning id into v_intake_id;
return v_intake_id;
end;
$_$;
ALTER FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb) OWNER TO supabase_admin;
--
-- Name: create_support_session(uuid, integer); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer DEFAULT 60) RETURNS json
CREATE FUNCTION public.patients_validate_member_consistency() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_tenant_responsible uuid;
v_tenant_therapist uuid;
BEGIN
-- responsible_member sempre deve existir e ser do tenant
SELECT tenant_id INTO v_tenant_responsible
FROM public.tenant_members
WHERE id = NEW.responsible_member_id;
IF v_tenant_responsible IS NULL THEN
RAISE EXCEPTION 'Responsible member not found';
END IF;
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id is required';
END IF;
IF v_tenant_responsible <> NEW.tenant_id THEN
RAISE EXCEPTION 'Responsible member must belong to the same tenant';
END IF;
-- therapist scope: therapist_member_id deve existir e ser do mesmo tenant
IF NEW.patient_scope = 'therapist' THEN
IF NEW.therapist_member_id IS NULL THEN
RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist';
END IF;
SELECT tenant_id INTO v_tenant_therapist
FROM public.tenant_members
WHERE id = NEW.therapist_member_id;
IF v_tenant_therapist IS NULL THEN
RAISE EXCEPTION 'Therapist member not found';
END IF;
IF v_tenant_therapist <> NEW.tenant_id THEN
RAISE EXCEPTION 'Therapist member must belong to the same tenant';
END IF;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.patients_validate_member_consistency() OWNER TO supabase_admin;
--
-- Name: patients_validate_responsible_member_tenant(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.patients_validate_responsible_member_tenant() RETURNS trigger
LANGUAGE plpgsql
AS $$
declare
m_tenant uuid;
begin
select tenant_id into m_tenant
from public.tenant_members
where id = new.responsible_member_id;
if m_tenant is null then
raise exception 'Responsible member not found';
end if;
if new.tenant_id is null then
raise exception 'tenant_id is required';
end if;
if m_tenant <> new.tenant_id then
raise exception 'Responsible member must belong to the same tenant';
end if;
return new;
end;
$$;
ALTER FUNCTION public.patients_validate_responsible_member_tenant() OWNER TO supabase_admin;
--
-- Name: populate_notification_queue(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.populate_notification_queue() RETURNS void
CREATE FUNCTION public.prevent_system_group_changes() RETURNS trigger
LANGUAGE plpgsql
AS $$
begin
-- Se for grupo do sistema, regras rígidas:
if old.is_system = true then
-- nunca pode deletar
if tg_op = 'DELETE' then
raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
end if;
if tg_op = 'UPDATE' then
-- permite SOMENTE mudar tenant_id e/ou updated_at
-- qualquer mudança de conteúdo permanece proibida
if
new.nome is distinct from old.nome or
new.descricao is distinct from old.descricao or
new.cor is distinct from old.cor or
new.is_active is distinct from old.is_active or
new.is_system is distinct from old.is_system or
new.owner_id is distinct from old.owner_id or
new.therapist_id is distinct from old.therapist_id or
new.created_at is distinct from old.created_at
then
raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
end if;
-- chegou aqui: só tenant_id/updated_at mudaram -> ok
return new;
end if;
end if;
-- não-system: deixa passar
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
ALTER FUNCTION public.prevent_system_group_changes() OWNER TO supabase_admin;
--
-- Name: provision_account_tenant(uuid, text, text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text) RETURNS uuid
CREATE FUNCTION public.rotate_patient_invite_token(p_new_token text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public'
AS $$
declare
v_uid uuid;
v_id uuid;
begin
-- pega o usuário logado
v_uid := auth.uid();
if v_uid is null then
raise exception 'Usuário não autenticado';
end if;
-- desativa tokens antigos ativos do usuário
update public.patient_invites
set active = false
where owner_id = v_uid
and active = true;
-- cria novo token
insert into public.patient_invites (owner_id, token, active)
values (v_uid, p_new_token, true)
returning id into v_id;
return v_id;
end;
$$;
ALTER FUNCTION public.rotate_patient_invite_token(p_new_token text) OWNER TO supabase_admin;
--
-- Name: saas_votar_doc(uuid, boolean); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) RETURNS jsonb
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
-- Bloqueia se houver histórico
IF NOT public.can_delete_patient(p_patient_id) THEN
RETURN jsonb_build_object(
'ok', false,
'error', 'has_history',
'message', 'Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.'
);
END IF;
-- Verifica ownership via RLS (owner_id ou responsible_member_id)
IF NOT EXISTS (
SELECT 1 FROM public.patients
WHERE id = p_patient_id
AND (
owner_id = auth.uid()
OR responsible_member_id IN (
SELECT id FROM public.tenant_members WHERE user_id = auth.uid()
)
)
) THEN
RETURN jsonb_build_object(
'ok', false,
'error', 'forbidden',
'message', 'Sem permissão para excluir este paciente.'
);
END IF;
DELETE FROM public.patients WHERE id = p_patient_id;
RETURN jsonb_build_object('ok', true);
END;
$$;
ALTER FUNCTION public.safe_delete_patient(p_patient_id uuid) OWNER TO supabase_admin;
--
-- Name: sanitize_phone_br(text); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.sanitize_phone_br(raw_phone text) RETURNS text

View File

@@ -0,0 +1,721 @@
-- =============================================================================
-- AgenciaPsi — Functions — realtime schema
-- =============================================================================
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;
$$;
ALTER FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer) OWNER TO supabase_admin;
--
-- Name: broadcast_changes(text, text, text, text, text, record, record, text); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text DEFAULT 'ROW'::text) RETURNS void
LANGUAGE plpgsql
AS $$
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;
$$;
ALTER FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text) OWNER TO supabase_admin;
--
-- Name: build_prepared_statement_sql(text, regclass, realtime.wal_column[]); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) RETURNS text
LANGUAGE sql
AS $$
/*
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
$$;
ALTER FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) OWNER TO supabase_admin;
--
-- Name: cast(text, regtype); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime."cast"(val text, type_ regtype) RETURNS jsonb
LANGUAGE plpgsql IMMUTABLE
AS $$
declare
res jsonb;
begin
execute format('select to_jsonb(%L::'|| type_::text || ')', val) into res;
return res;
end
$$;
ALTER FUNCTION realtime."cast"(val text, type_ regtype) OWNER TO supabase_admin;
--
-- Name: check_equality_op(realtime.equality_op, regtype, text, text); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) RETURNS boolean
LANGUAGE plpgsql IMMUTABLE
AS $$
/*
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;
$$;
ALTER FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) OWNER TO supabase_admin;
--
-- Name: is_visible_through_filters(realtime.wal_column[], realtime.user_defined_filter[]); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) RETURNS boolean
LANGUAGE sql IMMUTABLE
AS $_$
/*
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;
$_$;
ALTER FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) OWNER TO supabase_admin;
--
-- Name: list_changes(name, name, integer, integer); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer) RETURNS SETOF realtime.wal_rls
LANGUAGE sql
SET log_min_messages TO 'fatal'
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
$$;
ALTER FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer) OWNER TO supabase_admin;
--
-- Name: quote_wal2json(regclass); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.quote_wal2json(entity regclass) RETURNS text
LANGUAGE sql IMMUTABLE STRICT
AS $$
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
$$;
ALTER FUNCTION realtime.quote_wal2json(entity regclass) OWNER TO supabase_admin;
--
-- Name: send(jsonb, text, text, boolean); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true) RETURNS void
LANGUAGE plpgsql
AS $$
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;
$$;
ALTER FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean) OWNER TO supabase_admin;
--
-- Name: subscription_check_filters(); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.subscription_check_filters() RETURNS trigger
LANGUAGE plpgsql
AS $$
/*
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;
$$;
ALTER FUNCTION realtime.subscription_check_filters() OWNER TO supabase_admin;
--
-- Name: to_regrole(text); Type: FUNCTION; Schema: realtime; Owner: supabase_admin
--
CREATE FUNCTION realtime.to_regrole(role_name text) RETURNS regrole
LANGUAGE sql IMMUTABLE
AS $$ select role_name::regrole $$;
ALTER FUNCTION realtime.to_regrole(role_name text) OWNER TO supabase_admin;
--
-- Name: topic(); Type: FUNCTION; Schema: realtime; Owner: supabase_realtime_admin
--
CREATE FUNCTION realtime.topic() RETURNS text
LANGUAGE sql STABLE
AS $$
select nullif(current_setting('realtime.topic', true), '')::text;
$$;
ALTER FUNCTION realtime.topic() OWNER TO supabase_realtime_admin;
--
-- Name: can_insert_object(text, text, uuid, jsonb); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--

View File

@@ -0,0 +1,877 @@
-- =============================================================================
-- AgenciaPsi — Functions — storage schema
-- =============================================================================
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
$$;
ALTER FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) OWNER TO supabase_storage_admin;
--
-- Name: enforce_bucket_name_length(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.enforce_bucket_name_length() RETURNS trigger
LANGUAGE plpgsql
AS $$
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;
$$;
ALTER FUNCTION storage.enforce_bucket_name_length() OWNER TO supabase_storage_admin;
--
-- Name: extension(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.extension(name text) RETURNS text
LANGUAGE plpgsql
AS $$
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
$$;
ALTER FUNCTION storage.extension(name text) OWNER TO supabase_storage_admin;
--
-- Name: filename(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.filename(name text) RETURNS text
LANGUAGE plpgsql
AS $$
DECLARE
_parts text[];
BEGIN
select string_to_array(name, '/') into _parts;
return _parts[array_length(_parts,1)];
END
$$;
ALTER FUNCTION storage.filename(name text) OWNER TO supabase_storage_admin;
--
-- Name: foldername(text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.foldername(name text) RETURNS text[]
LANGUAGE plpgsql
AS $$
DECLARE
_parts text[];
BEGIN
select string_to_array(name, '/') into _parts;
return _parts[1:array_length(_parts,1)-1];
END
$$;
ALTER FUNCTION storage.foldername(name text) OWNER TO supabase_storage_admin;
--
-- Name: get_common_prefix(text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) RETURNS text
LANGUAGE sql IMMUTABLE
AS $$
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;
$$;
ALTER FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) OWNER TO supabase_storage_admin;
--
-- Name: get_size_by_bucket(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.get_size_by_bucket() RETURNS TABLE(size bigint, bucket_id text)
LANGUAGE plpgsql
AS $$
BEGIN
return query
select sum((metadata->>'size')::int) as size, obj.bucket_id
from "storage".objects as obj
group by obj.bucket_id;
END
$$;
ALTER FUNCTION storage.get_size_by_bucket() OWNER TO supabase_storage_admin;
--
-- Name: list_multipart_uploads_with_delimiter(text, text, text, integer, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, next_key_token text DEFAULT ''::text, next_upload_token text DEFAULT ''::text) RETURNS TABLE(key text, id text, created_at timestamp with time zone)
LANGUAGE plpgsql
AS $_$
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;
$_$;
ALTER FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer, next_key_token text, next_upload_token text) OWNER TO supabase_storage_admin;
--
-- Name: list_objects_with_delimiter(text, text, text, integer, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.list_objects_with_delimiter(_bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, start_after text DEFAULT ''::text, next_token text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, metadata jsonb, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone)
LANGUAGE plpgsql STABLE
AS $_$
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;
$_$;
ALTER FUNCTION storage.list_objects_with_delimiter(_bucket_id text, prefix_param text, delimiter_param text, max_keys integer, start_after text, next_token text, sort_order text) OWNER TO supabase_storage_admin;
--
-- Name: operation(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.operation() RETURNS text
LANGUAGE plpgsql STABLE
AS $$
BEGIN
RETURN current_setting('storage.operation', true);
END;
$$;
ALTER FUNCTION storage.operation() OWNER TO supabase_storage_admin;
--
-- Name: protect_delete(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.protect_delete() RETURNS trigger
LANGUAGE plpgsql
AS $$
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;
$$;
ALTER FUNCTION storage.protect_delete() OWNER TO supabase_storage_admin;
--
-- Name: search(text, text, integer, integer, integer, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
LANGUAGE plpgsql STABLE
AS $_$
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;
$_$;
ALTER FUNCTION storage.search(prefix text, bucketname text, limits integer, levels integer, offsets integer, search text, sortcolumn text, sortorder text) OWNER TO supabase_storage_admin;
--
-- Name: search_by_timestamp(text, text, integer, integer, text, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.search_by_timestamp(p_prefix text, p_bucket_id text, p_limit integer, p_level integer, p_start_after text, p_sort_order text, p_sort_column text, p_sort_column_after text) RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
LANGUAGE plpgsql STABLE
AS $_$
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;
$_$;
ALTER FUNCTION storage.search_by_timestamp(p_prefix text, p_bucket_id text, p_limit integer, p_level integer, p_start_after text, p_sort_order text, p_sort_column text, p_sort_column_after text) OWNER TO supabase_storage_admin;
--
-- Name: search_v2(text, text, integer, integer, text, text, text, text); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.search_v2(prefix text, bucket_name text, limits integer DEFAULT 100, levels integer DEFAULT 1, start_after text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text, sort_column text DEFAULT 'name'::text, sort_column_after text DEFAULT ''::text) RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
LANGUAGE plpgsql STABLE
AS $$
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;
$$;
ALTER FUNCTION storage.search_v2(prefix text, bucket_name text, limits integer, levels integer, start_after text, sort_order text, sort_column text, sort_column_after text) OWNER TO supabase_storage_admin;
--
-- Name: update_updated_at_column(); Type: FUNCTION; Schema: storage; Owner: supabase_storage_admin
--
CREATE FUNCTION storage.update_updated_at_column() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
ALTER FUNCTION storage.update_updated_at_column() OWNER TO supabase_storage_admin;
--
-- Name: http_request(); Type: FUNCTION; Schema: supabase_functions; Owner: supabase_functions_admin
--

View File

@@ -0,0 +1,87 @@
-- =============================================================================
-- AgenciaPsi — Functions — supabase_functions schema
-- =============================================================================
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
$$;
ALTER FUNCTION supabase_functions.http_request() OWNER TO supabase_functions_admin;
--
-- Name: extensions; Type: TABLE; Schema: _realtime; Owner: supabase_admin
--

View File

@@ -0,0 +1,472 @@
-- =============================================================================
-- AgenciaPsi — Tables — Agenda + Recorrências + Agendador Online
-- =============================================================================
-- agenda_bloqueios, agenda_configuracoes, agenda_eventos, agenda_excecoes,
-- agenda_online_slots, agenda_regras_semanais, agenda_slots_bloqueados_semanais,
-- agenda_slots_regras, recurrence_rules, recurrence_exceptions,
-- recurrence_rule_services, agendador_configuracoes, agendador_solicitacoes
-- =============================================================================
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])))
);
ALTER TABLE public.agenda_bloqueios OWNER TO supabase_admin;
--
-- Name: agenda_configuracoes; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_configuracoes (
owner_id uuid NOT NULL,
duracao_padrao_minutos integer DEFAULT 50 NOT NULL,
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,
CONSTRAINT agenda_configuracoes_admin_slot_visual_minutos_check CHECK ((admin_slot_visual_minutos = ANY (ARRAY[5, 10, 15, 20, 30, 60]))),
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)))
);
ALTER TABLE public.agenda_configuracoes OWNER TO supabase_admin;
--
-- Name: agenda_eventos; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_eventos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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))
);
ALTER TABLE public.agenda_eventos OWNER TO supabase_admin;
--
-- Name: COLUMN agenda_eventos.price; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON COLUMN public.agenda_eventos.price IS 'Valor da sessão em BRL. Preenchido automaticamente pela tabela professional_pricing do profissional.';
--
-- Name: agenda_excecoes; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_excecoes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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])))
);
ALTER TABLE public.agenda_excecoes OWNER TO supabase_admin;
--
-- Name: agenda_online_slots; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_online_slots (
id bigint NOT NULL,
owner_id uuid NOT NULL,
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])))
);
ALTER TABLE public.agenda_online_slots OWNER TO supabase_admin;
--
-- Name: agenda_online_slots_id_seq; Type: SEQUENCE; Schema: public; Owner: supabase_admin
--
CREATE SEQUENCE public.agenda_online_slots_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.agenda_online_slots_id_seq OWNER TO supabase_admin;
--
-- Name: agenda_online_slots_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: supabase_admin
--
ALTER SEQUENCE public.agenda_online_slots_id_seq OWNED BY public.agenda_online_slots.id;
--
-- Name: agenda_regras_semanais; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_regras_semanais (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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)))
);
ALTER TABLE public.agenda_regras_semanais OWNER TO supabase_admin;
--
-- Name: agenda_slots_bloqueados_semanais; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_slots_bloqueados_semanais (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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)))
);
ALTER TABLE public.agenda_slots_bloqueados_semanais OWNER TO supabase_admin;
--
-- Name: agenda_slots_regras; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agenda_slots_regras (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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)))
);
ALTER TABLE public.agenda_slots_regras OWNER TO supabase_admin;
--
-- Name: agendador_configuracoes; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agendador_configuracoes (
owner_id uuid NOT NULL,
tenant_id uuid,
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)))
);
ALTER TABLE public.agendador_configuracoes OWNER TO supabase_admin;
--
-- Name: COLUMN agendador_configuracoes.pagamento_modo; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON COLUMN public.agendador_configuracoes.pagamento_modo IS 'sem_pagamento | pagar_na_hora | pix_antecipado';
--
-- Name: COLUMN agendador_configuracoes.pagamento_metodos_visiveis; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON COLUMN public.agendador_configuracoes.pagamento_metodos_visiveis IS 'Métodos exibidos ao paciente quando pagamento_modo = pagar_na_hora. Ex: {pix, deposito, dinheiro, cartao, convenio}';
--
-- Name: agendador_solicitacoes; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.agendador_solicitacoes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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])))
);
ALTER TABLE public.agendador_solicitacoes OWNER TO supabase_admin;
--
-- Name: billing_contracts; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.recurrence_exceptions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
recurrence_id uuid NOT NULL,
tenant_id uuid NOT NULL,
original_date date NOT NULL,
type public.recurrence_exception_type NOT NULL,
new_date date,
new_start_time time without time zone,
new_end_time time without time zone,
modalidade text,
observacoes text,
titulo_custom text,
extra_fields jsonb,
reason text,
agenda_evento_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE public.recurrence_exceptions OWNER TO supabase_admin;
--
-- Name: recurrence_rule_services; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.recurrence_rule_services (
id uuid DEFAULT gen_random_uuid() NOT NULL,
rule_id uuid NOT NULL,
service_id uuid NOT NULL,
quantity integer DEFAULT 1 NOT NULL,
unit_price numeric(10,2) NOT NULL,
discount_pct numeric(5,2) DEFAULT 0,
discount_flat numeric(10,2) DEFAULT 0,
final_price numeric(10,2) NOT NULL,
created_at timestamp with time zone DEFAULT now(),
CONSTRAINT recurrence_rule_services_disc_flat_chk CHECK ((discount_flat >= (0)::numeric)),
CONSTRAINT recurrence_rule_services_disc_pct_chk CHECK (((discount_pct >= (0)::numeric) AND (discount_pct <= (100)::numeric))),
CONSTRAINT recurrence_rule_services_final_price_chk CHECK ((final_price >= (0)::numeric)),
CONSTRAINT recurrence_rule_services_quantity_chk CHECK ((quantity > 0))
);
ALTER TABLE public.recurrence_rule_services OWNER TO supabase_admin;
--
-- Name: recurrence_rules; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.recurrence_rules (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL,
therapist_id uuid,
patient_id uuid,
determined_commitment_id uuid,
type public.recurrence_type DEFAULT 'weekly'::public.recurrence_type NOT NULL,
"interval" smallint DEFAULT 1 NOT NULL,
weekdays smallint[] DEFAULT '{}'::smallint[] NOT NULL,
start_time time without time zone NOT NULL,
end_time time without time zone NOT NULL,
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
duration_min smallint DEFAULT 50 NOT NULL,
start_date date NOT NULL,
end_date date,
max_occurrences integer,
open_ended boolean DEFAULT true NOT NULL,
modalidade text DEFAULT 'presencial'::text,
titulo_custom text,
observacoes text,
extra_fields jsonb,
status text DEFAULT 'ativo'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
price numeric(10,2),
insurance_plan_id uuid,
insurance_guide_number text,
insurance_value numeric(10,2),
insurance_plan_service_id uuid,
CONSTRAINT recurrence_rules_dates_chk CHECK (((end_date IS NULL) OR (end_date >= start_date))),
CONSTRAINT recurrence_rules_interval_chk CHECK (("interval" >= 1)),
CONSTRAINT recurrence_rules_status_check CHECK ((status = ANY (ARRAY['ativo'::text, 'pausado'::text, 'cancelado'::text]))),
CONSTRAINT recurrence_rules_times_chk CHECK ((end_time > start_time))
);

View File

@@ -0,0 +1,608 @@
-- =============================================================================
-- AgenciaPsi — Tables — auth schema (Supabase GoTrue)
-- =============================================================================
-- auth.users, auth.identities, auth.sessions, auth.refresh_tokens,
-- auth.mfa_*, auth.saml_*, auth.sso_*, auth.flow_state, etc.
-- =============================================================================
CREATE TABLE auth.audit_log_entries (
instance_id uuid,
id uuid NOT NULL,
payload json,
created_at timestamp with time zone,
ip_address character varying(64) DEFAULT ''::character varying NOT NULL
);
ALTER TABLE auth.audit_log_entries OWNER TO supabase_auth_admin;
--
-- Name: TABLE audit_log_entries; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.audit_log_entries IS 'Auth: Audit trail for user actions.';
--
-- Name: flow_state; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.flow_state (
id uuid NOT NULL,
user_id uuid,
auth_code text,
code_challenge_method auth.code_challenge_method,
code_challenge text,
provider_type text NOT NULL,
provider_access_token text,
provider_refresh_token text,
created_at timestamp with time zone,
updated_at timestamp with time zone,
authentication_method text NOT NULL,
auth_code_issued_at timestamp with time zone,
invite_token text,
referrer text,
oauth_client_state_id uuid,
linking_target_id uuid,
email_optional boolean DEFAULT false NOT NULL
);
ALTER TABLE auth.flow_state OWNER TO supabase_auth_admin;
--
-- Name: TABLE flow_state; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.flow_state IS 'Stores metadata for all OAuth/SSO login flows';
--
-- Name: identities; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.identities (
provider_id text NOT NULL,
user_id uuid NOT NULL,
identity_data jsonb NOT NULL,
provider text NOT NULL,
last_sign_in_at timestamp with time zone,
created_at timestamp with time zone,
updated_at timestamp with time zone,
email text GENERATED ALWAYS AS (lower((identity_data ->> 'email'::text))) STORED,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE auth.identities OWNER TO supabase_auth_admin;
--
-- Name: TABLE identities; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.identities IS 'Auth: Stores identities associated to a user.';
--
-- Name: COLUMN identities.email; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.identities.email IS 'Auth: Email is a generated column that references the optional email property in the identity_data';
--
-- Name: instances; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.instances (
id uuid NOT NULL,
uuid uuid,
raw_base_config text,
created_at timestamp with time zone,
updated_at timestamp with time zone
);
ALTER TABLE auth.instances OWNER TO supabase_auth_admin;
--
-- Name: TABLE instances; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.instances IS 'Auth: Manages users across multiple sites.';
--
-- Name: mfa_amr_claims; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.mfa_amr_claims (
session_id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
authentication_method text NOT NULL,
id uuid NOT NULL
);
ALTER TABLE auth.mfa_amr_claims OWNER TO supabase_auth_admin;
--
-- Name: TABLE mfa_amr_claims; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.mfa_amr_claims IS 'auth: stores authenticator method reference claims for multi factor authentication';
--
-- Name: mfa_challenges; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.mfa_challenges (
id uuid NOT NULL,
factor_id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
verified_at timestamp with time zone,
ip_address inet NOT NULL,
otp_code text,
web_authn_session_data jsonb
);
ALTER TABLE auth.mfa_challenges OWNER TO supabase_auth_admin;
--
-- Name: TABLE mfa_challenges; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.mfa_challenges IS 'auth: stores metadata about challenge requests made';
--
-- Name: mfa_factors; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.mfa_factors (
id uuid NOT NULL,
user_id uuid NOT NULL,
friendly_name text,
factor_type auth.factor_type NOT NULL,
status auth.factor_status NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
secret text,
phone text,
last_challenged_at timestamp with time zone,
web_authn_credential jsonb,
web_authn_aaguid uuid,
last_webauthn_challenge_data jsonb
);
ALTER TABLE auth.mfa_factors OWNER TO supabase_auth_admin;
--
-- Name: TABLE mfa_factors; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.mfa_factors IS 'auth: stores metadata about factors';
--
-- Name: COLUMN mfa_factors.last_webauthn_challenge_data; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.mfa_factors.last_webauthn_challenge_data IS 'Stores the latest WebAuthn challenge data including attestation/assertion for customer verification';
--
-- Name: oauth_authorizations; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.oauth_authorizations (
id uuid NOT NULL,
authorization_id text NOT NULL,
client_id uuid NOT NULL,
user_id uuid,
redirect_uri text NOT NULL,
scope text NOT NULL,
state text,
resource text,
code_challenge text,
code_challenge_method auth.code_challenge_method,
response_type auth.oauth_response_type DEFAULT 'code'::auth.oauth_response_type NOT NULL,
status auth.oauth_authorization_status DEFAULT 'pending'::auth.oauth_authorization_status NOT NULL,
authorization_code text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '00:03:00'::interval) NOT NULL,
approved_at timestamp with time zone,
nonce text,
CONSTRAINT oauth_authorizations_authorization_code_length CHECK ((char_length(authorization_code) <= 255)),
CONSTRAINT oauth_authorizations_code_challenge_length CHECK ((char_length(code_challenge) <= 128)),
CONSTRAINT oauth_authorizations_expires_at_future CHECK ((expires_at > created_at)),
CONSTRAINT oauth_authorizations_nonce_length CHECK ((char_length(nonce) <= 255)),
CONSTRAINT oauth_authorizations_redirect_uri_length CHECK ((char_length(redirect_uri) <= 2048)),
CONSTRAINT oauth_authorizations_resource_length CHECK ((char_length(resource) <= 2048)),
CONSTRAINT oauth_authorizations_scope_length CHECK ((char_length(scope) <= 4096)),
CONSTRAINT oauth_authorizations_state_length CHECK ((char_length(state) <= 4096))
);
ALTER TABLE auth.oauth_authorizations OWNER TO supabase_auth_admin;
--
-- Name: oauth_client_states; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.oauth_client_states (
id uuid NOT NULL,
provider_type text NOT NULL,
code_verifier text,
created_at timestamp with time zone NOT NULL
);
ALTER TABLE auth.oauth_client_states OWNER TO supabase_auth_admin;
--
-- Name: TABLE oauth_client_states; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.oauth_client_states IS 'Stores OAuth states for third-party provider authentication flows where Supabase acts as the OAuth client.';
--
-- Name: oauth_clients; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.oauth_clients (
id uuid NOT NULL,
client_secret_hash text,
registration_type auth.oauth_registration_type NOT NULL,
redirect_uris text NOT NULL,
grant_types text NOT NULL,
client_name text,
client_uri text,
logo_uri text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
deleted_at timestamp with time zone,
client_type auth.oauth_client_type DEFAULT 'confidential'::auth.oauth_client_type NOT NULL,
token_endpoint_auth_method text NOT NULL,
CONSTRAINT oauth_clients_client_name_length CHECK ((char_length(client_name) <= 1024)),
CONSTRAINT oauth_clients_client_uri_length CHECK ((char_length(client_uri) <= 2048)),
CONSTRAINT oauth_clients_logo_uri_length CHECK ((char_length(logo_uri) <= 2048)),
CONSTRAINT oauth_clients_token_endpoint_auth_method_check CHECK ((token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text])))
);
ALTER TABLE auth.oauth_clients OWNER TO supabase_auth_admin;
--
-- Name: oauth_consents; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.oauth_consents (
id uuid NOT NULL,
user_id uuid NOT NULL,
client_id uuid NOT NULL,
scopes text NOT NULL,
granted_at timestamp with time zone DEFAULT now() NOT NULL,
revoked_at timestamp with time zone,
CONSTRAINT oauth_consents_revoked_after_granted CHECK (((revoked_at IS NULL) OR (revoked_at >= granted_at))),
CONSTRAINT oauth_consents_scopes_length CHECK ((char_length(scopes) <= 2048)),
CONSTRAINT oauth_consents_scopes_not_empty CHECK ((char_length(TRIM(BOTH FROM scopes)) > 0))
);
ALTER TABLE auth.oauth_consents OWNER TO supabase_auth_admin;
--
-- Name: one_time_tokens; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.one_time_tokens (
id uuid NOT NULL,
user_id uuid NOT NULL,
token_type auth.one_time_token_type NOT NULL,
token_hash text NOT NULL,
relates_to text NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
CONSTRAINT one_time_tokens_token_hash_check CHECK ((char_length(token_hash) > 0))
);
ALTER TABLE auth.one_time_tokens OWNER TO supabase_auth_admin;
--
-- Name: refresh_tokens; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.refresh_tokens (
instance_id uuid,
id bigint NOT NULL,
token character varying(255),
user_id character varying(255),
revoked boolean,
created_at timestamp with time zone,
updated_at timestamp with time zone,
parent character varying(255),
session_id uuid
);
ALTER TABLE auth.refresh_tokens OWNER TO supabase_auth_admin;
--
-- Name: TABLE refresh_tokens; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.refresh_tokens IS 'Auth: Store of tokens used to refresh JWT tokens once they expire.';
--
-- Name: refresh_tokens_id_seq; Type: SEQUENCE; Schema: auth; Owner: supabase_auth_admin
--
CREATE SEQUENCE auth.refresh_tokens_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE auth.refresh_tokens_id_seq OWNER TO supabase_auth_admin;
--
-- Name: refresh_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: auth; Owner: supabase_auth_admin
--
ALTER SEQUENCE auth.refresh_tokens_id_seq OWNED BY auth.refresh_tokens.id;
--
-- Name: saml_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.saml_providers (
id uuid NOT NULL,
sso_provider_id uuid NOT NULL,
entity_id text NOT NULL,
metadata_xml text NOT NULL,
metadata_url text,
attribute_mapping jsonb,
created_at timestamp with time zone,
updated_at timestamp with time zone,
name_id_format text,
CONSTRAINT "entity_id not empty" CHECK ((char_length(entity_id) > 0)),
CONSTRAINT "metadata_url not empty" CHECK (((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))),
CONSTRAINT "metadata_xml not empty" CHECK ((char_length(metadata_xml) > 0))
);
ALTER TABLE auth.saml_providers OWNER TO supabase_auth_admin;
--
-- Name: TABLE saml_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.saml_providers IS 'Auth: Manages SAML Identity Provider connections.';
--
-- Name: saml_relay_states; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.saml_relay_states (
id uuid NOT NULL,
sso_provider_id uuid NOT NULL,
request_id text NOT NULL,
for_email text,
redirect_to text,
created_at timestamp with time zone,
updated_at timestamp with time zone,
flow_state_id uuid,
CONSTRAINT "request_id not empty" CHECK ((char_length(request_id) > 0))
);
ALTER TABLE auth.saml_relay_states OWNER TO supabase_auth_admin;
--
-- Name: TABLE saml_relay_states; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.saml_relay_states IS 'Auth: Contains SAML Relay State information for each Service Provider initiated login.';
--
-- Name: schema_migrations; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.schema_migrations (
version character varying(255) NOT NULL
);
ALTER TABLE auth.schema_migrations OWNER TO supabase_auth_admin;
--
-- Name: TABLE schema_migrations; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.schema_migrations IS 'Auth: Manages updates to the auth system.';
--
-- Name: sessions; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.sessions (
id uuid NOT NULL,
user_id uuid NOT NULL,
created_at timestamp with time zone,
updated_at timestamp with time zone,
factor_id uuid,
aal auth.aal_level,
not_after timestamp with time zone,
refreshed_at timestamp without time zone,
user_agent text,
ip inet,
tag text,
oauth_client_id uuid,
refresh_token_hmac_key text,
refresh_token_counter bigint,
scopes text,
CONSTRAINT sessions_scopes_length CHECK ((char_length(scopes) <= 4096))
);
ALTER TABLE auth.sessions OWNER TO supabase_auth_admin;
--
-- Name: TABLE sessions; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.sessions IS 'Auth: Stores session data associated to a user.';
--
-- Name: COLUMN sessions.not_after; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.sessions.not_after IS 'Auth: Not after is a nullable column that contains a timestamp after which the session should be regarded as expired.';
--
-- Name: COLUMN sessions.refresh_token_hmac_key; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.sessions.refresh_token_hmac_key IS 'Holds a HMAC-SHA256 key used to sign refresh tokens for this session.';
--
-- Name: COLUMN sessions.refresh_token_counter; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.sessions.refresh_token_counter IS 'Holds the ID (counter) of the last issued refresh token.';
--
-- Name: sso_domains; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.sso_domains (
id uuid NOT NULL,
sso_provider_id uuid NOT NULL,
domain text NOT NULL,
created_at timestamp with time zone,
updated_at timestamp with time zone,
CONSTRAINT "domain not empty" CHECK ((char_length(domain) > 0))
);
ALTER TABLE auth.sso_domains OWNER TO supabase_auth_admin;
--
-- Name: TABLE sso_domains; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.sso_domains IS 'Auth: Manages SSO email address domain mapping to an SSO Identity Provider.';
--
-- Name: sso_providers; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.sso_providers (
id uuid NOT NULL,
resource_id text,
created_at timestamp with time zone,
updated_at timestamp with time zone,
disabled boolean,
CONSTRAINT "resource_id not empty" CHECK (((resource_id = NULL::text) OR (char_length(resource_id) > 0)))
);
ALTER TABLE auth.sso_providers OWNER TO supabase_auth_admin;
--
-- Name: TABLE sso_providers; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.sso_providers IS 'Auth: Manages SSO identity provider information; see saml_providers for SAML.';
--
-- Name: COLUMN sso_providers.resource_id; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.sso_providers.resource_id IS 'Auth: Uniquely identifies a SSO provider according to a user-chosen resource ID (case insensitive), useful in infrastructure as code.';
--
-- Name: users; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE auth.users (
instance_id uuid,
id uuid NOT NULL,
aud character varying(255),
role character varying(255),
email character varying(255),
encrypted_password character varying(255),
email_confirmed_at timestamp with time zone,
invited_at timestamp with time zone,
confirmation_token character varying(255),
confirmation_sent_at timestamp with time zone,
recovery_token character varying(255),
recovery_sent_at timestamp with time zone,
email_change_token_new character varying(255),
email_change character varying(255),
email_change_sent_at timestamp with time zone,
last_sign_in_at timestamp with time zone,
raw_app_meta_data jsonb,
raw_user_meta_data jsonb,
is_super_admin boolean,
created_at timestamp with time zone,
updated_at timestamp with time zone,
phone text DEFAULT NULL::character varying,
phone_confirmed_at timestamp with time zone,
phone_change text DEFAULT ''::character varying,
phone_change_token character varying(255) DEFAULT ''::character varying,
phone_change_sent_at timestamp with time zone,
confirmed_at timestamp with time zone GENERATED ALWAYS AS (LEAST(email_confirmed_at, phone_confirmed_at)) STORED,
email_change_token_current character varying(255) DEFAULT ''::character varying,
email_change_confirm_status smallint DEFAULT 0,
banned_until timestamp with time zone,
reauthentication_token character varying(255) DEFAULT ''::character varying,
reauthentication_sent_at timestamp with time zone,
is_sso_user boolean DEFAULT false NOT NULL,
deleted_at timestamp with time zone,
is_anonymous boolean DEFAULT false NOT NULL,
CONSTRAINT users_email_change_confirm_status_check CHECK (((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2)))
);
ALTER TABLE auth.users OWNER TO supabase_auth_admin;
--
-- Name: TABLE users; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON TABLE auth.users IS 'Auth: Stores user login data within a secure schema.';
--
-- Name: COLUMN users.is_sso_user; Type: COMMENT; Schema: auth; Owner: supabase_auth_admin
--
COMMENT ON COLUMN auth.users.is_sso_user IS 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.';
--
-- Name: addon_credits; Type: TABLE; Schema: public; Owner: supabase_admin
--

View File

@@ -0,0 +1,77 @@
-- =============================================================================
-- AgenciaPsi — Tables — Compromissos Determinados
-- =============================================================================
-- determined_commitments, determined_commitment_fields,
-- commitment_services, commitment_time_logs
-- =============================================================================
CREATE TABLE public.determined_commitments (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
created_by uuid,
is_native boolean DEFAULT false NOT NULL,
native_key text,
is_locked boolean DEFAULT false NOT NULL,
active boolean DEFAULT true NOT NULL,
name text NOT NULL,
description text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
bg_color text,
text_color text
);
CREATE TABLE public.determined_commitment_fields (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
commitment_id uuid NOT NULL,
key text NOT NULL,
label text NOT NULL,
field_type public.determined_field_type DEFAULT 'text'::public.determined_field_type NOT NULL,
required boolean DEFAULT false NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
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))
);
ALTER TABLE public.commitment_services OWNER TO supabase_admin;
--
-- Name: commitment_time_logs; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.commitment_time_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
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
);

View File

@@ -0,0 +1,171 @@
-- =============================================================================
-- AgenciaPsi — Tables — Core
-- =============================================================================
-- profiles, tenants, tenant_members, tenant_invites, tenant_features,
-- tenant_feature_exceptions_log, saas_admins, owner_users, user_settings,
-- company_profiles, dev_user_credentials
-- =============================================================================
CREATE TABLE public.profiles (
id uuid NOT NULL,
role text DEFAULT 'tenant_member'::text NOT NULL,
full_name text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
avatar_url text,
phone text,
bio text,
language text DEFAULT 'pt-BR'::text NOT NULL,
timezone text DEFAULT 'America/Sao_Paulo'::text NOT NULL,
notify_system_email boolean DEFAULT true NOT NULL,
notify_reminders boolean DEFAULT true NOT NULL,
notify_news boolean DEFAULT false NOT NULL,
account_type text DEFAULT 'free'::text NOT NULL,
platform_roles text[] DEFAULT '{}'::text[] NOT NULL,
nickname text,
work_description text,
work_description_other text,
site_url text,
social_instagram text,
social_youtube text,
social_facebook text,
social_x text,
social_custom jsonb DEFAULT '[]'::jsonb NOT NULL,
CONSTRAINT profiles_account_type_check CHECK ((account_type = ANY (ARRAY['free'::text, 'patient'::text, 'therapist'::text, 'clinic'::text]))),
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['saas_admin'::text, 'tenant_member'::text, 'portal_user'::text, 'patient'::text])))
);
CREATE TABLE public.tenants (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
kind text DEFAULT 'saas'::text NOT NULL,
CONSTRAINT tenants_kind_check CHECK ((kind = ANY (ARRAY['therapist'::text, 'clinic_coworking'::text, 'clinic_reception'::text, 'clinic_full'::text, 'clinic'::text, 'saas'::text, 'supervisor'::text])))
);
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.tenant_invites (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
email text NOT NULL,
role text NOT NULL,
token uuid DEFAULT gen_random_uuid() NOT NULL,
invited_by uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '7 days'::interval) NOT NULL,
accepted_at timestamp with time zone,
accepted_by uuid,
revoked_at timestamp with time zone,
revoked_by uuid,
CONSTRAINT tenant_invites_role_check CHECK ((role = ANY (ARRAY['therapist'::text, 'secretary'::text])))
);
CREATE TABLE public.tenant_features (
tenant_id uuid NOT NULL,
feature_key text NOT NULL,
enabled boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.tenant_feature_exceptions_log (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
feature_key text NOT NULL,
enabled boolean NOT NULL,
reason text,
created_by uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.saas_admins (
user_id uuid NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.owner_users (
owner_id uuid NOT NULL,
user_id uuid NOT NULL,
role text DEFAULT 'admin'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.user_settings (
user_id uuid NOT NULL,
theme_mode text DEFAULT 'dark'::text NOT NULL,
preset text DEFAULT 'Aura'::text NOT NULL,
primary_color text DEFAULT 'noir'::text NOT NULL,
surface_color text DEFAULT 'slate'::text NOT NULL,
menu_mode text DEFAULT 'static'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
layout_variant text DEFAULT 'classic'::text NOT NULL,
CONSTRAINT user_settings_layout_variant_check CHECK ((layout_variant = ANY (ARRAY['classic'::text, 'rail'::text]))),
CONSTRAINT user_settings_menu_mode_check CHECK ((menu_mode = ANY (ARRAY['static'::text, 'overlay'::text]))),
CONSTRAINT user_settings_theme_mode_check CHECK ((theme_mode = ANY (ARRAY['light'::text, 'dark'::text])))
);
CREATE TABLE public.company_profiles (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
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
);

View File

@@ -0,0 +1,199 @@
-- =============================================================================
-- AgenciaPsi — Tables — Financeiro
-- =============================================================================
-- financial_records, financial_categories, financial_exceptions,
-- payment_settings, professional_pricing, therapist_payouts,
-- therapist_payout_records, services, insurance_plans, insurance_plan_services
-- =============================================================================
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.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
);
ALTER TABLE public.financial_categories OWNER TO supabase_admin;
--
-- Name: financial_exceptions; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.financial_exceptions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid,
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.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.therapist_payout_records (
payout_id uuid NOT NULL,
financial_record_id uuid NOT NULL
);
CREATE TABLE public.services (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
name text NOT NULL,
description text,
price numeric(10,2) NOT NULL,
duration_min integer,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.insurance_plan_services (
id uuid DEFAULT gen_random_uuid() NOT NULL,
insurance_plan_id uuid NOT NULL,
name text NOT NULL,
value numeric(10,2) NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.insurance_plan_services OWNER TO supabase_admin;
--
-- Name: insurance_plans; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.insurance_plans (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
name text NOT NULL,
notes text,
default_value numeric(10,2),
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);

View File

@@ -0,0 +1,500 @@
-- =============================================================================
-- AgenciaPsi — Tables — Infraestrutura (realtime, storage, supabase_functions)
-- =============================================================================
CREATE TABLE _realtime.extensions (
id uuid NOT NULL,
type text,
settings jsonb,
tenant_external_id text,
inserted_at timestamp(0) without time zone NOT NULL,
updated_at timestamp(0) without time zone NOT NULL
);
ALTER TABLE _realtime.extensions OWNER TO supabase_admin;
--
-- Name: schema_migrations; Type: TABLE; Schema: _realtime; Owner: supabase_admin
--
CREATE TABLE _realtime.schema_migrations (
version bigint NOT NULL,
inserted_at timestamp(0) without time zone
);
ALTER TABLE _realtime.schema_migrations OWNER TO supabase_admin;
--
-- Name: tenants; Type: TABLE; Schema: _realtime; Owner: supabase_admin
--
CREATE TABLE _realtime.tenants (
id uuid NOT NULL,
name text,
external_id text,
jwt_secret text,
max_concurrent_users integer DEFAULT 200 NOT NULL,
inserted_at timestamp(0) without time zone NOT NULL,
updated_at timestamp(0) without time zone NOT NULL,
max_events_per_second integer DEFAULT 100 NOT NULL,
postgres_cdc_default text DEFAULT 'postgres_cdc_rls'::text,
max_bytes_per_second integer DEFAULT 100000 NOT NULL,
max_channels_per_client integer DEFAULT 100 NOT NULL,
max_joins_per_second integer DEFAULT 500 NOT NULL,
suspend boolean DEFAULT false,
jwt_jwks jsonb,
notify_private_alpha boolean DEFAULT false,
private_only boolean DEFAULT false NOT NULL,
migrations_ran integer DEFAULT 0,
broadcast_adapter character varying(255) DEFAULT 'gen_rpc'::character varying,
max_presence_events_per_second integer DEFAULT 1000,
max_payload_size_in_kb integer DEFAULT 3000,
CONSTRAINT jwt_secret_or_jwt_jwks_required CHECK (((jwt_secret IS NOT NULL) OR (jwt_jwks IS NOT NULL)))
);
ALTER TABLE _realtime.tenants OWNER TO supabase_admin;
--
-- Name: audit_log_entries; Type: TABLE; Schema: auth; Owner: supabase_auth_admin
--
CREATE TABLE realtime.messages (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
)
PARTITION BY RANGE (inserted_at);
ALTER TABLE realtime.messages OWNER TO supabase_realtime_admin;
--
-- Name: messages_2026_03_20; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_20 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_20 OWNER TO supabase_admin;
--
-- Name: messages_2026_03_21; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_21 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_21 OWNER TO supabase_admin;
--
-- Name: messages_2026_03_22; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_22 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_22 OWNER TO supabase_admin;
--
-- Name: messages_2026_03_23; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_23 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_23 OWNER TO supabase_admin;
--
-- Name: messages_2026_03_24; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_24 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_24 OWNER TO supabase_admin;
--
-- Name: messages_2026_03_25; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_25 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_25 OWNER TO supabase_admin;
--
-- Name: messages_2026_03_26; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.messages_2026_03_26 (
topic text NOT NULL,
extension text NOT NULL,
payload jsonb,
event text,
private boolean DEFAULT false,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
inserted_at timestamp without time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL
);
ALTER TABLE realtime.messages_2026_03_26 OWNER TO supabase_admin;
--
-- Name: schema_migrations; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.schema_migrations (
version bigint NOT NULL,
inserted_at timestamp(0) without time zone
);
ALTER TABLE realtime.schema_migrations OWNER TO supabase_admin;
--
-- Name: subscription; Type: TABLE; Schema: realtime; Owner: supabase_admin
--
CREATE TABLE realtime.subscription (
id bigint NOT NULL,
subscription_id uuid NOT NULL,
entity regclass NOT NULL,
filters realtime.user_defined_filter[] DEFAULT '{}'::realtime.user_defined_filter[] NOT NULL,
claims jsonb NOT NULL,
claims_role regrole GENERATED ALWAYS AS (realtime.to_regrole((claims ->> 'role'::text))) STORED NOT NULL,
created_at timestamp without time zone DEFAULT timezone('utc'::text, now()) NOT NULL
);
ALTER TABLE realtime.subscription OWNER TO supabase_admin;
--
-- Name: subscription_id_seq; Type: SEQUENCE; Schema: realtime; Owner: supabase_admin
--
ALTER TABLE realtime.subscription ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME realtime.subscription_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
--
-- Name: buckets; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.buckets (
id text NOT NULL,
name text NOT NULL,
owner uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
public boolean DEFAULT false,
avif_autodetection boolean DEFAULT false,
file_size_limit bigint,
allowed_mime_types text[],
owner_id text,
type storage.buckettype DEFAULT 'STANDARD'::storage.buckettype NOT NULL
);
ALTER TABLE storage.buckets OWNER TO supabase_storage_admin;
--
-- Name: COLUMN buckets.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin
--
COMMENT ON COLUMN storage.buckets.owner IS 'Field is deprecated, use owner_id instead';
--
-- Name: buckets_analytics; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.buckets_analytics (
name text NOT NULL,
type storage.buckettype DEFAULT 'ANALYTICS'::storage.buckettype NOT NULL,
format text DEFAULT 'ICEBERG'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
id uuid DEFAULT gen_random_uuid() NOT NULL,
deleted_at timestamp with time zone
);
ALTER TABLE storage.buckets_analytics OWNER TO supabase_storage_admin;
--
-- Name: buckets_vectors; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.buckets_vectors (
id text NOT NULL,
type storage.buckettype DEFAULT 'VECTOR'::storage.buckettype NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE storage.buckets_vectors OWNER TO supabase_storage_admin;
--
-- Name: iceberg_namespaces; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.iceberg_namespaces (
id uuid DEFAULT gen_random_uuid() NOT NULL,
bucket_name text NOT NULL,
name text NOT NULL COLLATE pg_catalog."C",
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
catalog_id uuid NOT NULL
);
ALTER TABLE storage.iceberg_namespaces OWNER TO supabase_storage_admin;
--
-- Name: iceberg_tables; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.iceberg_tables (
id uuid DEFAULT gen_random_uuid() NOT NULL,
namespace_id uuid NOT NULL,
bucket_name text NOT NULL,
name text NOT NULL COLLATE pg_catalog."C",
location text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
remote_table_id text,
shard_key text,
shard_id text,
catalog_id uuid NOT NULL
);
ALTER TABLE storage.iceberg_tables OWNER TO supabase_storage_admin;
--
-- Name: migrations; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.migrations (
id integer NOT NULL,
name character varying(100) NOT NULL,
hash character varying(40) NOT NULL,
executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE storage.migrations OWNER TO supabase_storage_admin;
--
-- Name: objects; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.objects (
id uuid DEFAULT gen_random_uuid() NOT NULL,
bucket_id text,
name text,
owner uuid,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
last_accessed_at timestamp with time zone DEFAULT now(),
metadata jsonb,
path_tokens text[] GENERATED ALWAYS AS (string_to_array(name, '/'::text)) STORED,
version text,
owner_id text,
user_metadata jsonb
);
ALTER TABLE storage.objects OWNER TO supabase_storage_admin;
--
-- Name: COLUMN objects.owner; Type: COMMENT; Schema: storage; Owner: supabase_storage_admin
--
COMMENT ON COLUMN storage.objects.owner IS 'Field is deprecated, use owner_id instead';
--
-- Name: s3_multipart_uploads; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.s3_multipart_uploads (
id text NOT NULL,
in_progress_size bigint DEFAULT 0 NOT NULL,
upload_signature text NOT NULL,
bucket_id text NOT NULL,
key text NOT NULL COLLATE pg_catalog."C",
version text NOT NULL,
owner_id text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
user_metadata jsonb
);
ALTER TABLE storage.s3_multipart_uploads OWNER TO supabase_storage_admin;
--
-- Name: s3_multipart_uploads_parts; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.s3_multipart_uploads_parts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
upload_id text NOT NULL,
size bigint DEFAULT 0 NOT NULL,
part_number integer NOT NULL,
bucket_id text NOT NULL,
key text NOT NULL COLLATE pg_catalog."C",
etag text NOT NULL,
owner_id text,
version text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE storage.s3_multipart_uploads_parts OWNER TO supabase_storage_admin;
--
-- Name: vector_indexes; Type: TABLE; Schema: storage; Owner: supabase_storage_admin
--
CREATE TABLE storage.vector_indexes (
id text DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL COLLATE pg_catalog."C",
bucket_id text NOT NULL,
data_type text NOT NULL,
dimension integer NOT NULL,
distance_metric text NOT NULL,
metadata_configuration jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE supabase_functions.hooks (
id bigint NOT NULL,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
request_id bigint
);
ALTER TABLE supabase_functions.hooks OWNER TO supabase_functions_admin;
--
-- Name: TABLE hooks; Type: COMMENT; Schema: supabase_functions; Owner: supabase_functions_admin
--
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
--
-- Name: hooks_id_seq; Type: SEQUENCE; Schema: supabase_functions; Owner: supabase_functions_admin
--
CREATE SEQUENCE supabase_functions.hooks_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE supabase_functions.hooks_id_seq OWNER TO supabase_functions_admin;
--
-- Name: hooks_id_seq; Type: SEQUENCE OWNED BY; Schema: supabase_functions; Owner: supabase_functions_admin
--
ALTER SEQUENCE supabase_functions.hooks_id_seq OWNED BY supabase_functions.hooks.id;
--
-- Name: migrations; Type: TABLE; Schema: supabase_functions; Owner: supabase_functions_admin
--
CREATE TABLE supabase_functions.migrations (
version text NOT NULL,
inserted_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE supabase_functions.migrations OWNER TO supabase_functions_admin;
--
-- Name: messages_2026_03_20; Type: TABLE ATTACH; Schema: realtime; Owner: supabase_admin
--

View File

@@ -0,0 +1,267 @@
-- =============================================================================
-- AgenciaPsi — Tables — Notificações + Email Templates
-- =============================================================================
-- notification_channels, notification_logs, notification_preferences,
-- notification_queue, notification_schedules, notification_templates,
-- notifications, email_templates_global, email_templates_tenant,
-- email_layout_config
-- =============================================================================
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,
CONSTRAINT notification_channels_channel_check CHECK ((channel = ANY (ARRAY['whatsapp'::text, 'email'::text, 'sms'::text]))),
CONSTRAINT notification_channels_connection_status_check CHECK ((connection_status = ANY (ARRAY['connected'::text, 'disconnected'::text, 'connecting'::text, 'qr_pending'::text, 'error'::text]))),
CONSTRAINT notification_channels_provider_check CHECK ((provider = ANY (ARRAY['evolution_api'::text, 'meta_official'::text, 'twilio'::text, 'zenvia'::text, 'sendgrid'::text, 'resend'::text, 'smtp'::text, 'zapi'::text])))
);
ALTER TABLE public.notification_channels OWNER TO supabase_admin;
--
-- Name: notification_logs; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
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])))
);
ALTER TABLE public.notification_logs OWNER TO supabase_admin;
--
-- Name: notification_preferences; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notification_preferences (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
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
);
ALTER TABLE public.notification_preferences OWNER TO supabase_admin;
--
-- Name: notification_queue; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notification_queue (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
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])))
);
ALTER TABLE public.notification_queue OWNER TO supabase_admin;
--
-- Name: notification_schedules; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notification_schedules (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
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])))
);
ALTER TABLE public.notification_schedules OWNER TO supabase_admin;
--
-- Name: notification_templates; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notification_templates (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
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])))
);
ALTER TABLE public.notification_templates OWNER TO supabase_admin;
--
-- Name: notifications; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notifications (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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.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
);
ALTER TABLE public.email_layout_config OWNER TO supabase_admin;
--
-- Name: email_templates_global; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.email_templates_global (
id uuid DEFAULT gen_random_uuid() NOT NULL,
key text NOT NULL,
domain text NOT NULL,
channel text DEFAULT 'email'::text NOT NULL,
subject text NOT NULL,
body_html text NOT NULL,
body_text text,
version integer DEFAULT 1 NOT NULL,
is_active boolean DEFAULT true NOT NULL,
variables jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE public.email_templates_global OWNER TO supabase_admin;
--
-- Name: email_templates_tenant; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.email_templates_tenant (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
owner_id uuid,
template_key text NOT NULL,
subject text,
body_html text,
body_text text,
enabled boolean DEFAULT true NOT NULL,
synced_version integer,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,185 @@
-- =============================================================================
-- AgenciaPsi — Tables — Pacientes
-- =============================================================================
-- patients, patient_groups, patient_group_patient, patient_tags,
-- patient_patient_tag, patient_intake_requests, patient_invites,
-- patient_discounts
-- =============================================================================
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,
CONSTRAINT cpf_responsavel_format_check CHECK (((cpf_responsavel IS NULL) OR (cpf_responsavel ~ '^\d{11}$'::text))),
CONSTRAINT patients_cpf_format_check CHECK (((cpf IS NULL) OR (cpf ~ '^\d{11}$'::text))),
CONSTRAINT patients_patient_scope_check CHECK ((patient_scope = ANY (ARRAY['clinic'::text, 'therapist'::text]))),
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text]))),
CONSTRAINT patients_therapist_scope_consistency CHECK ((((patient_scope = 'clinic'::text) AND (therapist_member_id IS NULL)) OR ((patient_scope = 'therapist'::text) AND (therapist_member_id IS NOT NULL))))
);
CREATE TABLE public.patient_groups (
id uuid DEFAULT gen_random_uuid() NOT NULL,
nome text NOT NULL,
descricao text,
cor text,
is_active boolean DEFAULT true NOT NULL,
is_system boolean DEFAULT false NOT NULL,
owner_id uuid DEFAULT auth.uid() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
therapist_id uuid,
tenant_id uuid NOT NULL
);
CREATE TABLE public.patient_group_patient (
patient_group_id uuid NOT NULL,
patient_id uuid NOT NULL,
created_at timestamp with time zone DEFAULT now(),
tenant_id uuid NOT NULL
);
CREATE TABLE public.patient_tags (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
nome text NOT NULL,
cor text,
is_padrao boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone,
tenant_id uuid NOT NULL
);
CREATE TABLE public.patient_patient_tag (
owner_id uuid NOT NULL,
patient_id uuid NOT NULL,
tag_id uuid NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
tenant_id uuid NOT NULL
);
CREATE TABLE public.patient_intake_requests (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
token text NOT NULL,
consent boolean DEFAULT false NOT NULL,
status text DEFAULT 'new'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
converted_patient_id uuid,
rejected_reason text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
cpf text,
rg text,
cep text,
nome_completo text,
email_principal text,
telefone text,
pais text,
cidade text,
estado text,
endereco text,
numero text,
bairro text,
complemento text,
data_nascimento date,
naturalidade text,
genero text,
estado_civil text,
onde_nos_conheceu text,
encaminhado_por text,
observacoes text,
notas_internas text,
email_alternativo text,
telefone_alternativo text,
profissao text,
escolaridade text,
nacionalidade text,
avatar_url text,
tenant_id uuid,
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'converted'::text, 'rejected'::text])))
);
CREATE TABLE public.patient_invites (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
token text NOT NULL,
active boolean DEFAULT true NOT NULL,
expires_at timestamp with time zone,
max_uses integer,
uses integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
tenant_id uuid
);
CREATE TABLE public.patient_discounts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
patient_id uuid NOT NULL,
discount_pct numeric(5,2) DEFAULT 0,
discount_flat numeric(10,2) DEFAULT 0,
reason text,
active boolean DEFAULT true NOT NULL,
active_from timestamp with time zone DEFAULT now(),
active_to timestamp with time zone,
created_at timestamp with time zone DEFAULT now()
);

View File

@@ -0,0 +1,371 @@
-- =============================================================================
-- AgenciaPsi — Tables — Plans, Billing, Subscriptions
-- =============================================================================
-- plans, plan_prices, plan_features, plan_public, plan_public_bullets,
-- features, entitlements_invalidation, subscriptions, subscription_events,
-- subscription_intents_personal, subscription_intents_tenant,
-- subscription_intents_legacy, billing_contracts,
-- addon_credits, addon_products, addon_transactions,
-- modules, module_features, tenant_modules
-- =============================================================================
CREATE TABLE public.plans (
id uuid DEFAULT gen_random_uuid() NOT NULL,
key text NOT NULL,
name text NOT NULL,
description text,
is_active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
price_cents integer DEFAULT 0 NOT NULL,
currency text DEFAULT 'BRL'::text NOT NULL,
billing_interval text DEFAULT 'month'::text NOT NULL,
target text,
max_supervisees integer,
CONSTRAINT plans_target_check CHECK ((target = ANY (ARRAY['patient'::text, 'therapist'::text, 'clinic'::text, 'supervisor'::text])))
);
CREATE TABLE public.plan_prices (
id uuid DEFAULT gen_random_uuid() NOT NULL,
plan_id uuid NOT NULL,
currency text DEFAULT 'BRL'::text NOT NULL,
"interval" text NOT NULL,
amount_cents integer NOT NULL,
is_active boolean DEFAULT true NOT NULL,
active_from timestamp with time zone DEFAULT now() NOT NULL,
active_to timestamp with time zone,
source text DEFAULT 'manual'::text NOT NULL,
provider text,
provider_price_id text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT plan_prices_amount_cents_check CHECK ((amount_cents >= 0)),
CONSTRAINT plan_prices_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text])))
);
CREATE TABLE public.plan_features (
plan_id uuid NOT NULL,
feature_id uuid NOT NULL,
enabled boolean DEFAULT true NOT NULL,
limits jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.plan_public (
plan_id uuid NOT NULL,
public_name text DEFAULT ''::text NOT NULL,
public_description text DEFAULT ''::text NOT NULL,
badge text,
is_featured boolean DEFAULT false NOT NULL,
is_visible boolean DEFAULT true NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.plan_public_bullets (
id uuid DEFAULT gen_random_uuid() NOT NULL,
plan_id uuid NOT NULL,
text text NOT NULL,
sort_order integer DEFAULT 0 NOT NULL,
highlight boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.features (
id uuid DEFAULT gen_random_uuid() NOT NULL,
key text NOT NULL,
description text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
descricao text DEFAULT ''::text NOT NULL,
name text DEFAULT ''::text NOT NULL
);
ALTER TABLE public.features OWNER TO supabase_admin;
--
-- Name: COLUMN features.descricao; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON COLUMN public.features.descricao IS 'Descrição humana da feature (exibição no admin e documentação).';
--
-- Name: feriados; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.entitlements_invalidation (
owner_id uuid NOT NULL,
changed_at timestamp with time zone DEFAULT now() NOT NULL
);
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.subscription_events (
id uuid DEFAULT gen_random_uuid() NOT NULL,
subscription_id uuid NOT NULL,
owner_id uuid NOT NULL,
event_type text NOT NULL,
old_plan_id uuid,
new_plan_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid,
source text DEFAULT 'admin_ui'::text,
reason text,
metadata jsonb,
owner_type text NOT NULL,
owner_ref uuid NOT NULL,
CONSTRAINT subscription_events_owner_ref_consistency_chk CHECK ((owner_id = owner_ref)),
CONSTRAINT subscription_events_owner_type_chk CHECK (((owner_type IS NULL) OR (owner_type = ANY (ARRAY['clinic'::text, 'therapist'::text]))))
);
CREATE TABLE public.subscription_intents_personal (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL,
created_by_user_id uuid,
email text NOT NULL,
plan_id uuid NOT NULL,
plan_key text,
"interval" text,
amount_cents integer,
currency text,
status text DEFAULT 'new'::text NOT NULL,
source text DEFAULT 'manual'::text NOT NULL,
notes text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
paid_at timestamp with time zone,
subscription_id uuid,
CONSTRAINT sint_personal_interval_check CHECK ((("interval" IS NULL) OR ("interval" = ANY (ARRAY['month'::text, 'year'::text])))),
CONSTRAINT sint_personal_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
);
CREATE TABLE public.subscription_intents_tenant (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid NOT NULL,
created_by_user_id uuid,
email text NOT NULL,
plan_id uuid NOT NULL,
plan_key text,
"interval" text,
amount_cents integer,
currency text,
status text DEFAULT 'new'::text NOT NULL,
source text DEFAULT 'manual'::text NOT NULL,
notes text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
paid_at timestamp with time zone,
tenant_id uuid NOT NULL,
subscription_id uuid,
CONSTRAINT sint_tenant_interval_check CHECK ((("interval" IS NULL) OR ("interval" = ANY (ARRAY['month'::text, 'year'::text])))),
CONSTRAINT sint_tenant_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
);
CREATE TABLE public.subscription_intents_legacy (
id uuid DEFAULT gen_random_uuid() NOT NULL,
user_id uuid,
email text,
plan_key text NOT NULL,
"interval" text NOT NULL,
amount_cents integer NOT NULL,
currency text DEFAULT 'BRL'::text NOT NULL,
status text DEFAULT 'new'::text NOT NULL,
source text DEFAULT 'landing'::text NOT NULL,
notes text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
paid_at timestamp with time zone,
tenant_id uuid NOT NULL,
created_by_user_id uuid,
CONSTRAINT subscription_intents_interval_check CHECK (("interval" = ANY (ARRAY['month'::text, 'year'::text]))),
CONSTRAINT subscription_intents_status_check CHECK ((status = ANY (ARRAY['new'::text, 'waiting_payment'::text, 'paid'::text, 'canceled'::text])))
);
CREATE TABLE public.billing_contracts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
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.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
);
ALTER TABLE public.addon_transactions OWNER TO supabase_admin;
--
-- Name: TABLE addon_transactions; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON TABLE public.addon_transactions IS 'Histórico de todas as transações de créditos: compras, consumo, ajustes, reembolsos.';
--
-- Name: agenda_bloqueios; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.modules (
id uuid DEFAULT gen_random_uuid() NOT NULL,
key text NOT NULL,
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.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.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
);
ALTER TABLE public.tenant_modules OWNER TO supabase_admin;

View File

@@ -0,0 +1,204 @@
-- =============================================================================
-- AgenciaPsi — Tables — SaaS Admin, FAQ, Docs, UI
-- =============================================================================
-- saas_docs, saas_doc_votos, saas_faq, saas_faq_itens,
-- feriados, global_notices, login_carousel_slides, notice_dismissals,
-- support_sessions
-- =============================================================================
CREATE TABLE public.saas_doc_votos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
doc_id uuid NOT NULL,
user_id uuid NOT NULL,
util boolean NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE public.saas_doc_votos OWNER TO supabase_admin;
--
-- Name: saas_docs; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.saas_docs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
titulo text NOT NULL,
conteudo text DEFAULT ''::text NOT NULL,
medias jsonb DEFAULT '[]'::jsonb NOT NULL,
tipo_acesso text DEFAULT 'usuario'::text NOT NULL,
pagina_path text NOT NULL,
docs_relacionados uuid[] DEFAULT '{}'::uuid[] NOT NULL,
ativo boolean DEFAULT true NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
categoria text,
exibir_no_faq boolean DEFAULT false NOT NULL,
votos_util integer DEFAULT 0 NOT NULL,
votos_nao_util integer DEFAULT 0 NOT NULL,
CONSTRAINT saas_docs_tipo_acesso_check CHECK ((tipo_acesso = ANY (ARRAY['admin'::text, 'usuario'::text])))
);
ALTER TABLE public.saas_docs OWNER TO supabase_admin;
--
-- Name: COLUMN saas_docs.categoria; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON COLUMN public.saas_docs.categoria IS 'Agrupa docs no portal FAQ (ex: Conta, Agenda, Pagamentos)';
--
-- Name: COLUMN saas_docs.exibir_no_faq; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON COLUMN public.saas_docs.exibir_no_faq IS 'Se true, a doc e seus itens FAQ aparecem no portal de FAQ';
--
-- Name: saas_faq; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.saas_faq (
id uuid DEFAULT gen_random_uuid() NOT NULL,
pergunta text NOT NULL,
categoria text,
publico boolean DEFAULT false NOT NULL,
votos integer DEFAULT 0 NOT NULL,
titulo text,
conteudo text,
tipo_acesso text DEFAULT 'usuario'::text NOT NULL,
pagina_path text NOT NULL,
pagina_label text,
medias jsonb DEFAULT '[]'::jsonb NOT NULL,
faqs_relacionados uuid[] DEFAULT '{}'::uuid[] NOT NULL,
ativo boolean DEFAULT true NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE public.saas_faq OWNER TO supabase_admin;
--
-- Name: saas_faq_itens; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.saas_faq_itens (
id uuid DEFAULT gen_random_uuid() NOT NULL,
doc_id uuid NOT NULL,
pergunta text NOT NULL,
resposta text,
ordem integer DEFAULT 0 NOT NULL,
ativo boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE public.saas_faq_itens OWNER TO supabase_admin;
--
-- Name: TABLE saas_faq_itens; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON TABLE public.saas_faq_itens IS 'Pares pergunta/resposta vinculados a um documento de ajuda';
--
-- Name: services; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.feriados (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
owner_id uuid,
tipo text DEFAULT 'municipal'::text NOT NULL,
nome text NOT NULL,
data date NOT NULL,
cidade text,
estado text,
observacao text,
bloqueia_sessoes boolean DEFAULT false NOT NULL,
criado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT feriados_tipo_check CHECK ((tipo = ANY (ARRAY['municipal'::text, 'personalizado'::text])))
);
CREATE TABLE public.global_notices (
id uuid DEFAULT gen_random_uuid() NOT NULL,
title text,
message text DEFAULT ''::text NOT NULL,
variant text DEFAULT 'info'::text NOT NULL,
roles text[] DEFAULT '{}'::text[] NOT NULL,
contexts text[] DEFAULT '{}'::text[] NOT NULL,
starts_at timestamp with time zone,
ends_at timestamp with time zone,
is_active boolean DEFAULT true NOT NULL,
priority integer DEFAULT 0 NOT NULL,
dismissible boolean DEFAULT true NOT NULL,
persist_dismiss boolean DEFAULT true NOT NULL,
dismiss_scope text DEFAULT 'device'::text NOT NULL,
show_once boolean DEFAULT false NOT NULL,
max_views integer,
cooldown_minutes integer,
version integer DEFAULT 1 NOT NULL,
action_type text DEFAULT 'none'::text NOT NULL,
action_label text,
action_url text,
action_route text,
views_count integer DEFAULT 0 NOT NULL,
clicks_count integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid,
content_align text DEFAULT 'left'::text NOT NULL,
link_target text DEFAULT '_blank'::text NOT NULL,
CONSTRAINT global_notices_action_type_check CHECK ((action_type = ANY (ARRAY['none'::text, 'internal'::text, 'external'::text]))),
CONSTRAINT global_notices_content_align_check CHECK ((content_align = ANY (ARRAY['left'::text, 'center'::text, 'right'::text, 'justify'::text]))),
CONSTRAINT global_notices_dismiss_scope_check CHECK ((dismiss_scope = ANY (ARRAY['session'::text, 'device'::text, 'user'::text]))),
CONSTRAINT global_notices_link_target_check CHECK ((link_target = ANY (ARRAY['_blank'::text, '_self'::text, '_parent'::text, '_top'::text]))),
CONSTRAINT global_notices_variant_check CHECK ((variant = ANY (ARRAY['info'::text, 'success'::text, 'warning'::text, 'error'::text])))
);
CREATE TABLE public.login_carousel_slides (
id uuid DEFAULT gen_random_uuid() NOT NULL,
title text NOT NULL,
body text NOT NULL,
icon text DEFAULT 'pi-star'::text NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
ativo boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
CREATE TABLE public.notice_dismissals (
id uuid DEFAULT gen_random_uuid() NOT NULL,
notice_id uuid NOT NULL,
user_id uuid NOT NULL,
version integer DEFAULT 1 NOT NULL,
dismissed_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.support_sessions (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
admin_id uuid NOT NULL,
token text DEFAULT encode(extensions.gen_random_bytes(32), 'hex'::text) NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '01:00:00'::interval) NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,596 @@
-- =============================================================================
-- AgenciaPsi — Views
-- =============================================================================
-- current_tenant_id, owner_feature_entitlements, subscription_intents,
-- v_auth_users_public, v_cashflow_projection, v_commitment_totals,
-- v_patient_groups_with_counts, v_plan_active_prices, v_public_pricing,
-- v_subscription_feature_mismatch, v_subscription_health, v_subscription_health_v2,
-- v_tag_patient_counts, v_tenant_active_subscription, v_tenant_entitlements,
-- v_tenant_entitlements_full, v_tenant_entitlements_json,
-- v_tenant_feature_exceptions, v_tenant_feature_mismatch,
-- v_tenant_members_with_profiles, v_tenant_people, v_tenant_staff,
-- v_user_active_subscription, v_user_entitlements
-- =============================================================================
CREATE VIEW public.current_tenant_id AS
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
ALTER VIEW public.current_tenant_id OWNER TO supabase_admin;
--
-- Name: determined_commitment_fields; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.owner_feature_entitlements AS
WITH base AS (
SELECT s.user_id AS owner_id,
f.key AS feature_key,
pf.limits,
'plan'::text AS source
FROM ((public.subscriptions s
JOIN public.plan_features pf ON (((pf.plan_id = s.plan_id) AND (pf.enabled = true))))
JOIN public.features f ON ((f.id = pf.feature_id)))
WHERE ((s.status = 'active'::text) AND (s.user_id IS NOT NULL))
UNION ALL
SELECT tm.owner_id,
f.key AS feature_key,
mf.limits,
'module'::text AS source
FROM (((public.tenant_modules tm
JOIN public.modules m ON (((m.id = tm.module_id) AND (m.is_active = true))))
JOIN public.module_features mf ON (((mf.module_id = m.id) AND (mf.enabled = true))))
JOIN public.features f ON ((f.id = mf.feature_id)))
WHERE ((tm.status = 'active'::text) AND (tm.owner_id IS NOT NULL))
)
SELECT owner_id,
feature_key,
array_agg(DISTINCT source) AS sources,
jsonb_agg(limits) FILTER (WHERE (limits IS NOT NULL)) AS limits_list
FROM base
GROUP BY owner_id, feature_key;
ALTER VIEW public.owner_feature_entitlements OWNER TO supabase_admin;
--
-- Name: owner_users; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.subscription_intents AS
SELECT t.id,
t.user_id,
t.created_by_user_id,
t.email,
t.plan_id,
t.plan_key,
t."interval",
t.amount_cents,
t.currency,
t.status,
t.source,
t.notes,
t.created_at,
t.paid_at,
t.tenant_id,
t.subscription_id,
'clinic'::text AS plan_target
FROM public.subscription_intents_tenant t
UNION ALL
SELECT p.id,
p.user_id,
p.created_by_user_id,
p.email,
p.plan_id,
p.plan_key,
p."interval",
p.amount_cents,
p.currency,
p.status,
p.source,
p.notes,
p.created_at,
p.paid_at,
NULL::uuid AS tenant_id,
p.subscription_id,
'therapist'::text AS plan_target
FROM public.subscription_intents_personal p;
ALTER VIEW public.subscription_intents OWNER TO supabase_admin;
--
-- Name: subscription_intents_legacy; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_auth_users_public AS
SELECT id AS user_id,
email,
created_at,
last_sign_in_at
FROM auth.users u;
ALTER VIEW public.v_auth_users_public OWNER TO supabase_admin;
--
-- Name: v_cashflow_projection; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_cashflow_projection WITH (security_invoker='on') AS
SELECT gs.mes,
to_char(gs.mes, 'YYYY-MM'::text) AS mes_label,
COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'receita'::public.financial_record_type) AND (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text])))), (0)::numeric) AS receitas_projetadas,
COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'despesa'::public.financial_record_type) AND (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text])))), (0)::numeric) AS despesas_projetadas,
COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'receita'::public.financial_record_type) AND (fr.status = 'pending'::text))), (0)::numeric) AS receitas_pendentes,
COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'receita'::public.financial_record_type) AND (fr.status = 'overdue'::text))), (0)::numeric) AS receitas_vencidas,
COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'despesa'::public.financial_record_type) AND (fr.status = 'pending'::text))), (0)::numeric) AS despesas_pendentes,
COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'despesa'::public.financial_record_type) AND (fr.status = 'overdue'::text))), (0)::numeric) AS despesas_vencidas,
(COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'receita'::public.financial_record_type) AND (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text])))), (0)::numeric) - COALESCE(sum(fr.final_amount) FILTER (WHERE ((fr.type = 'despesa'::public.financial_record_type) AND (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text])))), (0)::numeric)) AS saldo_projetado,
count(fr.id) FILTER (WHERE (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text]))) AS count_registros
FROM (generate_series(((date_trunc('month'::text, (CURRENT_DATE)::timestamp with time zone))::date)::timestamp with time zone, (((date_trunc('month'::text, (CURRENT_DATE)::timestamp with time zone) + '5 mons'::interval))::date)::timestamp with time zone, '1 mon'::interval) gs(mes)
LEFT JOIN public.financial_records fr ON (((fr.deleted_at IS NULL) AND (fr.status = ANY (ARRAY['pending'::text, 'overdue'::text])) AND ((date_trunc('month'::text, (fr.due_date)::timestamp with time zone))::date = gs.mes))))
GROUP BY gs.mes
ORDER BY gs.mes;
ALTER VIEW public.v_cashflow_projection OWNER TO supabase_admin;
--
-- Name: VIEW v_cashflow_projection; Type: COMMENT; Schema: public; Owner: supabase_admin
--
COMMENT ON VIEW public.v_cashflow_projection IS 'Fluxo de caixa projetado: próximos 6 meses com totais de pending+overdue por due_date. Usa security_invoker=on — filtra automaticamente pelo auth.uid() via RLS de financial_records.';
--
-- Name: v_commitment_totals; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_commitment_totals AS
SELECT c.tenant_id,
c.id AS commitment_id,
(COALESCE(sum(l.minutes), (0)::bigint))::integer AS total_minutes
FROM (public.determined_commitments c
LEFT JOIN public.commitment_time_logs l ON ((l.commitment_id = c.id)))
GROUP BY c.tenant_id, c.id;
ALTER VIEW public.v_commitment_totals OWNER TO supabase_admin;
--
-- Name: v_patient_groups_with_counts; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_patient_groups_with_counts AS
SELECT pg.id,
pg.nome,
pg.cor,
pg.owner_id,
pg.is_system,
pg.is_active,
pg.created_at,
pg.updated_at,
(COALESCE(count(pgp.patient_id), (0)::bigint))::integer AS patients_count
FROM (public.patient_groups pg
LEFT JOIN public.patient_group_patient pgp ON ((pgp.patient_group_id = pg.id)))
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at;
ALTER VIEW public.v_patient_groups_with_counts OWNER TO supabase_admin;
--
-- Name: v_plan_active_prices; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_plan_active_prices AS
SELECT plan_id,
max(
CASE
WHEN (("interval" = 'month'::text) AND is_active) THEN amount_cents
ELSE NULL::integer
END) AS monthly_cents,
max(
CASE
WHEN (("interval" = 'year'::text) AND is_active) THEN amount_cents
ELSE NULL::integer
END) AS yearly_cents,
max(
CASE
WHEN (("interval" = 'month'::text) AND is_active) THEN currency
ELSE NULL::text
END) AS monthly_currency,
max(
CASE
WHEN (("interval" = 'year'::text) AND is_active) THEN currency
ELSE NULL::text
END) AS yearly_currency
FROM public.plan_prices
GROUP BY plan_id;
ALTER VIEW public.v_plan_active_prices OWNER TO supabase_admin;
--
-- Name: v_public_pricing; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_public_pricing AS
SELECT p.id AS plan_id,
p.key AS plan_key,
p.name AS plan_name,
COALESCE(pp.public_name, ''::text) AS public_name,
COALESCE(pp.public_description, ''::text) AS public_description,
pp.badge,
COALESCE(pp.is_featured, false) AS is_featured,
COALESCE(pp.is_visible, true) AS is_visible,
COALESCE(pp.sort_order, 0) AS sort_order,
ap.monthly_cents,
ap.yearly_cents,
ap.monthly_currency,
ap.yearly_currency,
COALESCE(( SELECT jsonb_agg(jsonb_build_object('id', b.id, 'text', b.text, 'highlight', b.highlight, 'sort_order', b.sort_order) ORDER BY b.sort_order, b.created_at) AS jsonb_agg
FROM public.plan_public_bullets b
WHERE (b.plan_id = p.id)), '[]'::jsonb) AS bullets,
p.target AS plan_target
FROM ((public.plans p
LEFT JOIN public.plan_public pp ON ((pp.plan_id = p.id)))
LEFT JOIN public.v_plan_active_prices ap ON ((ap.plan_id = p.id)))
ORDER BY COALESCE(pp.sort_order, 0), p.key;
ALTER VIEW public.v_public_pricing OWNER TO supabase_admin;
--
-- Name: v_subscription_feature_mismatch; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_subscription_feature_mismatch AS
WITH expected AS (
SELECT s.user_id AS owner_id,
f.key AS feature_key
FROM ((public.subscriptions s
JOIN public.plan_features pf ON (((pf.plan_id = s.plan_id) AND (pf.enabled = true))))
JOIN public.features f ON ((f.id = pf.feature_id)))
WHERE ((s.status = 'active'::text) AND (s.tenant_id IS NULL) AND (s.user_id IS NOT NULL))
), actual AS (
SELECT e.owner_id,
e.feature_key
FROM public.owner_feature_entitlements e
)
SELECT COALESCE(expected.owner_id, actual.owner_id) AS owner_id,
COALESCE(expected.feature_key, actual.feature_key) AS feature_key,
CASE
WHEN ((expected.feature_key IS NOT NULL) AND (actual.feature_key IS NULL)) THEN 'missing_entitlement'::text
WHEN ((expected.feature_key IS NULL) AND (actual.feature_key IS NOT NULL)) THEN 'unexpected_entitlement'::text
ELSE NULL::text
END AS mismatch_type
FROM (expected
FULL JOIN actual ON (((expected.owner_id = actual.owner_id) AND (expected.feature_key = actual.feature_key))))
WHERE ((expected.feature_key IS NULL) OR (actual.feature_key IS NULL));
ALTER VIEW public.v_subscription_feature_mismatch OWNER TO supabase_admin;
--
-- Name: v_subscription_health; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_subscription_health AS
SELECT s.id AS subscription_id,
s.user_id AS owner_id,
s.status,
s.plan_id,
p.key AS plan_key,
s.current_period_start,
s.current_period_end,
s.updated_at,
CASE
WHEN (s.plan_id IS NULL) THEN 'missing_plan'::text
WHEN (p.id IS NULL) THEN 'invalid_plan'::text
WHEN ((s.status = 'active'::text) AND (s.current_period_end IS NOT NULL) AND (s.current_period_end < now())) THEN 'expired_but_active'::text
WHEN ((s.status = 'canceled'::text) AND (s.current_period_end > now())) THEN 'canceled_but_still_in_period'::text
ELSE 'ok'::text
END AS health_status,
CASE
WHEN (s.tenant_id IS NOT NULL) THEN 'clinic'::text
ELSE 'therapist'::text
END AS owner_type,
COALESCE(s.tenant_id, s.user_id) AS owner_ref
FROM (public.subscriptions s
LEFT JOIN public.plans p ON ((p.id = s.plan_id)));
ALTER VIEW public.v_subscription_health OWNER TO supabase_admin;
--
-- Name: v_subscription_health_v2; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_subscription_health_v2 AS
SELECT s.id AS subscription_id,
s.user_id AS owner_id,
CASE
WHEN (s.tenant_id IS NOT NULL) THEN 'clinic'::text
ELSE 'therapist'::text
END AS owner_type,
COALESCE(s.tenant_id, s.user_id) AS owner_ref,
s.status,
s.plan_id,
p.key AS plan_key,
s.current_period_start,
s.current_period_end,
s.updated_at,
CASE
WHEN (s.plan_id IS NULL) THEN 'missing_plan'::text
WHEN (p.id IS NULL) THEN 'invalid_plan'::text
WHEN ((s.status = 'active'::text) AND (s.current_period_end IS NOT NULL) AND (s.current_period_end < now())) THEN 'expired_but_active'::text
WHEN ((s.status = 'canceled'::text) AND (s.current_period_end > now())) THEN 'canceled_but_still_in_period'::text
ELSE 'ok'::text
END AS health_status
FROM (public.subscriptions s
LEFT JOIN public.plans p ON ((p.id = s.plan_id)));
ALTER VIEW public.v_subscription_health_v2 OWNER TO supabase_admin;
--
-- Name: v_tag_patient_counts; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tag_patient_counts AS
SELECT t.id,
t.owner_id,
t.nome,
t.cor,
t.is_padrao,
t.created_at,
t.updated_at,
(COALESCE(count(ppt.patient_id), (0)::bigint))::integer AS pacientes_count,
(COALESCE(count(ppt.patient_id), (0)::bigint))::integer AS patient_count
FROM (public.patient_tags t
LEFT JOIN public.patient_patient_tag ppt ON (((ppt.tag_id = t.id) AND (ppt.owner_id = t.owner_id))))
GROUP BY t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at;
ALTER VIEW public.v_tag_patient_counts OWNER TO supabase_admin;
--
-- Name: v_tenant_active_subscription; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_active_subscription AS
SELECT DISTINCT ON (tenant_id) tenant_id,
plan_id,
plan_key,
"interval",
status,
current_period_start,
current_period_end,
created_at
FROM public.subscriptions s
WHERE ((tenant_id IS NOT NULL) AND (status = 'active'::text) AND ((current_period_end IS NULL) OR (current_period_end > now())))
ORDER BY tenant_id, created_at DESC;
ALTER VIEW public.v_tenant_active_subscription OWNER TO supabase_admin;
--
-- Name: v_tenant_entitlements; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_entitlements AS
SELECT a.tenant_id,
f.key AS feature_key,
true AS allowed
FROM ((public.v_tenant_active_subscription a
JOIN public.plan_features pf ON (((pf.plan_id = a.plan_id) AND (pf.enabled = true))))
JOIN public.features f ON ((f.id = pf.feature_id)));
ALTER VIEW public.v_tenant_entitlements OWNER TO supabase_admin;
--
-- Name: v_tenant_entitlements_full; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_entitlements_full AS
SELECT a.tenant_id,
f.key AS feature_key,
(pf.enabled = true) AS allowed,
pf.limits,
a.plan_id,
p.key AS plan_key
FROM (((public.v_tenant_active_subscription a
JOIN public.plan_features pf ON ((pf.plan_id = a.plan_id)))
JOIN public.features f ON ((f.id = pf.feature_id)))
JOIN public.plans p ON ((p.id = a.plan_id)));
ALTER VIEW public.v_tenant_entitlements_full OWNER TO supabase_admin;
--
-- Name: v_tenant_entitlements_json; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_entitlements_json AS
SELECT tenant_id,
max(plan_key) AS plan_key,
jsonb_object_agg(feature_key, jsonb_build_object('allowed', allowed, 'limits', COALESCE(limits, '{}'::jsonb)) ORDER BY feature_key) AS entitlements
FROM public.v_tenant_entitlements_full
GROUP BY tenant_id;
ALTER VIEW public.v_tenant_entitlements_json OWNER TO supabase_admin;
--
-- Name: v_tenant_feature_exceptions; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_feature_exceptions AS
SELECT tf.tenant_id,
a.plan_key,
tf.feature_key,
'commercial_exception'::text AS exception_type
FROM ((public.tenant_features tf
JOIN public.v_tenant_active_subscription a ON ((a.tenant_id = tf.tenant_id)))
LEFT JOIN public.v_tenant_entitlements_full v ON (((v.tenant_id = tf.tenant_id) AND (v.feature_key = tf.feature_key))))
WHERE ((tf.enabled = true) AND (COALESCE(v.allowed, false) = false));
ALTER VIEW public.v_tenant_feature_exceptions OWNER TO supabase_admin;
--
-- Name: v_tenant_feature_mismatch; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_feature_mismatch AS
WITH plan_allowed AS (
SELECT v.tenant_id,
v.feature_key,
v.allowed
FROM public.v_tenant_entitlements_full v
), overrides AS (
SELECT tf.tenant_id,
tf.feature_key,
tf.enabled
FROM public.tenant_features tf
)
SELECT o.tenant_id,
o.feature_key,
CASE
WHEN ((o.enabled = true) AND (COALESCE(p.allowed, false) = false)) THEN 'unexpected_override'::text
ELSE NULL::text
END AS mismatch_type
FROM (overrides o
LEFT JOIN plan_allowed p ON (((p.tenant_id = o.tenant_id) AND (p.feature_key = o.feature_key))))
WHERE ((o.enabled = true) AND (COALESCE(p.allowed, false) = false));
ALTER VIEW public.v_tenant_feature_mismatch OWNER TO supabase_admin;
--
-- Name: v_tenant_members_with_profiles; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_members_with_profiles AS
SELECT tm.id AS tenant_member_id,
tm.tenant_id,
tm.user_id,
tm.role,
tm.status,
tm.created_at,
p.full_name,
au.email
FROM ((public.tenant_members tm
LEFT JOIN public.profiles p ON ((p.id = tm.user_id)))
LEFT JOIN auth.users au ON ((au.id = tm.user_id)));
ALTER VIEW public.v_tenant_members_with_profiles OWNER TO supabase_admin;
--
-- Name: v_tenant_people; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_people AS
SELECT 'member'::text AS type,
m.tenant_id,
m.user_id,
u.email,
m.role,
m.status,
NULL::uuid AS invite_token,
NULL::timestamp with time zone AS expires_at
FROM (public.tenant_members m
JOIN auth.users u ON ((u.id = m.user_id)))
UNION ALL
SELECT 'invite'::text AS type,
i.tenant_id,
NULL::uuid AS user_id,
i.email,
i.role,
'invited'::text AS status,
i.token AS invite_token,
i.expires_at
FROM public.tenant_invites i
WHERE ((i.accepted_at IS NULL) AND (i.revoked_at IS NULL));
ALTER VIEW public.v_tenant_people OWNER TO supabase_admin;
--
-- Name: v_tenant_staff; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_tenant_staff AS
SELECT ('m_'::text || (tm.id)::text) AS row_id,
tm.tenant_id,
tm.user_id,
tm.role,
tm.status,
tm.created_at,
p.full_name,
au.email,
NULL::uuid AS invite_token
FROM ((public.tenant_members tm
LEFT JOIN public.profiles p ON ((p.id = tm.user_id)))
LEFT JOIN auth.users au ON ((au.id = tm.user_id)))
UNION ALL
SELECT ('i_'::text || (ti.id)::text) AS row_id,
ti.tenant_id,
NULL::uuid AS user_id,
ti.role,
'invited'::text AS status,
ti.created_at,
NULL::text AS full_name,
ti.email,
ti.token AS invite_token
FROM public.tenant_invites ti
WHERE ((ti.accepted_at IS NULL) AND (ti.revoked_at IS NULL) AND (ti.expires_at > now()));
ALTER VIEW public.v_tenant_staff OWNER TO supabase_admin;
--
-- Name: v_user_active_subscription; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_user_active_subscription AS
SELECT DISTINCT ON (user_id) user_id,
plan_id,
plan_key,
"interval",
status,
current_period_start,
current_period_end,
created_at
FROM public.subscriptions s
WHERE ((tenant_id IS NULL) AND (user_id IS NOT NULL) AND (status = 'active'::text) AND ((current_period_end IS NULL) OR (current_period_end > now())))
ORDER BY user_id, created_at DESC;
ALTER VIEW public.v_user_active_subscription OWNER TO supabase_admin;
--
-- Name: v_user_entitlements; Type: VIEW; Schema: public; Owner: supabase_admin
--
CREATE VIEW public.v_user_entitlements AS
SELECT a.user_id,
f.key AS feature_key,
true AS allowed
FROM ((public.v_user_active_subscription a
JOIN public.plan_features pf ON (((pf.plan_id = a.plan_id) AND (pf.enabled = true))))
JOIN public.features f ON ((f.id = pf.feature_id)));
ALTER VIEW public.v_user_entitlements OWNER TO supabase_admin;
--
-- Name: messages; Type: TABLE; Schema: realtime; Owner: supabase_realtime_admin
--

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,481 @@
-- =============================================================================
-- AgenciaPsi — Triggers
-- =============================================================================
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
--
-- Name: users trg_seed_patient_groups; Type: TRIGGER; Schema: auth; Owner: supabase_auth_admin
--
CREATE TRIGGER trg_seed_patient_groups AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.on_new_user_seed_patient_groups();
--
-- Name: agenda_bloqueios agenda_bloqueios_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER agenda_bloqueios_updated_at BEFORE UPDATE ON public.agenda_bloqueios FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: agendador_configuracoes agendador_slug_trigger; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER agendador_slug_trigger BEFORE INSERT OR UPDATE ON public.agendador_configuracoes FOR EACH ROW EXECUTE FUNCTION public.agendador_gerar_slug();
--
-- Name: tenant_members prevent_saas_membership_trigger; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER prevent_saas_membership_trigger BEFORE INSERT ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.prevent_saas_membership();
--
-- Name: insurance_plan_services set_insurance_plan_services_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER set_insurance_plan_services_updated_at BEFORE UPDATE ON public.insurance_plan_services FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: user_settings t_user_settings_set_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER t_user_settings_set_updated_at BEFORE UPDATE ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: agenda_configuracoes tg_agenda_configuracoes_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER tg_agenda_configuracoes_updated_at BEFORE UPDATE ON public.agenda_configuracoes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: agenda_eventos tg_agenda_eventos_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER tg_agenda_eventos_updated_at BEFORE UPDATE ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: agenda_excecoes tg_agenda_excecoes_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER tg_agenda_excecoes_updated_at BEFORE UPDATE ON public.agenda_excecoes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: agenda_regras_semanais tg_agenda_regras_semanais_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER tg_agenda_regras_semanais_updated_at BEFORE UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: recurrence_rules tg_recurrence_rules_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER tg_recurrence_rules_updated_at BEFORE UPDATE ON public.recurrence_rules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at_recurrence();
--
-- Name: plan_public tr_plan_public_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER tr_plan_public_updated_at BEFORE UPDATE ON public.plan_public FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: profiles trg_account_type_immutable; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_account_type_immutable BEFORE UPDATE OF account_type ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.guard_account_type_immutable();
--
-- Name: agenda_configuracoes trg_agenda_cfg_sync; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_agenda_cfg_sync BEFORE INSERT OR UPDATE ON public.agenda_configuracoes FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync();
--
-- Name: agenda_eventos trg_agenda_eventos_busy_mirror_del; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_agenda_eventos_busy_mirror_del AFTER DELETE ON public.agenda_eventos FOR EACH ROW WHEN (((old.mirror_of_event_id IS NULL) AND (old.tenant_id = old.owner_id))) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos();
--
-- Name: agenda_eventos trg_agenda_eventos_busy_mirror_ins; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_agenda_eventos_busy_mirror_ins AFTER INSERT ON public.agenda_eventos FOR EACH ROW WHEN (((new.mirror_of_event_id IS NULL) AND (new.tenant_id = new.owner_id) AND (new.visibility_scope = ANY (ARRAY['busy_only'::text, 'private'::text])))) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos();
--
-- Name: agenda_eventos trg_agenda_eventos_busy_mirror_upd; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_agenda_eventos_busy_mirror_upd AFTER UPDATE ON public.agenda_eventos FOR EACH ROW WHEN (((new.mirror_of_event_id IS NULL) AND (new.tenant_id = new.owner_id) AND ((new.visibility_scope IS DISTINCT FROM old.visibility_scope) OR (new.inicio_em IS DISTINCT FROM old.inicio_em) OR (new.fim_em IS DISTINCT FROM old.fim_em) OR (new.owner_id IS DISTINCT FROM old.owner_id) OR (new.tenant_id IS DISTINCT FROM old.tenant_id)))) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos();
--
-- Name: agenda_regras_semanais trg_agenda_regras_semanais_no_overlap; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_agenda_regras_semanais_no_overlap BEFORE INSERT OR UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap();
--
-- Name: agenda_eventos trg_auto_financial_from_session; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_auto_financial_from_session AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session();
--
-- Name: notification_preferences trg_cancel_notifs_on_opt_out; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_cancel_notifs_on_opt_out AFTER UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out();
--
-- Name: agenda_eventos trg_cancel_notifs_on_session_cancel; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_cancel_notifs_on_session_cancel AFTER UPDATE ON public.agenda_eventos FOR EACH ROW WHEN ((new.status IS DISTINCT FROM old.status)) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel();
--
-- Name: company_profiles trg_company_profiles_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_company_profiles_updated_at BEFORE UPDATE ON public.company_profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: determined_commitment_fields trg_determined_commitment_fields_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_determined_commitment_fields_updated_at BEFORE UPDATE ON public.determined_commitment_fields FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: determined_commitments trg_determined_commitments_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_determined_commitments_updated_at BEFORE UPDATE ON public.determined_commitments FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: email_layout_config trg_email_layout_config_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_email_layout_config_updated_at BEFORE UPDATE ON public.email_layout_config FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: email_templates_global trg_email_templates_global_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_email_templates_global_updated_at BEFORE UPDATE ON public.email_templates_global FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: email_templates_tenant trg_email_templates_tenant_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_email_templates_tenant_updated_at BEFORE UPDATE ON public.email_templates_tenant FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: financial_exceptions trg_financial_exceptions_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_financial_exceptions_updated_at BEFORE UPDATE ON public.financial_exceptions FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: financial_records trg_financial_records_auto_overdue; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_financial_records_auto_overdue BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue();
--
-- Name: financial_records trg_financial_records_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_financial_records_updated_at BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: global_notices trg_global_notices_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_global_notices_updated_at BEFORE UPDATE ON public.global_notices FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: insurance_plans trg_insurance_plans_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_insurance_plans_updated_at BEFORE UPDATE ON public.insurance_plans FOR EACH ROW EXECUTE FUNCTION public.set_insurance_plans_updated_at();
--
-- Name: plans trg_no_change_core_plan_key; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_no_change_core_plan_key BEFORE UPDATE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_change_core_plan_key();
--
-- Name: plans trg_no_change_plan_target; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_no_change_plan_target BEFORE UPDATE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_change_plan_target();
--
-- Name: plans trg_no_delete_core_plans; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_no_delete_core_plans BEFORE DELETE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_delete_core_plans();
--
-- Name: notification_channels trg_notification_channels_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notification_channels_updated_at BEFORE UPDATE ON public.notification_channels FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: notification_logs trg_notification_logs_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notification_logs_updated_at BEFORE UPDATE ON public.notification_logs FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: notification_preferences trg_notification_preferences_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notification_preferences_updated_at BEFORE UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: notification_queue trg_notification_queue_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notification_queue_updated_at BEFORE UPDATE ON public.notification_queue FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: notification_schedules trg_notification_schedules_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notification_schedules_updated_at BEFORE UPDATE ON public.notification_schedules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: notification_templates trg_notification_templates_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notification_templates_updated_at BEFORE UPDATE ON public.notification_templates FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: patient_intake_requests trg_notify_on_intake; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notify_on_intake AFTER INSERT ON public.patient_intake_requests FOR EACH ROW EXECUTE FUNCTION public.notify_on_intake();
--
-- Name: agendador_solicitacoes trg_notify_on_scheduling; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notify_on_scheduling AFTER INSERT ON public.agendador_solicitacoes FOR EACH ROW EXECUTE FUNCTION public.notify_on_scheduling();
--
-- Name: agenda_eventos trg_notify_on_session_status; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notify_on_session_status AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status();
--
-- Name: tenant_members trg_patient_cannot_own_tenant; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_patient_cannot_own_tenant BEFORE INSERT OR UPDATE ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.guard_patient_cannot_own_tenant();
--
-- Name: patient_groups trg_patient_groups_set_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_patient_groups_set_updated_at BEFORE UPDATE ON public.patient_groups FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: patient_intake_requests trg_patient_intake_requests_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_patient_intake_requests_updated_at BEFORE UPDATE ON public.patient_intake_requests FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: patient_tags trg_patient_tags_set_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_patient_tags_set_updated_at BEFORE UPDATE ON public.patient_tags FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: patients trg_patients_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_patients_updated_at BEFORE UPDATE ON public.patients FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: patients trg_patients_validate_members; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_patients_validate_members BEFORE INSERT OR UPDATE OF tenant_id, responsible_member_id, patient_scope, therapist_member_id ON public.patients FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency();
--
-- Name: payment_settings trg_payment_settings_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_payment_settings_updated_at BEFORE UPDATE ON public.payment_settings FOR EACH ROW EXECUTE FUNCTION public.update_payment_settings_updated_at();
--
-- Name: patient_groups trg_prevent_promoting_to_system; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_prevent_promoting_to_system BEFORE UPDATE ON public.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system();
--
-- Name: patient_groups trg_prevent_system_group_changes; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_prevent_system_group_changes BEFORE DELETE OR UPDATE ON public.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes();
--
-- Name: professional_pricing trg_professional_pricing_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_professional_pricing_updated_at BEFORE UPDATE ON public.professional_pricing FOR EACH ROW EXECUTE FUNCTION public.update_professional_pricing_updated_at();
--
-- Name: profiles trg_profiles_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_profiles_updated_at BEFORE UPDATE ON public.profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: services trg_services_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_services_updated_at BEFORE UPDATE ON public.services FOR EACH ROW EXECUTE FUNCTION public.set_services_updated_at();
--
-- Name: subscription_intents trg_subscription_intents_view_insert; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_subscription_intents_view_insert INSTEAD OF INSERT ON public.subscription_intents FOR EACH ROW EXECUTE FUNCTION public.subscription_intents_view_insert();
--
-- Name: subscriptions trg_subscriptions_validate_scope; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_subscriptions_validate_scope BEFORE INSERT OR UPDATE ON public.subscriptions FOR EACH ROW EXECUTE FUNCTION public.subscriptions_validate_scope();
--
-- Name: tenant_features trg_tenant_features_guard_with_plan; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_tenant_features_guard_with_plan BEFORE INSERT OR UPDATE ON public.tenant_features FOR EACH ROW EXECUTE FUNCTION public.tenant_features_guard_with_plan();
--
-- Name: tenant_features trg_tenant_features_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_tenant_features_updated_at BEFORE UPDATE ON public.tenant_features FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: tenants trg_tenant_kind_immutable; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_tenant_kind_immutable BEFORE UPDATE OF kind ON public.tenants FOR EACH ROW EXECUTE FUNCTION public.guard_tenant_kind_immutable();
--
-- Name: therapist_payouts trg_therapist_payouts_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_therapist_payouts_updated_at BEFORE UPDATE ON public.therapist_payouts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: user_settings trg_user_settings_updated_at; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_user_settings_updated_at BEFORE UPDATE ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
--
-- Name: subscription tr_check_filters; Type: TRIGGER; Schema: realtime; Owner: supabase_admin
--
CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters();
--
-- Name: buckets enforce_bucket_name_length_trigger; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
--
CREATE TRIGGER enforce_bucket_name_length_trigger BEFORE INSERT OR UPDATE OF name ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.enforce_bucket_name_length();
--
-- Name: buckets protect_buckets_delete; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
--
CREATE TRIGGER protect_buckets_delete BEFORE DELETE ON storage.buckets FOR EACH STATEMENT EXECUTE FUNCTION storage.protect_delete();
--
-- Name: objects protect_objects_delete; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
--
CREATE TRIGGER protect_objects_delete BEFORE DELETE ON storage.objects FOR EACH STATEMENT EXECUTE FUNCTION storage.protect_delete();
--
-- Name: objects update_objects_updated_at; Type: TRIGGER; Schema: storage; Owner: supabase_storage_admin
--
CREATE TRIGGER update_objects_updated_at BEFORE UPDATE ON storage.objects FOR EACH ROW EXECUTE FUNCTION storage.update_updated_at_column();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
#!/bin/bash
# =============================================================================
# Executa todos os seeds na ordem correta
# =============================================================================
# Uso: bash run_all_seeds.sh
#
# Pré-requisitos:
# - Container supabase_db_agenciapsi-primesakai rodando
# - Schema já aplicado (schema/00_full/schema.sql)
# =============================================================================
CONTAINER="supabase_db_agenciapsi-primesakai"
DIR="$(cd "$(dirname "$0")" && pwd)"
set -e
echo "=== Aplicando seeds — Usuários ==="
for seed in \
seed_001_fixed.sql \
seed_002.sql \
seed_003.sql
do
echo ""
echo ">>> $seed"
docker exec -i "$CONTAINER" psql -U postgres -d postgres < "$DIR/$seed"
echo "<<< OK"
done
echo ""
echo "=== Aplicando seeds — Sistema (planos, features, subscriptions) ==="
for seed in \
seed_010_plans.sql \
seed_011_features.sql \
seed_012_plan_features.sql \
seed_013_subscriptions.sql \
seed_014_global_data.sql
do
echo ""
echo ">>> $seed"
docker exec -i "$CONTAINER" psql -U postgres -d postgres < "$DIR/$seed"
echo "<<< OK"
done
echo ""
echo "=== Todos os seeds aplicados com sucesso ==="

View File

@@ -0,0 +1,327 @@
-- =============================================================================
-- SEED — Usuários fictícios para teste
-- =============================================================================
-- IMPORTANTE: Execute APÓS a migration_001.sql
-- IMPORTANTE: Requer extensão pgcrypto (já ativa no Supabase)
--
-- Cria os seguintes usuários de teste:
--
-- paciente@agenciapsi.com.br senha: Teste@123 → paciente
-- terapeuta@agenciapsi.com.br senha: Teste@123 → terapeuta solo
-- clinica1@agenciapsi.com.br senha: Teste@123 → clínica coworking
-- clinica2@agenciapsi.com.br senha: Teste@123 → clínica com secretaria
-- clinica3@agenciapsi.com.br senha: Teste@123 → clínica full
-- saas@agenciapsi.com.br senha: Teste@123 → admin da plataforma
--
-- =============================================================================
BEGIN;
-- Limpa seeds anteriores se existirem
DELETE FROM auth.users
WHERE email IN (
'paciente@agenciapsi.com.br',
'terapeuta@agenciapsi.com.br',
'clinica1@agenciapsi.com.br',
'clinica2@agenciapsi.com.br',
'clinica3@agenciapsi.com.br',
'saas@agenciapsi.com.br'
);
-- ============================================================
-- 1. Cria usuários no auth.users
-- NOTA: confirmed_at é coluna gerada — removida do INSERT
-- ============================================================
INSERT INTO auth.users (
instance_id,
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_user_meta_data,
raw_app_meta_data,
role,
aud,
is_sso_user,
is_anonymous,
confirmation_token,
recovery_token,
email_change_token_new,
email_change_token_current,
email_change
)
VALUES
-- Paciente
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0001-0001-0001-000000000001',
'paciente@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Ana Paciente"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Terapeuta
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0002-0002-0002-000000000002',
'terapeuta@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Bruno Terapeuta"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Clínica 1 — Coworking
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0003-0003-0003-000000000003',
'clinica1@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Clínica Espaço Psi"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Clínica 2 — Recepção
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0004-0004-0004-000000000004',
'clinica2@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Clínica Mente sã"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Clínica 3 — Full
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0005-0005-0005-000000000005',
'clinica3@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Clínica Bem Estar"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- SaaS Admin
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0006-0006-0006-000000000006',
'saas@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Admin Plataforma"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
);
-- auth.identities (obrigatório para GoTrue reconhecer login email/senha)
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
VALUES
(gen_random_uuid(), 'aaaaaaaa-0001-0001-0001-000000000001', 'paciente@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0001-0001-0001-000000000001", "email": "paciente@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0002-0002-0002-000000000002', 'terapeuta@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0002-0002-0002-000000000002", "email": "terapeuta@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0003-0003-0003-000000000003', 'clinica1@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0003-0003-0003-000000000003", "email": "clinica1@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0004-0004-0004-000000000004', 'clinica2@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0004-0004-0004-000000000004", "email": "clinica2@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0005-0005-0005-000000000005', 'clinica3@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0005-0005-0005-000000000005", "email": "clinica3@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now()),
(gen_random_uuid(), 'aaaaaaaa-0006-0006-0006-000000000006', 'saas@agenciapsi.com.br', 'email', '{"sub": "aaaaaaaa-0006-0006-0006-000000000006", "email": "saas@agenciapsi.com.br", "email_verified": true}'::jsonb, now(), now(), now())
ON CONFLICT (provider, provider_id) DO NOTHING;
-- ============================================================
-- 2. Profiles
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name)
VALUES
('aaaaaaaa-0001-0001-0001-000000000001', 'portal_user', 'patient', 'Ana Paciente'),
('aaaaaaaa-0002-0002-0002-000000000002', 'tenant_member', 'therapist', 'Bruno Terapeuta'),
('aaaaaaaa-0003-0003-0003-000000000003', 'tenant_member', 'clinic', 'Clínica Espaço Psi'),
('aaaaaaaa-0004-0004-0004-000000000004', 'tenant_member', 'clinic', 'Clínica Mente sã'),
('aaaaaaaa-0005-0005-0005-000000000005', 'tenant_member', 'clinic', 'Clínica Bem Estar'),
('aaaaaaaa-0006-0006-0006-000000000006', 'saas_admin', 'free', 'Admin Plataforma')
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name;
-- ============================================================
-- 3. SaaS Admin na tabela saas_admins
-- ============================================================
INSERT INTO public.saas_admins (user_id, created_at)
VALUES ('aaaaaaaa-0006-0006-0006-000000000006', now())
ON CONFLICT (user_id) DO NOTHING;
-- ============================================================
-- 4. Tenant do terapeuta
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'Bruno Terapeuta', 'therapist', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0002-0002-0002-000000000002', 'aaaaaaaa-0002-0002-0002-000000000002', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0002-0002-0002-000000000002'); END $$;
-- ============================================================
-- 5. Tenant da Clínica 1 — Coworking
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'Clínica Espaço Psi', 'clinic_coworking', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0003-0003-0003-000000000003', 'aaaaaaaa-0003-0003-0003-000000000003', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0003-0003-0003-000000000003'); END $$;
-- ============================================================
-- 6. Tenant da Clínica 2 — Recepção
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'Clínica Mente sã', 'clinic_reception', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0004-0004-0004-000000000004', 'aaaaaaaa-0004-0004-0004-000000000004', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0004-0004-0004-000000000004'); END $$;
-- ============================================================
-- 7. Tenant da Clínica 3 — Full
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'Clínica Bem Estar', 'clinic_full', now())
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES ('bbbbbbbb-0005-0005-0005-000000000005', 'aaaaaaaa-0005-0005-0005-000000000005', 'tenant_admin', 'active', now())
ON CONFLICT (tenant_id, user_id) DO NOTHING;
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0005-0005-0005-000000000005'); END $$;
-- ============================================================
-- 8. Subscriptions ativas para cada conta
-- ============================================================
-- Terapeuta → plano therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0002-0002-0002-000000000002',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- Clínica 1 → plano clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0003-0003-0003-000000000003',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clínica 2 → plano clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0004-0004-0004-000000000004',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clínica 3 → plano clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0005-0005-0005-000000000005',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Paciente → plano patient_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0001-0001-0001-000000000001',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'patient_free';
-- ============================================================
-- 9. Vincula terapeuta à Clínica 3 (full) como exemplo
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005',
'aaaaaaaa-0002-0002-0002-000000000002',
'therapist',
'active',
now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 10. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed aplicado com sucesso.';
RAISE NOTICE '';
RAISE NOTICE ' Usuários criados:';
RAISE NOTICE ' paciente@agenciapsi.com.br → patient';
RAISE NOTICE ' terapeuta@agenciapsi.com.br → therapist (tenant próprio + vinculado à Clínica 3)';
RAISE NOTICE ' clinica1@agenciapsi.com.br → clinic_coworking';
RAISE NOTICE ' clinica2@agenciapsi.com.br → clinic_reception';
RAISE NOTICE ' clinica3@agenciapsi.com.br → clinic_full';
RAISE NOTICE ' saas@agenciapsi.com.br → saas_admin';
RAISE NOTICE ' Senha de todos: Teste@123';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,199 @@
-- =============================================================================
-- SEED 002 — Supervisor e Editor
-- =============================================================================
-- Execute APÓS seed_001.sql
-- Requer: pgcrypto (já ativo no Supabase)
--
-- Cria os seguintes usuários de teste:
--
-- supervisor@agenciapsi.com.br senha: Teste@123 → supervisor da Clínica 3
-- editor@agenciapsi.com.br senha: Teste@123 → editor de conteúdo (plataforma)
--
-- UUIDs reservados:
-- Supervisor → aaaaaaaa-0007-0007-0007-000000000007
-- Editor → aaaaaaaa-0008-0008-0008-000000000008
--
-- =============================================================================
BEGIN;
-- ============================================================
-- 0. Migration: adiciona platform_roles em profiles (se não existir)
-- ============================================================
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS platform_roles text[] NOT NULL DEFAULT '{}';
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.';
-- ============================================================
-- 1. Remove seeds anteriores (idempotente)
-- ============================================================
DELETE FROM auth.users
WHERE email IN (
'supervisor@agenciapsi.com.br',
'editor@agenciapsi.com.br'
);
-- ============================================================
-- 2. Cria usuários no auth.users
-- ============================================================
INSERT INTO auth.users (
instance_id,
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_user_meta_data,
raw_app_meta_data,
role,
aud,
is_sso_user,
is_anonymous,
confirmation_token,
recovery_token,
email_change_token_new,
email_change_token_current,
email_change
)
VALUES
-- Supervisor
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0007-0007-0007-000000000007',
'supervisor@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Carlos Supervisor"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Editor de Conteúdo
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0008-0008-0008-000000000008',
'editor@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Diana Editora"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
);
-- ============================================================
-- 3. auth.identities (obrigatório para GoTrue reconhecer login)
-- ============================================================
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
VALUES
(
gen_random_uuid(),
'aaaaaaaa-0007-0007-0007-000000000007',
'supervisor@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0007-0007-0007-000000000007", "email": "supervisor@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
),
(
gen_random_uuid(),
'aaaaaaaa-0008-0008-0008-000000000008',
'editor@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0008-0008-0008-000000000008", "email": "editor@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
)
ON CONFLICT (provider, provider_id) DO NOTHING;
-- ============================================================
-- 4. Profiles
-- Supervisor → tenant_member (papel no tenant via tenant_members.role)
-- Editor → tenant_member + platform_roles = '{editor}'
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name, platform_roles)
VALUES
(
'aaaaaaaa-0007-0007-0007-000000000007',
'tenant_member',
'therapist',
'Carlos Supervisor',
'{}'
),
(
'aaaaaaaa-0008-0008-0008-000000000008',
'tenant_member',
'therapist',
'Diana Editora',
'{editor}' -- permissão de plataforma: acesso à área do editor
)
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name,
platform_roles = EXCLUDED.platform_roles;
-- ============================================================
-- 5. Vincula Supervisor à Clínica 3 (Full) com role 'supervisor'
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0007-0007-0007-000000000007', -- Carlos Supervisor
'supervisor',
'active',
now()
)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
role = EXCLUDED.role,
status = EXCLUDED.status;
-- ============================================================
-- 6. Vincula Editor à Clínica 3 como terapeuta
-- (contexto de tenant para o editor poder usar /therapist também,
-- se necessário. O papel de editor vem de platform_roles.)
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0008-0008-0008-000000000008', -- Diana Editora
'therapist',
'active',
now()
)
ON CONFLICT (tenant_id, user_id) DO UPDATE SET
role = EXCLUDED.role,
status = EXCLUDED.status;
-- ============================================================
-- 7. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed 002 aplicado com sucesso.';
RAISE NOTICE '';
RAISE NOTICE ' Migration aplicada:';
RAISE NOTICE ' → profiles.platform_roles text[] adicionada (se não existia)';
RAISE NOTICE '';
RAISE NOTICE ' Usuários criados:';
RAISE NOTICE ' supervisor@agenciapsi.com.br → supervisor da Clínica Bem Estar (Full)';
RAISE NOTICE ' editor@agenciapsi.com.br → editor de conteúdo (platform_roles = {editor})';
RAISE NOTICE ' Senha de todos: Teste@123';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,283 @@
-- =============================================================================
-- SEED 003 — Terapeuta 2, Terapeuta 3 e Secretária
-- =============================================================================
-- Execute APÓS seed_001.sql (e seed_002.sql se quiser todos os seeds)
-- Requer: pgcrypto (já ativo no Supabase)
--
-- Cria os seguintes usuários de teste:
--
-- therapist2@agenciapsi.com.br senha: Teste@123 → terapeuta 2 (tenant próprio + Clínica 3)
-- therapist3@agenciapsi.com.br senha: Teste@123 → terapeuta 3 (tenant próprio + Clínica 3)
-- secretary@agenciapsi.com.br senha: Teste@123 → clinic_admin na Clínica 2 (Mente sã)
--
-- UUIDs reservados:
-- Terapeuta 2 → aaaaaaaa-0009-0009-0009-000000000009
-- Terapeuta 3 → aaaaaaaa-0010-0010-0010-000000000010
-- Secretária → aaaaaaaa-0011-0011-0011-000000000011
-- Tenant Terapeuta 2 → bbbbbbbb-0009-0009-0009-000000000009
-- Tenant Terapeuta 3 → bbbbbbbb-0010-0010-0010-000000000010
-- =============================================================================
BEGIN;
-- ============================================================
-- 1. Remove seeds anteriores (idempotente)
-- ============================================================
DELETE FROM auth.users
WHERE email IN (
'therapist2@agenciapsi.com.br',
'therapist3@agenciapsi.com.br',
'secretary@agenciapsi.com.br'
);
-- ============================================================
-- 2. Cria usuários no auth.users
-- ⚠️ confirmed_at é coluna gerada — NÃO incluir na lista
-- ============================================================
INSERT INTO auth.users (
instance_id,
id,
email,
encrypted_password,
email_confirmed_at,
created_at,
updated_at,
raw_user_meta_data,
raw_app_meta_data,
role,
aud,
is_sso_user,
is_anonymous,
confirmation_token,
recovery_token,
email_change_token_new,
email_change_token_current,
email_change
)
VALUES
-- Terapeuta 2
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0009-0009-0009-000000000009',
'therapist2@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Eva Terapeuta"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Terapeuta 3
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0010-0010-0010-000000000010',
'therapist3@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Felipe Terapeuta"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
),
-- Secretária
(
'00000000-0000-0000-0000-000000000000',
'aaaaaaaa-0011-0011-0011-000000000011',
'secretary@agenciapsi.com.br',
crypt('Teste@123', gen_salt('bf')),
now(), now(), now(),
'{"name": "Gabriela Secretária"}'::jsonb,
'{"provider": "email", "providers": ["email"]}'::jsonb,
'authenticated', 'authenticated', false, false, '', '', '', '', ''
);
-- ============================================================
-- 3. auth.identities (obrigatório para GoTrue reconhecer login)
-- ============================================================
INSERT INTO auth.identities (id, user_id, provider_id, provider, identity_data, created_at, updated_at, last_sign_in_at)
VALUES
(
gen_random_uuid(),
'aaaaaaaa-0009-0009-0009-000000000009',
'therapist2@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0009-0009-0009-000000000009", "email": "therapist2@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
),
(
gen_random_uuid(),
'aaaaaaaa-0010-0010-0010-000000000010',
'therapist3@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0010-0010-0010-000000000010", "email": "therapist3@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
),
(
gen_random_uuid(),
'aaaaaaaa-0011-0011-0011-000000000011',
'secretary@agenciapsi.com.br',
'email',
'{"sub": "aaaaaaaa-0011-0011-0011-000000000011", "email": "secretary@agenciapsi.com.br", "email_verified": true}'::jsonb,
now(), now(), now()
)
ON CONFLICT (provider, provider_id) DO NOTHING;
-- ============================================================
-- 4. Profiles
-- ============================================================
INSERT INTO public.profiles (id, role, account_type, full_name)
VALUES
(
'aaaaaaaa-0009-0009-0009-000000000009',
'tenant_member',
'therapist',
'Eva Terapeuta'
),
(
'aaaaaaaa-0010-0010-0010-000000000010',
'tenant_member',
'therapist',
'Felipe Terapeuta'
),
(
'aaaaaaaa-0011-0011-0011-000000000011',
'tenant_member',
'therapist',
'Gabriela Secretária'
)
ON CONFLICT (id) DO UPDATE SET
role = EXCLUDED.role,
account_type = EXCLUDED.account_type,
full_name = EXCLUDED.full_name;
-- ============================================================
-- 5. Tenants pessoais dos Terapeutas 2 e 3
-- ============================================================
INSERT INTO public.tenants (id, name, kind, created_at)
VALUES
('bbbbbbbb-0009-0009-0009-000000000009', 'Eva Terapeuta', 'therapist', now()),
('bbbbbbbb-0010-0010-0010-000000000010', 'Felipe Terapeuta', 'therapist', now())
ON CONFLICT (id) DO NOTHING;
-- Terapeuta 2 → tenant_admin do próprio tenant
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0009-0009-0009-000000000009',
'aaaaaaaa-0009-0009-0009-000000000009',
'tenant_admin', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- Terapeuta 3 → tenant_admin do próprio tenant
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0010-0010-0010-000000000010',
'aaaaaaaa-0010-0010-0010-000000000010',
'tenant_admin', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 6. Vincula Terapeutas 2 e 3 à Clínica 3 — Full
-- (mesmo padrão de terapeuta@agenciapsi.com.br no seed_001)
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES
(
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0009-0009-0009-000000000009', -- Eva Terapeuta
'therapist', 'active', now()
),
(
'bbbbbbbb-0005-0005-0005-000000000005', -- Clínica Bem Estar (Full)
'aaaaaaaa-0010-0010-0010-000000000010', -- Felipe Terapeuta
'therapist', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 7. Vincula Secretária à Clínica 2 (Recepção) como clinic_admin
-- A secretária gerencia a recepção/agenda da clínica.
-- Acessa a área /admin com o mesmo contexto de clinic_admin.
-- ============================================================
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
VALUES (
'bbbbbbbb-0004-0004-0004-000000000004', -- Clínica Mente sã (Recepção)
'aaaaaaaa-0011-0011-0011-000000000011', -- Gabriela Secretária
'clinic_admin', 'active', now()
)
ON CONFLICT (tenant_id, user_id) DO NOTHING;
-- ============================================================
-- 8. Subscriptions
-- Terapeutas 2 e 3 → therapist_free (escopo: user_id)
-- Secretária → sem assinatura própria (usa o plano da Clínica 2)
-- ============================================================
-- Terapeuta 2 → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0009-0009-0009-000000000009',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = 'aaaaaaaa-0009-0009-0009-000000000009' AND s.status = 'active'
);
-- Terapeuta 3 → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0010-0010-0010-000000000010',
p.id, p.key, 'active', 'month',
now(), now() + interval '30 days',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = 'aaaaaaaa-0010-0010-0010-000000000010' AND s.status = 'active'
);
-- Nota: a Secretária não tem assinatura própria.
-- O acesso vem do plano da Clínica 2 (tenant_id = bbbbbbbb-0004-0004-0004-000000000004).
-- ============================================================
-- 9. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE '✅ Seed 003 aplicado com sucesso.';
RAISE NOTICE '';
RAISE NOTICE ' Usuários criados:';
RAISE NOTICE ' therapist2@agenciapsi.com.br → tenant próprio (bbbbbbbb-0009) + Clínica 3 como therapist';
RAISE NOTICE ' therapist3@agenciapsi.com.br → tenant próprio (bbbbbbbb-0010) + Clínica 3 como therapist';
RAISE NOTICE ' secretary@agenciapsi.com.br → clinic_admin na Clínica 2 Mente sã (bbbbbbbb-0004)';
RAISE NOTICE ' Senha de todos: Teste@123';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,74 @@
-- =============================================================================
-- SEED 010 — Planos e Preços
-- =============================================================================
-- Idempotente: ON CONFLICT (id) DO UPDATE
--
-- Planos:
-- patient_free → pacientes (R$0)
-- therapist_free → terapeutas solo (R$0)
-- therapist_pro → terapeutas PRO (R$49/mês, R$490/ano)
-- clinic_free → clínicas (R$0)
-- clinic_pro → clínicas PRO (R$149/mês, R$1490/ano)
-- supervisor_free → supervisores (R$0, até 3 supervisionados)
-- supervisor_pro → supervisores PRO (R$0, até 20 supervisionados)
--
-- UUIDs preservados do data_dump original para consistência.
-- =============================================================================
BEGIN;
-- ============================================================
-- 1. Plans
-- ============================================================
INSERT INTO public.plans (id, key, name, description, is_active, created_at, price_cents, currency, billing_interval, target, max_supervisees)
VALUES
('984c1f29-a975-4208-93ac-2118ed1039b7', 'patient_free', 'Paciente Free', 'Plano gratuito para pacientes', true, '2026-03-03 22:40:11.413107+00', 0, 'BRL', 'month', 'patient', NULL),
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'therapist_free', 'THERAPIST FREE', 'Plano gratuito para terapeutas.', true, '2026-03-01 09:40:48.439668+00', 0, 'BRL', 'month', 'therapist', NULL),
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'therapist_pro', 'THERAPIST PRO', 'Plano completo para terapeutas.', true, '2026-03-01 09:25:03.878498+00', 4900, 'BRL', 'month', 'therapist', NULL),
('01a5867f-0705-4714-ac97-a23470949157', 'clinic_free', 'CLINIC FREE', 'Plano gratuito para clínicas iniciarem.', true, '2026-03-01 09:25:03.878498+00', 0, 'BRL', 'month', 'clinic', NULL),
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'clinic_pro', 'CLINIC PRO', 'Plano completo para clínicas.', true, '2026-03-01 09:30:06.50975+00', 14900, 'BRL', 'month', 'clinic', NULL),
('8c4895a3-e12d-48de-a078-efb8a4ea2eb2', 'supervisor_free', 'Supervisor Free', 'Plano gratuito de supervisão. Até 3 terapeutas supervisionados.', true, '2026-03-05 00:58:17.218326+00', 0, 'BRL', 'month', 'supervisor', 3),
('ca28e46c-0687-45d5-9406-0a0f56a5b625', 'supervisor_pro', 'Supervisor PRO', 'Plano profissional de supervisão. Até 20 terapeutas supervisionados.', true, '2026-03-05 00:58:17.218326+00', 0, 'BRL', 'month', 'supervisor', 20)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
is_active = EXCLUDED.is_active,
price_cents = EXCLUDED.price_cents,
currency = EXCLUDED.currency,
billing_interval = EXCLUDED.billing_interval,
max_supervisees = EXCLUDED.max_supervisees;
-- ============================================================
-- 2. Plan Prices (somente planos pagos)
-- ============================================================
INSERT INTO public.plan_prices (id, plan_id, currency, "interval", amount_cents, is_active, active_from, active_to, source, provider, provider_price_id, created_at)
VALUES
-- therapist_pro: R$49/mês
('37510504-4617-4421-9979-4249778bd5ae', '82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'BRL', 'month', 4900, true, '2026-03-01 09:25:03.878498+00', NULL, 'manual', NULL, NULL, '2026-03-01 09:25:03.878498+00'),
-- therapist_pro: R$490/ano
('225afd5a-9f30-46bc-a0df-5eb8f91660cb', '82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'BRL', 'year', 49000, true, '2026-03-01 09:25:03.878498+00', NULL, 'manual', NULL, NULL, '2026-03-01 09:25:03.878498+00'),
-- clinic_pro: R$149/mês
('124779b4-362d-4890-9631-747021ecc1c0', 'a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'BRL', 'month', 14900, true, '2026-03-01 09:30:06.50975+00', NULL, 'manual', NULL, NULL, '2026-03-01 09:30:06.50975+00'),
-- clinic_pro: R$1490/ano
('73908784-6299-45c8-b547-e1556b45c292', 'a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'BRL', 'year', 149000, true, '2026-03-01 09:30:06.50975+00', NULL, 'manual', NULL, NULL, '2026-03-01 09:30:06.50975+00')
ON CONFLICT (id) DO UPDATE SET
amount_cents = EXCLUDED.amount_cents,
is_active = EXCLUDED.is_active,
active_from = EXCLUDED.active_from,
active_to = EXCLUDED.active_to;
-- ============================================================
-- 3. Confirma
-- ============================================================
DO $$
BEGIN
RAISE NOTICE 'seed_010_plans: 7 planos e 4 preços inseridos/atualizados.';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,85 @@
-- =============================================================================
-- SEED 011 — Features (capacidades do sistema)
-- =============================================================================
-- Idempotente: ON CONFLICT (id) DO UPDATE
-- UUIDs preservados do data_dump original.
--
-- 26 features organizadas por domínio:
-- agenda.* → visualizar, gerenciar agenda
-- patients.* → módulo de pacientes
-- online_scheduling.* → agendamento online
-- supervisor.* → supervisão clínica
-- reports / reminders → relatórios e lembretes
-- clinic / branding → funcionalidades de clínica
-- intake → triagem / cadastro externo
-- =============================================================================
BEGIN;
INSERT INTO public.features (id, key, description, created_at, descricao, name)
VALUES
-- ── Agenda ──
('5e539124-630f-4c2a-a9de-7999317085e6', 'agenda.view', NULL, '2026-02-21 02:36:01.562728+00', 'Visualizar agenda', 'Visualizar Agenda'),
('a74fef14-c9d9-4884-ba45-f81c60e0783a', 'agenda.manage', NULL, '2026-02-21 02:35:50.629667+00', 'Adicionar Compromissos na agenda', 'Gerenciar Agenda'),
-- ── Pacientes ──
('a56482a1-0787-49da-90a7-e1857488734a', 'patients', 'Patients module', '2026-03-02 12:35:19.955748+00', 'Pacientes', 'Pacientes'),
('57f631a1-9ebe-480b-a2cb-144ad32ff5f0', 'patients.view', NULL, '2026-02-28 11:15:14.275572+00', 'Visualizar pacientes', 'Visualizar Pacientes'),
('4e5bc50b-e339-42fe-9d91-61e8555f83e7', 'patients.manage', NULL, '2026-02-28 11:15:14.275572+00', 'Gerenciar pacientes', 'Gerenciar Pacientes'),
-- ── Agendamento Online ──
('53a48c3b-0617-4618-adf8-f3a255c51ee4', 'online_scheduling', 'Online scheduling capability', '2026-03-01 09:59:15.432733+00', 'Agendamento online', 'Agendamento Online (Público)'),
('5739aa27-b089-4b15-b149-31b13d768825', 'online_scheduling.manage', 'Gerenciar agendamento online (config/admin)', '2026-02-15 21:50:02.056357+00', 'Gerenciar agendamento online (admin)', 'Agendamento Online'),
('0bfe0b1c-8c3d-4c0c-af29-2ddc24f31bc7', 'online_scheduling.public', 'Página pública de agendamento', '2026-02-15 21:50:02.056357+00', 'Página pública de agendamento', 'Página Pública de Agendamento'),
-- ── Supervisão ──
('9ab8bdbb-838b-4946-aa5d-fd9cfdd257b3', 'supervisor.access', NULL, '2026-03-05 00:58:17.218326+00', 'Acesso básico ao espaço de supervisão (sala, lista de supervisionados).', 'Acesso à Supervisão'),
('1167b54a-0e93-43a2-94d7-c12e64eb56de', 'supervisor.invite', NULL, '2026-03-05 00:58:17.218326+00', 'Permite convidar terapeutas para participar da sala de supervisão.', 'Convidar Supervisionados'),
('761e4495-b46a-4791-9519-86ffe48dc47f', 'supervisor.sessions', NULL, '2026-03-05 00:58:17.218326+00', 'Agendamento e registro de sessões de supervisão.', 'Sessões de Supervisão'),
('7e82ee01-44f6-4b3f-9861-840c58e13f58', 'supervisor.reports', NULL, '2026-03-05 00:58:17.218326+00', 'Relatórios avançados de progresso e evolução dos supervisionados.', 'Relatórios de Supervisão'),
-- ── Relatórios ──
('b3efa25d-60a4-4974-8153-6ec098b3d477', 'reports_basic', 'Basic reports', '2026-03-01 09:59:15.432733+00', 'Relatórios básicos', 'Relatórios Básicos'),
('bf133ad1-da8e-4ea9-bd66-21901cb50075', 'reports_advanced', 'Advanced reports', '2026-03-01 09:59:15.432733+00', 'Relatórios avançados', 'Relatórios Avançados (v2)'),
('a830e45b-3bb4-4b17-812d-fe83777a2377', 'advanced_reports', 'Relatórios avançados (KPIs, filtros e exportações)', '2026-02-15 23:29:55.845638+00', 'Relatórios avançados', 'Relatórios Avançados'),
-- ── Lembretes ──
('f5d66212-fd73-4472-a306-07928e5deaec', 'reminders', 'Notifications/reminders capability', '2026-03-01 09:59:15.432733+00', 'Lembretes', 'Lembretes'),
('8cc81988-d02a-4542-9cb2-ce2ed7c18d60', 'sms_reminder', 'Lembretes por SMS (confirmações e avisos)', '2026-02-15 23:29:55.845638+00', 'Lembretes por SMS', 'Lembretes por SMS'),
-- ── Clínica ──
('9b36c65d-b3b3-4bed-b6d5-f7ee8c087c80', 'clinic_calendar', 'Clinic calendar / admin view', '2026-03-01 09:59:15.432733+00', 'Agenda da clínica', 'Agenda da Clínica'),
('336aeeba-b18e-4e68-8303-d42ba09f4b20', 'secretary', 'Secretary features (clinic)', '2026-03-01 09:59:15.432733+00', 'Secretaria', 'Secretaria'),
('30c9cdd5-7c8c-44d9-8c0b-614165bb9496', 'shared_reception', 'Shared reception / secretary', '2026-03-02 12:35:19.955748+00', 'Recepção / Secretária', 'Recepção Compartilhada'),
('74fc1321-4d17-49c3-b72e-db3a7f4be451', 'rooms', 'Rooms / coworking', '2026-03-02 12:35:19.955748+00', 'Salas / Coworking', 'Salas / Coworking'),
-- ── Intake / Cadastro externo ──
('c109ad27-0edf-4774-91a7-94dac4faab49', 'intake_public', 'Public intake link', '2026-03-02 12:35:19.955748+00', 'Link externo de cadastro', 'Link Externo de Cadastro'),
('90e92108-8124-40ee-88a0-f0ecafb57d76', 'intakes_pro', 'Cadastros/Intakes PRO (fluxos avançados e automações)', '2026-02-15 23:29:55.845638+00', 'Formulários PRO', 'Formulários PRO'),
-- ── Branding / API / Auditoria ──
('f393178c-284d-422f-b096-8793f85428d5', 'custom_branding', 'Custom branding', '2026-03-01 09:59:15.432733+00', 'Personalização de marca', 'Marca Personalizada'),
('d6f54674-ea8b-484b-af0e-99127a510da2', 'api_access', 'API/Integrations access', '2026-03-01 09:59:15.432733+00', 'Integrações/API', 'Acesso à API'),
('a5593d96-dd95-46bb-bef0-bd379b56ad50', 'audit_log', 'Audit log capability', '2026-03-01 09:59:15.432733+00', 'Auditoria', 'Log de Auditoria'),
-- ── Documentos ──
('b1a2c3d4-1111-4aaa-bbbb-000000000001', 'documents.upload', 'Upload e gestão de arquivos do paciente', '2026-03-29 00:00:00.000000+00', 'Upload de documentos', 'Upload de Documentos'),
('b1a2c3d4-1111-4aaa-bbbb-000000000002', 'documents.templates', 'Geração de documentos a partir de templates', '2026-03-29 00:00:00.000000+00', 'Templates de documentos', 'Templates de Documentos'),
('b1a2c3d4-1111-4aaa-bbbb-000000000003', 'documents.signatures', 'Assinatura eletrônica de documentos', '2026-03-29 00:00:00.000000+00', 'Assinaturas eletrônicas', 'Assinaturas Eletrônicas'),
('b1a2c3d4-1111-4aaa-bbbb-000000000004', 'documents.share_links', 'Links temporários para compartilhamento de documentos', '2026-03-29 00:00:00.000000+00', 'Links de compartilhamento', 'Links de Compartilhamento'),
('b1a2c3d4-1111-4aaa-bbbb-000000000005', 'documents.patient_portal', 'Acesso a documentos pelo portal do paciente', '2026-03-29 00:00:00.000000+00', 'Portal do paciente (documentos)', 'Portal do Paciente (Documentos)')
ON CONFLICT (id) DO UPDATE SET
key = EXCLUDED.key,
description = EXCLUDED.description,
descricao = EXCLUDED.descricao,
name = EXCLUDED.name;
DO $$
BEGIN
RAISE NOTICE 'seed_011_features: 31 features inseridas/atualizadas.';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,215 @@
-- =============================================================================
-- SEED 012 — Plan Features (vínculo plano ↔ feature com limites)
-- =============================================================================
-- Idempotente: ON CONFLICT (plan_id, feature_id) DO UPDATE
--
-- Legenda:
-- enabled=true → feature ativa no plano
-- enabled=false → feature aparece como "PRO" / upsell na UI
-- limits jsonb → limites quantitativos (sessions_per_month, etc.)
--
-- IMPORTANTE: therapist_free e clinic_free incluem features básicas
-- necessárias para o sistema funcionar (agenda, pacientes, etc.).
-- O data_dump de 11/03 estava incompleto para esses planos.
-- =============================================================================
BEGIN;
-- Limpa plan_features existentes para reinserir de forma limpa
DELETE FROM public.plan_features;
-- ════════════════════════════════════════════════════════════════
-- CLINIC PRO (a74bc2d4) — todas as features habilitadas
-- ════════════════════════════════════════════════════════════════
INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- Agenda
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '5e539124-630f-4c2a-a9de-7999317085e6', true, NULL), -- agenda.view
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a74fef14-c9d9-4884-ba45-f81c60e0783a', true, NULL), -- agenda.manage
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '9b36c65d-b3b3-4bed-b6d5-f7ee8c087c80', true, '{"max_patients": 9999, "max_therapists": 999}'), -- clinic_calendar
-- Pacientes
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a56482a1-0787-49da-90a7-e1857488734a', true, NULL), -- patients
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '57f631a1-9ebe-480b-a2cb-144ad32ff5f0', true, NULL), -- patients.view
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '4e5bc50b-e339-42fe-9d91-61e8555f83e7', true, NULL), -- patients.manage
-- Agendamento online
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '53a48c3b-0617-4618-adf8-f3a255c51ee4', true, '{"sessions_per_month": 9999}'), -- online_scheduling
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '5739aa27-b089-4b15-b149-31b13d768825', true, NULL), -- online_scheduling.manage
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '0bfe0b1c-8c3d-4c0c-af29-2ddc24f31bc7', true, NULL), -- online_scheduling.public
-- Relatórios
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b3efa25d-60a4-4974-8153-6ec098b3d477', true, NULL), -- reports_basic
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'bf133ad1-da8e-4ea9-bd66-21901cb50075', true, NULL), -- reports_advanced
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a830e45b-3bb4-4b17-812d-fe83777a2377', true, NULL), -- advanced_reports
-- Lembretes
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'f5d66212-fd73-4472-a306-07928e5deaec', true, '{"reminders_per_month": 9999}'), -- reminders
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '8cc81988-d02a-4542-9cb2-ce2ed7c18d60', true, NULL), -- sms_reminder
-- Clínica
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '336aeeba-b18e-4e68-8303-d42ba09f4b20', true, NULL), -- secretary
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '30c9cdd5-7c8c-44d9-8c0b-614165bb9496', true, NULL), -- shared_reception
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '74fc1321-4d17-49c3-b72e-db3a7f4be451', true, NULL), -- rooms
-- Intake
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'c109ad27-0edf-4774-91a7-94dac4faab49', true, NULL), -- intake_public
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', '90e92108-8124-40ee-88a0-f0ecafb57d76', true, NULL), -- intakes_pro
-- PRO exclusivo
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL), -- audit_log
-- Documentos
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 5000}'), -- documents.upload
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', true, NULL), -- documents.templates
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', true, NULL), -- documents.signatures
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', true, NULL), -- documents.share_links
('a74bc2d4-88c6-4cc6-b004-ef2bcb1b5145', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', true, NULL); -- documents.patient_portal
-- ════════════════════════════════════════════════════════════════
-- CLINIC FREE (01a5867f) — features básicas para funcionar
-- ════════════════════════════════════════════════════════════════
INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- Agenda
('01a5867f-0705-4714-ac97-a23470949157', '5e539124-630f-4c2a-a9de-7999317085e6', true, NULL), -- agenda.view
('01a5867f-0705-4714-ac97-a23470949157', 'a74fef14-c9d9-4884-ba45-f81c60e0783a', true, NULL), -- agenda.manage
('01a5867f-0705-4714-ac97-a23470949157', '9b36c65d-b3b3-4bed-b6d5-f7ee8c087c80', true, '{"max_patients": 30, "max_therapists": 5}'), -- clinic_calendar
-- Pacientes
('01a5867f-0705-4714-ac97-a23470949157', 'a56482a1-0787-49da-90a7-e1857488734a', true, NULL), -- patients
('01a5867f-0705-4714-ac97-a23470949157', '57f631a1-9ebe-480b-a2cb-144ad32ff5f0', true, NULL), -- patients.view
('01a5867f-0705-4714-ac97-a23470949157', '4e5bc50b-e339-42fe-9d91-61e8555f83e7', true, NULL), -- patients.manage
-- Agendamento online (com limites)
('01a5867f-0705-4714-ac97-a23470949157', '53a48c3b-0617-4618-adf8-f3a255c51ee4', true, '{"sessions_per_month": 40}'), -- online_scheduling
('01a5867f-0705-4714-ac97-a23470949157', '5739aa27-b089-4b15-b149-31b13d768825', true, NULL), -- online_scheduling.manage
('01a5867f-0705-4714-ac97-a23470949157', '0bfe0b1c-8c3d-4c0c-af29-2ddc24f31bc7', true, NULL), -- online_scheduling.public
-- Relatórios (básico sim, avançado não)
('01a5867f-0705-4714-ac97-a23470949157', 'b3efa25d-60a4-4974-8153-6ec098b3d477', true, NULL), -- reports_basic
('01a5867f-0705-4714-ac97-a23470949157', 'bf133ad1-da8e-4ea9-bd66-21901cb50075', false, NULL), -- reports_advanced (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'a830e45b-3bb4-4b17-812d-fe83777a2377', false, NULL), -- advanced_reports (PRO)
-- Lembretes (com limites)
('01a5867f-0705-4714-ac97-a23470949157', 'f5d66212-fd73-4472-a306-07928e5deaec', true, '{"reminders_per_month": 50}'), -- reminders
('01a5867f-0705-4714-ac97-a23470949157', '8cc81988-d02a-4542-9cb2-ce2ed7c18d60', false, NULL), -- sms_reminder (PRO)
-- Intake
('01a5867f-0705-4714-ac97-a23470949157', 'c109ad27-0edf-4774-91a7-94dac4faab49', true, NULL), -- intake_public
('01a5867f-0705-4714-ac97-a23470949157', '90e92108-8124-40ee-88a0-f0ecafb57d76', false, NULL), -- intakes_pro (PRO)
-- PRO-only (desabilitado)
('01a5867f-0705-4714-ac97-a23470949157', '336aeeba-b18e-4e68-8303-d42ba09f4b20', false, NULL), -- secretary (PRO)
('01a5867f-0705-4714-ac97-a23470949157', '30c9cdd5-7c8c-44d9-8c0b-614165bb9496', false, NULL), -- shared_reception (PRO)
('01a5867f-0705-4714-ac97-a23470949157', '74fc1321-4d17-49c3-b72e-db3a7f4be451', false, NULL), -- rooms (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL), -- audit_log (PRO)
-- Documentos
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 500}'), -- documents.upload (FREE)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', false, NULL), -- documents.templates (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', false, NULL), -- documents.signatures (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', false, NULL), -- documents.share_links (PRO)
('01a5867f-0705-4714-ac97-a23470949157', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', false, NULL); -- documents.patient_portal (PRO)
-- ════════════════════════════════════════════════════════════════
-- THERAPIST PRO (82067ba7) — todas as features habilitadas
-- ════════════════════════════════════════════════════════════════
INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- Agenda
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '5e539124-630f-4c2a-a9de-7999317085e6', true, NULL), -- agenda.view
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a74fef14-c9d9-4884-ba45-f81c60e0783a', true, NULL), -- agenda.manage
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '9b36c65d-b3b3-4bed-b6d5-f7ee8c087c80', true, NULL), -- clinic_calendar
-- Pacientes
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '57f631a1-9ebe-480b-a2cb-144ad32ff5f0', true, NULL), -- patients.view
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '4e5bc50b-e339-42fe-9d91-61e8555f83e7', true, NULL), -- patients.manage
-- Agendamento online
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '53a48c3b-0617-4618-adf8-f3a255c51ee4', true, '{"sessions_per_month": 9999}'), -- online_scheduling
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '5739aa27-b089-4b15-b149-31b13d768825', true, NULL), -- online_scheduling.manage
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '0bfe0b1c-8c3d-4c0c-af29-2ddc24f31bc7', true, NULL), -- online_scheduling.public
-- Relatórios
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b3efa25d-60a4-4974-8153-6ec098b3d477', true, NULL), -- reports_basic
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'bf133ad1-da8e-4ea9-bd66-21901cb50075', true, NULL), -- reports_advanced
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a830e45b-3bb4-4b17-812d-fe83777a2377', true, NULL), -- advanced_reports
-- Lembretes
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'f5d66212-fd73-4472-a306-07928e5deaec', true, '{"reminders_per_month": 9999}'), -- reminders
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '8cc81988-d02a-4542-9cb2-ce2ed7c18d60', true, NULL), -- sms_reminder
-- Clínica
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '336aeeba-b18e-4e68-8303-d42ba09f4b20', true, NULL), -- secretary
-- Intake
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'c109ad27-0edf-4774-91a7-94dac4faab49', true, NULL), -- intake_public
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', '90e92108-8124-40ee-88a0-f0ecafb57d76', true, NULL), -- intakes_pro
-- PRO exclusivo
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'f393178c-284d-422f-b096-8793f85428d5', true, NULL), -- custom_branding
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'd6f54674-ea8b-484b-af0e-99127a510da2', true, NULL), -- api_access
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', true, NULL), -- audit_log
-- Documentos
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 5000}'), -- documents.upload
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', true, NULL), -- documents.templates
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', true, NULL), -- documents.signatures
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', true, NULL), -- documents.share_links
('82067ba7-16f0-4803-b36f-4c4e8919d4b4', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', true, NULL); -- documents.patient_portal
-- ════════════════════════════════════════════════════════════════
-- THERAPIST FREE (c56fe2a8) — features básicas para funcionar
-- ════════════════════════════════════════════════════════════════
INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
-- Agenda
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '5e539124-630f-4c2a-a9de-7999317085e6', true, NULL), -- agenda.view
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'a74fef14-c9d9-4884-ba45-f81c60e0783a', true, NULL), -- agenda.manage
-- Pacientes
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '57f631a1-9ebe-480b-a2cb-144ad32ff5f0', true, NULL), -- patients.view
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '4e5bc50b-e339-42fe-9d91-61e8555f83e7', true, NULL), -- patients.manage
-- Agendamento online (com limites)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '53a48c3b-0617-4618-adf8-f3a255c51ee4', true, '{"sessions_per_month": 40}'), -- online_scheduling
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '5739aa27-b089-4b15-b149-31b13d768825', true, NULL), -- online_scheduling.manage
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '0bfe0b1c-8c3d-4c0c-af29-2ddc24f31bc7', true, NULL), -- online_scheduling.public
-- Relatórios (básico sim, avançado não)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b3efa25d-60a4-4974-8153-6ec098b3d477', true, NULL), -- reports_basic
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'bf133ad1-da8e-4ea9-bd66-21901cb50075', false, NULL), -- reports_advanced (PRO)
-- Lembretes (com limites)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'f5d66212-fd73-4472-a306-07928e5deaec', true, '{"reminders_per_month": 50}'), -- reminders
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '8cc81988-d02a-4542-9cb2-ce2ed7c18d60', false, NULL), -- sms_reminder (PRO)
-- Intake
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'c109ad27-0edf-4774-91a7-94dac4faab49', true, NULL), -- intake_public
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', '90e92108-8124-40ee-88a0-f0ecafb57d76', false, NULL), -- intakes_pro (PRO)
-- PRO-only (desabilitado)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'f393178c-284d-422f-b096-8793f85428d5', false, NULL), -- custom_branding (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'd6f54674-ea8b-484b-af0e-99127a510da2', false, NULL), -- api_access (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'a5593d96-dd95-46bb-bef0-bd379b56ad50', false, NULL), -- audit_log (PRO)
-- Documentos
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000001', true, '{"max_storage_mb": 500}'), -- documents.upload (FREE)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000002', false, NULL), -- documents.templates (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000003', false, NULL), -- documents.signatures (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000004', false, NULL), -- documents.share_links (PRO)
('c56fe2a8-2c17-4048-adc7-ff7fbd89461a', 'b1a2c3d4-1111-4aaa-bbbb-000000000005', false, NULL); -- documents.patient_portal (PRO)
-- ════════════════════════════════════════════════════════════════
-- SUPERVISOR FREE (8c4895a3) — acesso + convite
-- ════════════════════════════════════════════════════════════════
INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
('8c4895a3-e12d-48de-a078-efb8a4ea2eb2', '9ab8bdbb-838b-4946-aa5d-fd9cfdd257b3', true, NULL), -- supervisor.access
('8c4895a3-e12d-48de-a078-efb8a4ea2eb2', '1167b54a-0e93-43a2-94d7-c12e64eb56de', true, NULL); -- supervisor.invite
-- ════════════════════════════════════════════════════════════════
-- SUPERVISOR PRO (ca28e46c) — acesso + convite + sessões + relatórios
-- ════════════════════════════════════════════════════════════════
INSERT INTO public.plan_features (plan_id, feature_id, enabled, limits) VALUES
('ca28e46c-0687-45d5-9406-0a0f56a5b625', '9ab8bdbb-838b-4946-aa5d-fd9cfdd257b3', true, NULL), -- supervisor.access
('ca28e46c-0687-45d5-9406-0a0f56a5b625', '1167b54a-0e93-43a2-94d7-c12e64eb56de', true, NULL), -- supervisor.invite
('ca28e46c-0687-45d5-9406-0a0f56a5b625', '761e4495-b46a-4791-9519-86ffe48dc47f', true, NULL), -- supervisor.sessions
('ca28e46c-0687-45d5-9406-0a0f56a5b625', '7e82ee01-44f6-4b3f-9861-840c58e13f58', true, NULL); -- supervisor.reports
-- ════════════════════════════════════════════════════════════════
-- PATIENT FREE (984c1f29) — sem features de plano (acesso via portal)
-- ════════════════════════════════════════════════════════════════
-- Pacientes acessam via portal_user, não precisam de features de plano.
-- O acesso é controlado pelo role 'portal_user' no profile.
DO $$
BEGIN
RAISE NOTICE 'seed_012_plan_features: plan_features inseridos para todos os 7 planos.';
END;
$$;
COMMIT;

View File

@@ -0,0 +1,184 @@
-- =============================================================================
-- SEED 013 — Subscriptions + Determined Commitments
-- =============================================================================
-- Execute APÓS: seed_010 (plans), seed_011 (features), seed_012 (plan_features)
-- E APÓS: seed_001, seed_002, seed_003 (usuários e tenants)
--
-- Cria subscriptions para todos os usuários de teste e chama
-- seed_determined_commitments() para tenants que ainda não têm.
--
-- Mapeamento:
-- paciente@ (user_id) → patient_free
-- terapeuta@ (user_id) → therapist_free
-- clinica1@ (tenant_id) → clinic_free (coworking)
-- clinica2@ (tenant_id) → clinic_free (recepção)
-- clinica3@ (tenant_id) → clinic_free (full)
-- supervisor@ (user_id) → supervisor_free
-- editor@ (user_id) → therapist_free (acesso via platform_roles)
-- therapist2@ (user_id) → therapist_free
-- therapist3@ (user_id) → therapist_free
-- secretary@ → sem plano próprio (usa plano da Clínica 2)
-- saas@ → saas_admin (sem plano)
-- =============================================================================
BEGIN;
-- Limpa subscriptions de seed anteriores
DELETE FROM public.subscriptions WHERE source = 'seed';
-- ============================================================
-- 1. Subscriptions — user_id scope (therapist, patient, supervisor)
-- ============================================================
-- Paciente → patient_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0001-0001-0001-000000000001',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'patient_free';
-- Terapeuta → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0002-0002-0002-000000000002',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- Supervisor → supervisor_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0007-0007-0007-000000000007',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'supervisor_free';
-- Editor → therapist_free (acesso editor vem de platform_roles)
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0008-0008-0008-000000000008',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- Terapeuta 2 → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0009-0009-0009-000000000009',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- Terapeuta 3 → therapist_free
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'aaaaaaaa-0010-0010-0010-000000000010',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'therapist_free';
-- ============================================================
-- 2. Subscriptions — tenant_id scope (clinic)
-- ============================================================
-- Clínica 1 (Espaço Psi / Coworking) → clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0003-0003-0003-000000000003',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clínica 2 (Mente sã / Recepção) → clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0004-0004-0004-000000000004',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- Clínica 3 (Bem Estar / Full) → clinic_free
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, "interval",
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
'bbbbbbbb-0005-0005-0005-000000000005',
p.id, p.key, 'active', 'month',
now(), now() + interval '1 year',
'seed', now(), now()
FROM public.plans p WHERE p.key = 'clinic_free';
-- ============================================================
-- 3. Determined Commitments (idempotente por design da função)
-- ============================================================
-- Chama para todos os tenants. A função usa ON CONFLICT internamente.
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0002-0002-0002-000000000002'); END $$; -- Terapeuta
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0003-0003-0003-000000000003'); END $$; -- Clínica 1
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0004-0004-0004-000000000004'); END $$; -- Clínica 2
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0005-0005-0005-000000000005'); END $$; -- Clínica 3
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0009-0009-0009-000000000009'); END $$; -- Terapeuta 2
DO $$ BEGIN PERFORM public.seed_determined_commitments('bbbbbbbb-0010-0010-0010-000000000010'); END $$; -- Terapeuta 3
-- ============================================================
-- 4. Confirma
-- ============================================================
DO $$
DECLARE
v_count integer;
BEGIN
SELECT count(*) INTO v_count FROM public.subscriptions WHERE source = 'seed';
RAISE NOTICE 'seed_013_subscriptions: % subscriptions ativas criadas.', v_count;
SELECT count(*) INTO v_count FROM public.determined_commitments WHERE is_native = true;
RAISE NOTICE 'seed_013_subscriptions: % determined_commitments nativos existentes.', v_count;
END;
$$;
COMMIT;

View File

@@ -0,0 +1,427 @@
-- =============================================================================
-- SEED 014 — Dados Globais (Email Templates, Notification Templates, Carousel)
-- =============================================================================
-- Idempotente: ON CONFLICT DO UPDATE / DO NOTHING
--
-- Cria:
-- 11 email_templates_global (templates de email do sistema)
-- 7 notification_templates WhatsApp (default)
-- 6 notification_templates SMS (default)
-- 3 login_carousel_slides (slides da página de login)
-- =============================================================================
BEGIN;
-- ============================================================
-- 1. Email Templates Globais
-- ============================================================
INSERT INTO public.email_templates_global
(key, domain, channel, subject, body_html, body_text, version, is_active, variables)
VALUES
-- session.reminder.email
(
'session.reminder.email',
'session',
'email',
'Lembrete: sua sessão amanhã às {{session_time}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Este é um lembrete da sua sessão agendada para <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong>.</p>
<p>Modalidade: <strong>{{session_modality}}</strong></p>
{{#if session_link}}
<p><a href="{{session_link}}">Clique aqui para entrar na sessão online</a></p>
{{/if}}
<p>Em caso de necessidade de cancelamento, entre em contato com antecedência.</p>
<p>Até logo,<br><strong>{{therapist_name}}</strong></p>
',
'Olá, {{patient_name}}! Lembrete da sua sessão: {{session_date}} às {{session_time}} ({{session_modality}}).',
2,
true,
'{"patient_name":"Nome completo do paciente","session_date":"Data da sessão (ex: 20/03/2026)","session_time":"Horário da sessão (ex: 14:00)","session_modality":"Presencial ou Online","session_link":"Link da videochamada (apenas online)","therapist_name":"Nome do terapeuta"}'
),
-- session.confirmation.email
(
'session.confirmation.email',
'session',
'email',
'Sessão confirmada — {{session_date}} às {{session_time}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua sessão foi confirmada com sucesso.</p>
<ul>
<li><strong>Data:</strong> {{session_date}}</li>
<li><strong>Horário:</strong> {{session_time}}</li>
<li><strong>Modalidade:</strong> {{session_modality}}</li>
{{#if session_address}}<li><strong>Local:</strong> {{session_address}}</li>{{/if}}
</ul>
<p>Até lá,<br><strong>{{therapist_name}}</strong></p>
',
'Sessão confirmada: {{session_date}} às {{session_time}} ({{session_modality}}).',
1,
true,
'{"patient_name":"Nome do paciente","session_date":"Data da sessão","session_time":"Horário da sessão","session_modality":"Presencial ou Online","session_address":"Endereço (apenas presencial)","therapist_name":"Nome do terapeuta"}'
),
-- session.cancellation.email
(
'session.cancellation.email',
'session',
'email',
'Sessão cancelada — {{session_date}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Informamos que sua sessão do dia <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong> foi cancelada.</p>
{{#if cancellation_reason}}<p>Motivo: {{cancellation_reason}}</p>{{/if}}
<p>Entre em contato para reagendar.</p>
<p><strong>{{therapist_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","session_date":"Data cancelada","session_time":"Horário cancelado","cancellation_reason":"Motivo do cancelamento (opcional)","therapist_name":"Nome do terapeuta"}'
),
-- session.rescheduled.email
(
'session.rescheduled.email',
'session',
'email',
'Sessão reagendada — {{session_date}} às {{session_time}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua sessão foi reagendada para <strong>{{session_date}}</strong> às <strong>{{session_time}}</strong>.</p>
<p>Modalidade: <strong>{{session_modality}}</strong></p>
<p>Até lá,<br><strong>{{therapist_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","session_date":"Nova data","session_time":"Novo horário","session_modality":"Presencial ou Online","therapist_name":"Nome do terapeuta"}'
),
-- intake.received.email
(
'intake.received.email',
'intake',
'email',
'Recebemos seu cadastro — {{clinic_name}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Recebemos seu cadastro com sucesso. Nossa equipe entrará em contato em breve para dar continuidade ao processo.</p>
<p>Obrigado pela confiança,<br><strong>{{clinic_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do solicitante","clinic_name":"Nome da clínica ou terapeuta"}'
),
-- intake.approved.email
(
'intake.approved.email',
'intake',
'email',
'Cadastro aprovado — bem-vindo(a)!',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Seu cadastro foi aprovado. Você já pode acessar o portal e agendar sua primeira sessão.</p>
<p><a href="{{portal_link}}">Acessar portal →</a></p>
<p>Qualquer dúvida, estamos à disposição.<br><strong>{{therapist_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","therapist_name":"Nome do terapeuta","portal_link":"Link do portal do paciente"}'
),
-- intake.rejected.email
(
'intake.rejected.email',
'intake',
'email',
'Atualização sobre seu cadastro — {{clinic_name}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Agradecemos seu interesse. Infelizmente não será possível dar continuidade ao seu cadastro no momento.</p>
{{#if rejection_reason}}<p>{{rejection_reason}}</p>{{/if}}
<p><strong>{{clinic_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","clinic_name":"Nome da clínica","rejection_reason":"Motivo (opcional)"}'
),
-- scheduler.request_accepted.email
(
'scheduler.request_accepted.email',
'session',
'email',
'Sua solicitação foi aceita — {{session_date}} às {{session_time}}',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Sua solicitação de agendamento foi aceita.</p>
<ul>
<li><strong>Data:</strong> {{session_date}}</li>
<li><strong>Horário:</strong> {{session_time}}</li>
<li><strong>Tipo:</strong> {{session_type}}</li>
<li><strong>Modalidade:</strong> {{session_modality}}</li>
</ul>
<p>Até logo,<br><strong>{{therapist_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","session_date":"Data confirmada","session_time":"Horário confirmado","session_type":"Primeira consulta / Retorno","session_modality":"Presencial ou Online","therapist_name":"Nome do terapeuta"}'
),
-- scheduler.request_rejected.email
(
'scheduler.request_rejected.email',
'session',
'email',
'Atualização sobre sua solicitação de agendamento',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Infelizmente não foi possível confirmar sua solicitação de agendamento para <strong>{{session_date}}</strong>.</p>
{{#if rejection_reason}}<p>Motivo: {{rejection_reason}}</p>{{/if}}
<p>Entre em contato para verificar outros horários disponíveis.</p>
<p><strong>{{therapist_name}}</strong></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","session_date":"Data solicitada","rejection_reason":"Motivo (opcional)","therapist_name":"Nome do terapeuta"}'
),
-- system.welcome.email
(
'system.welcome.email',
'system',
'email',
'Bem-vindo(a) ao {{clinic_name}}!',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Seja bem-vindo(a)! Sua conta foi criada com sucesso.</p>
<p><a href="{{portal_link}}">Acessar minha área →</a></p>
',
NULL,
1,
true,
'{"patient_name":"Nome do paciente","clinic_name":"Nome da clínica","portal_link":"Link do portal"}'
),
-- system.password_reset.email
(
'system.password_reset.email',
'system',
'email',
'Redefinição de senha',
'
<p>Olá, <strong>{{patient_name}}</strong>!</p>
<p>Recebemos uma solicitação para redefinir sua senha.</p>
<p><a href="{{reset_link}}">Clique aqui para redefinir sua senha →</a></p>
<p>Se você não solicitou a redefinição, ignore este e-mail.</p>
',
NULL,
1,
true,
'{"patient_name":"Nome do usuário","reset_link":"Link de redefinição de senha"}'
)
ON CONFLICT (key) DO UPDATE SET
domain = EXCLUDED.domain,
channel = EXCLUDED.channel,
subject = EXCLUDED.subject,
body_html = EXCLUDED.body_html,
body_text = EXCLUDED.body_text,
version = EXCLUDED.version,
is_active = EXCLUDED.is_active,
variables = EXCLUDED.variables,
updated_at = now();
-- ============================================================
-- 2. Notification Templates — WhatsApp (default)
-- ============================================================
INSERT INTO public.notification_templates
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES
-- Lembrete 24h
(
NULL, NULL,
'session.lembrete.whatsapp',
'session', 'whatsapp', 'lembrete_sessao',
E'Olá, {{nome_paciente}}! \U0001F44B\n\nLembrete da sua sessão com {{nome_terapeuta}}.\n\n\U0001F4C5 Data: {{data_sessao}}\n\U0001F550 Hora: {{hora_sessao}}\n\U0001F4CD Modalidade: {{modalidade}}\n\nQualquer dúvida, entre em contato. Até lá! \U0001F49A',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade"]',
true, true
),
-- Lembrete 2h
(
NULL, NULL,
'session.lembrete_2h.whatsapp',
'session', 'whatsapp', 'lembrete_sessao',
E'Olá, {{nome_paciente}}! Sua sessão com {{nome_terapeuta}} começa em 2 horas.\n\n\U0001F550 Hora: {{hora_sessao}}\n\U0001F4CD Modalidade: {{modalidade}}\n\nAté já! \U0001F60A',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade"]',
true, true
),
-- Confirmação
(
NULL, NULL,
'session.confirmacao.whatsapp',
'session', 'whatsapp', 'confirmacao_sessao',
E'Olá, {{nome_paciente}}! \u2705\n\nSua sessão foi confirmada!\n\n\U0001F4C5 Data: {{data_sessao}}\n\U0001F550 Hora: {{hora_sessao}}\n\U0001F4CD Modalidade: {{modalidade}}\n\nAté lá! \U0001F49A',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade","link_confirmacao"]',
true, true
),
-- Cancelamento
(
NULL, NULL,
'session.cancelamento.whatsapp',
'session', 'whatsapp', 'cancelamento_sessao',
E'Olá, {{nome_paciente}}. Infelizmente a sessão do dia {{data_sessao}} às {{hora_sessao}} foi cancelada.\n\nEntre em contato para reagendar. \U0001F64F',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade"]',
true, true
),
-- Reagendamento
(
NULL, NULL,
'session.reagendamento.whatsapp',
'session', 'whatsapp', 'reagendamento',
E'Olá, {{nome_paciente}}! Sua sessão foi reagendada.\n\n\U0001F4C5 Nova data: {{data_sessao}}\n\U0001F550 Novo horário: {{hora_sessao}}\n\U0001F4CD Modalidade: {{modalidade}}\n\nAté lá! \U0001F60A',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade"]',
true, true
),
-- Cobrança pendente
(
NULL, NULL,
'cobranca.pendente.whatsapp',
'billing', 'whatsapp', 'cobranca_pendente',
E'Olá, {{nome_paciente}}. Identificamos uma cobrança pendente no valor de {{valor_sessao}} referente à sua sessão do dia {{data_sessao}}.\n\nPor favor, entre em contato para regularizar. \U0001F64F',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade","valor_sessao"]',
true, true
),
-- Boas-vindas
(
NULL, NULL,
'sistema.boas_vindas.whatsapp',
'system', 'whatsapp', 'boas_vindas_paciente',
E'Olá, {{nome_paciente}}! \U0001F389\n\nSeja muito bem-vindo(a)! Estamos felizes em ter você aqui.\n\nEm caso de dúvidas, estamos à disposição. Até a nossa primeira sessão! \U0001F49A',
'["nome_paciente","nome_terapeuta","data_sessao","hora_sessao","modalidade"]',
true, true
)
ON CONFLICT (tenant_id, owner_id, key, deleted_at) DO NOTHING;
-- ============================================================
-- 3. Notification Templates — SMS (default)
-- ============================================================
INSERT INTO public.notification_templates
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES
(
NULL, NULL,
'session.reminder.sms',
'session', 'sms', 'lembrete_sessao',
'Olá {{patient_name}}, lembrete da sua sessão em {{session_date}} às {{session_time}} com {{therapist_name}}. Modalidade: {{session_modality}}.',
'["patient_name","session_date","session_time","therapist_name","session_modality"]',
true, true
),
(
NULL, NULL,
'session.confirmation.sms',
'session', 'sms', 'confirmacao_sessao',
'Sessão confirmada! {{session_date}} às {{session_time}} ({{session_modality}}) com {{therapist_name}}.',
'["patient_name","session_date","session_time","session_modality","therapist_name"]',
true, true
),
(
NULL, NULL,
'session.cancellation.sms',
'session', 'sms', 'cancelamento_sessao',
'Sua sessão de {{session_date}} às {{session_time}} foi cancelada. Entre em contato para reagendar. — {{therapist_name}}',
'["patient_name","session_date","session_time","therapist_name"]',
true, true
),
(
NULL, NULL,
'session.rescheduled.sms',
'session', 'sms', 'reagendamento',
'Sua sessão foi reagendada para {{session_date}} às {{session_time}} ({{session_modality}}). — {{therapist_name}}',
'["patient_name","session_date","session_time","session_modality","therapist_name"]',
true, true
),
(
NULL, NULL,
'intake.received.sms',
'intake', 'sms', 'intake_recebido',
'Olá {{patient_name}}, recebemos seu cadastro. Em breve entraremos em contato. — {{clinic_name}}',
'["patient_name","clinic_name"]',
true, true
),
(
NULL, NULL,
'intake.approved.sms',
'intake', 'sms', 'intake_aprovado',
'Olá {{patient_name}}, seu cadastro foi aprovado! Acesse o portal para agendar sua sessão. — {{therapist_name}}',
'["patient_name","therapist_name"]',
true, true
)
ON CONFLICT (tenant_id, owner_id, key, deleted_at) DO NOTHING;
-- ============================================================
-- 4. Login Carousel Slides
-- ============================================================
-- Limpa slides existentes e reinsere
DELETE FROM public.login_carousel_slides;
INSERT INTO public.login_carousel_slides (title, body, icon, ordem, ativo)
VALUES
(
'<strong>Gestão clínica simplificada</strong>',
'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: <em>seus pacientes</em>.',
'pi-calendar-clock',
0,
true
),
(
'<strong>Múltiplos profissionais, uma só plataforma</strong>',
'Adicione terapeutas, gerencie vínculos por clínica e mantenha equipes organizadas com controle de acesso por papel.',
'pi-users',
1,
true
),
(
'<strong>Seguro, privado e sempre disponível</strong>',
'Dados protegidos com autenticação robusta, controle de acesso por perfil e conformidade com as boas práticas de privacidade.',
'pi-shield',
2,
true
);
-- ============================================================
-- 5. Confirma
-- ============================================================
DO $$
DECLARE
v_emails integer;
v_notif integer;
v_slides integer;
BEGIN
SELECT count(*) INTO v_emails FROM public.email_templates_global;
SELECT count(*) INTO v_notif FROM public.notification_templates WHERE is_default = true;
SELECT count(*) INTO v_slides FROM public.login_carousel_slides WHERE ativo = true;
RAISE NOTICE 'seed_014_global_data: % email templates, % notification templates, % carousel slides.', v_emails, v_notif, v_slides;
END;
$$;
COMMIT;

View File

@@ -0,0 +1,230 @@
-- ==========================================================================
-- Agencia PSI — Seed: Templates globais de documentos
-- ==========================================================================
-- 4 templates padrao do sistema (is_global = true).
-- Disponiveis para todos os tenants como base para personalizacao.
-- ==========================================================================
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES
-- ────────────────────────────────────────────────────────────
-- 1. Declaração de Comparecimento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Declaração de Comparecimento',
'declaracao_comparecimento',
'Declara que o paciente compareceu à sessão na data e horário indicados.',
E'<h2 style="text-align:center; margin-bottom:30px;">DECLARAÇÃO DE COMPARECIMENTO</h2>\n\n<p>Declaro, para os devidos fins, que <strong>{{paciente_nome}}</strong>, portador(a) do CPF nº <strong>{{paciente_cpf}}</strong>, compareceu a esta clínica/consultório no dia <strong>{{data_sessao}}</strong>, no horário das <strong>{{hora_inicio}}</strong> às <strong>{{hora_fim}}</strong>, para atendimento psicológico.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_sessao','hora_inicio','hora_fim','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 2. Atestado Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Atestado Psicológico',
'atestado_psicologico',
'Atesta acompanhamento psicológico do paciente conforme Resolução CFP.',
E'<h2 style="text-align:center; margin-bottom:30px;">ATESTADO PSICOLÓGICO</h2>\n\n<p>Atesto, para os devidos fins, que <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, encontra-se em acompanhamento psicológico neste consultório/clínica.</p>\n\n<p>O presente atestado é emitido com base no atendimento realizado, em conformidade com o Código de Ética Profissional do Psicólogo e as Resoluções do Conselho Federal de Psicologia.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 3. Relatório de Acompanhamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Relatório de Acompanhamento',
'relatorio_acompanhamento',
'Modelo base para relatório de acompanhamento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">RELATÓRIO DE ACOMPANHAMENTO PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:180px;">Paciente:</td><td style="padding:4px 8px;">{{paciente_nome}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data de nascimento:</td><td style="padding:4px 8px;">{{paciente_data_nascimento}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">CPF:</td><td style="padding:4px 8px;">{{paciente_cpf}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data do relatório:</td><td style="padding:4px 8px;">{{data_atual}}</td></tr>\n</table>\n\n<h3>1. Demanda inicial</h3>\n<p>[Descreva a queixa ou demanda que motivou o início do acompanhamento.]</p>\n\n<h3>2. Procedimentos utilizados</h3>\n<p>[Descreva os métodos, técnicas e instrumentos utilizados.]</p>\n\n<h3>3. Evolução observada</h3>\n<p>[Descreva a evolução do paciente ao longo do acompanhamento.]</p>\n\n<h3>4. Considerações finais</h3>\n<p>[Conclusões e recomendações.]</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Art. 9º do Código de Ética do Psicólogo.\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','paciente_cpf','terapeuta_nome','terapeuta_crp','data_atual','data_atual_extenso','cidade_estado','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 4. Recibo de Pagamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Recibo de Pagamento',
'recibo_pagamento',
'Recibo para pagamento de sessão de atendimento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">RECIBO DE PAGAMENTO</h2>\n\n<p>Recebi de <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, a quantia de <strong>{{valor}}</strong> ({{valor_extenso}}), referente a sessão de atendimento psicológico realizada em <strong>{{data_sessao}}</strong>.</p>\n\n<p><strong>Forma de pagamento:</strong> {{forma_pagamento}}</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · CNPJ: {{clinica_cnpj}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','valor','valor_extenso','data_sessao','forma_pagamento','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_cnpj','clinica_telefone'],
true, true
)
,
-- ────────────────────────────────────────────────────────────
-- 5. Contrato de Prestação de Serviços Psicológicos
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Contrato de Prestação de Serviços',
'contrato_servicos',
'Contrato padrão entre profissional/clínica e paciente para prestação de serviços psicológicos.',
E'<h2 style="text-align:center; margin-bottom:30px;">CONTRATO DE PRESTAÇÃO DE SERVIÇOS PSICOLÓGICOS</h2>\n\n<p>Pelo presente instrumento particular, de um lado <strong>{{terapeuta_nome}}</strong>, Psicólogo(a), inscrito(a) no CRP sob o nº <strong>{{terapeuta_crp}}</strong>, CPF nº <strong>{{terapeuta_cpf}}</strong>, doravante denominado(a) <strong>CONTRATADO(A)</strong>, e de outro lado <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, doravante denominado(a) <strong>CONTRATANTE</strong>, têm entre si justo e contratado o seguinte:</p>\n\n<h3>CLÁUSULA 1ª — DO OBJETO</h3>\n<p>O presente contrato tem por objeto a prestação de serviços de atendimento psicológico clínico, na modalidade <strong>{{modalidade_atendimento}}</strong>, pelo(a) CONTRATADO(A) ao CONTRATANTE.</p>\n\n<h3>CLÁUSULA 2ª — DA PERIODICIDADE</h3>\n<p>As sessões ocorrerão com frequência <strong>{{frequencia_sessoes}}</strong>, com duração aproximada de <strong>{{duracao_sessao}}</strong> minutos, em dia e horário previamente acordados entre as partes.</p>\n\n<h3>CLÁUSULA 3ª — DOS HONORÁRIOS</h3>\n<p>O valor de cada sessão será de <strong>{{valor}}</strong> ({{valor_extenso}}), a ser pago <strong>{{forma_pagamento}}</strong>.</p>\n<p>Em caso de reajuste, o(a) CONTRATADO(A) comunicará o CONTRATANTE com antecedência mínima de 30 (trinta) dias.</p>\n\n<h3>CLÁUSULA 4ª — DO CANCELAMENTO E FALTAS</h3>\n<p>O cancelamento ou remarcação de sessões deverá ser comunicado com antecedência mínima de <strong>24 (vinte e quatro) horas</strong>. Faltas sem aviso prévio serão cobradas integralmente.</p>\n\n<h3>CLÁUSULA 5ª — DO SIGILO</h3>\n<p>O(A) CONTRATADO(A) compromete-se a manter sigilo absoluto sobre todas as informações obtidas durante o atendimento, conforme o Código de Ética Profissional do Psicólogo (Resolução CFP nº 010/2005) e a Lei Geral de Proteção de Dados (Lei nº 13.709/2018).</p>\n\n<h3>CLÁUSULA 6ª — DA RESCISÃO</h3>\n<p>O presente contrato poderá ser rescindido por qualquer das partes, a qualquer tempo, mediante comunicação prévia, sem ônus adicionais além das sessões já realizadas.</p>\n\n<h3>CLÁUSULA 7ª — DO FORO</h3>\n<p>Para dirimir quaisquer controvérsias oriundas deste contrato, as partes elegem o foro da Comarca de <strong>{{cidade_estado}}</strong>.</p>\n\n<p style="margin-top:30px;">E por estarem assim justas e contratadas, as partes assinam o presente instrumento em 2 (duas) vias de igual teor e forma.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · CNPJ: {{clinica_cnpj}} · {{clinica_endereco}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_cpf','modalidade_atendimento','frequencia_sessoes','duracao_sessao','valor','valor_extenso','forma_pagamento','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco','clinica_telefone','clinica_cnpj'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 6. Termo de Consentimento Livre e Esclarecido (TCLE)
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento Livre e Esclarecido',
'tcle',
'TCLE para início de acompanhamento psicológico, conforme exigências éticas do CFP.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO LIVRE E ESCLARECIDO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro que fui devidamente informado(a) e esclarecido(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, sobre os seguintes pontos:</p>\n\n<h3>1. Natureza do serviço</h3>\n<p>O atendimento psicológico consiste em sessões de psicoterapia, com abordagem <strong>{{abordagem_terapeutica}}</strong>, visando o acolhimento e o tratamento das demandas apresentadas.</p>\n\n<h3>2. Sigilo profissional</h3>\n<p>Todas as informações compartilhadas durante as sessões são estritamente confidenciais, conforme o Código de Ética Profissional do Psicólogo (Resolução CFP nº 010/2005), podendo ser quebrado apenas nas hipóteses previstas em lei.</p>\n\n<h3>3. Registro de informações</h3>\n<p>O(A) profissional poderá realizar registros das sessões (prontuário psicológico) para fins de acompanhamento clínico, mantidos em sigilo conforme a LGPD (Lei nº 13.709/2018) e as normas do CFP.</p>\n\n<h3>4. Direitos do paciente</h3>\n<ul>\n <li>Receber informações sobre o processo terapêutico;</li>\n <li>Interromper o atendimento a qualquer momento;</li>\n <li>Solicitar encaminhamento a outro profissional;</li>\n <li>Ter acesso às informações registradas em seu prontuário.</li>\n</ul>\n\n<h3>5. Limitações</h3>\n<p>O acompanhamento psicológico não substitui tratamento médico ou psiquiátrico quando necessário. O(A) profissional poderá sugerir encaminhamentos complementares.</p>\n\n<p style="margin-top:30px;">Declaro que li e compreendi as informações acima e <strong>consinto livremente</strong> com o início do acompanhamento psicológico.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este termo é regido pela Resolução CFP nº 010/2005 e pela Lei nº 13.709/2018 (LGPD).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','abordagem_terapeutica','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 7. Encaminhamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Encaminhamento',
'encaminhamento',
'Carta de encaminhamento do paciente para outro profissional ou serviço.',
E'<h2 style="text-align:center; margin-bottom:30px;">ENCAMINHAMENTO</h2>\n\n<p>Ao(À) profissional/serviço de <strong>{{especialidade_destino}}</strong>,</p>\n\n<p style="margin-top:20px;">Encaminho o(a) paciente <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, para avaliação e acompanhamento em <strong>{{especialidade_destino}}</strong>.</p>\n\n<p><strong>Motivo do encaminhamento:</strong></p>\n<p>{{motivo_encaminhamento}}</p>\n\n<p><strong>Informações relevantes:</strong></p>\n<p>{{informacoes_relevantes}}</p>\n\n<p style="margin-top:20px;">Coloco-me à disposição para troca de informações que se façam necessárias ao melhor atendimento do(a) paciente, respeitadas as normas de sigilo profissional.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}<br/>\n <span style="font-size:10pt; color:#666;">{{terapeuta_email}} · {{terapeuta_telefone}}</span>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','especialidade_destino','motivo_encaminhamento','informacoes_relevantes','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 8. Autorização para Atendimento de Menor
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Autorização para Atendimento de Menor',
'autorizacao_menor',
'Termo de autorização dos responsáveis legais para atendimento psicológico de criança ou adolescente.',
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA ATENDIMENTO PSICOLÓGICO DE MENOR</h2>\n\n<p>Eu, <strong>{{responsavel_nome}}</strong>, CPF nº <strong>{{responsavel_cpf}}</strong>, na qualidade de <strong>{{grau_parentesco}}</strong> e responsável legal do(a) menor <strong>{{paciente_nome}}</strong>, nascido(a) em <strong>{{paciente_data_nascimento}}</strong>, <strong>AUTORIZO</strong> a realização de atendimento psicológico pelo(a) profissional abaixo identificado(a).</p>\n\n<h3>Profissional responsável</h3>\n<p><strong>{{terapeuta_nome}}</strong><br/>\nPsicólogo(a) — CRP {{terapeuta_crp}}</p>\n\n<h3>Declarações</h3>\n<p>Declaro estar ciente de que:</p>\n<ul>\n <li>O atendimento seguirá as normas do Código de Ética Profissional do Psicólogo e do Estatuto da Criança e do Adolescente (ECA);</li>\n <li>As informações do atendimento são sigilosas, sendo compartilhadas com o responsável apenas o necessário para o bem-estar do(a) menor, conforme julgamento técnico do(a) profissional;</li>\n <li>Posso solicitar informações sobre a evolução do tratamento a qualquer momento;</li>\n <li>Posso revogar esta autorização a qualquer tempo, mediante comunicação por escrito.</li>\n</ul>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{responsavel_nome}}</strong><br/>\n Responsável legal<br/>\n CPF: {{responsavel_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','responsavel_nome','responsavel_cpf','grau_parentesco','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 9. Laudo Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Laudo Psicológico',
'laudo_psicologico',
'Modelo de laudo psicológico conforme Resolução CFP nº 06/2019.',
E'<h2 style="text-align:center; margin-bottom:30px;">LAUDO PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:200px;">Solicitante:</td><td style="padding:4px 8px;">{{solicitante}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Finalidade:</td><td style="padding:4px 8px;">{{finalidade}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Avaliado(a):</td><td style="padding:4px 8px;">{{paciente_nome}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data de nascimento:</td><td style="padding:4px 8px;">{{paciente_data_nascimento}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">CPF:</td><td style="padding:4px 8px;">{{paciente_cpf}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Data do laudo:</td><td style="padding:4px 8px;">{{data_atual}}</td></tr>\n</table>\n\n<h3>1. Descrição da demanda</h3>\n<p>[Apresente a demanda e o motivo da avaliação, conforme solicitação recebida.]</p>\n\n<h3>2. Procedimentos</h3>\n<p>[Descreva os recursos e instrumentos técnicos utilizados na avaliação: entrevistas, testes psicológicos (com nome, autor e parecer favorável do SATEPSI quando aplicável), observação, etc.]</p>\n\n<h3>3. Análise</h3>\n<p>[Apresente de forma clara e fundamentada os dados obtidos, integrando os resultados dos procedimentos realizados à luz da literatura científica da Psicologia. Não inclua informações que não tenham relação com a demanda.]</p>\n\n<h3>4. Conclusão</h3>\n<p>[Apresente o resultado da avaliação, indicando a resposta à demanda inicial. A conclusão deve ser coerente com a análise e os procedimentos utilizados.]</p>\n\n<p style="margin-top:40px; font-size:10pt; color:#666;"><em>Este laudo foi elaborado em conformidade com a Resolução CFP nº 06/2019, que institui regras para a elaboração de documentos escritos produzidos pelo psicólogo no exercício profissional.</em></p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Art. 9º do Código de Ética do Psicólogo e Resolução CFP nº 06/2019.\n</div>',
ARRAY['paciente_nome','paciente_data_nascimento','paciente_cpf','solicitante','finalidade','terapeuta_nome','terapeuta_crp','data_atual','data_atual_extenso','cidade_estado','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 10. Parecer Psicológico
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Parecer Psicológico',
'parecer_psicologico',
'Manifestação técnica sobre questão específica no campo da Psicologia, conforme Resolução CFP nº 06/2019.',
E'<h2 style="text-align:center; margin-bottom:30px;">PARECER PSICOLÓGICO</h2>\n\n<table style="width:100%; border-collapse:collapse; margin-bottom:20px;">\n <tr><td style="padding:4px 8px; font-weight:bold; width:200px;">Parecer nº:</td><td style="padding:4px 8px;">{{numero_parecer}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Solicitante:</td><td style="padding:4px 8px;">{{solicitante}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Assunto:</td><td style="padding:4px 8px;">{{assunto}}</td></tr>\n <tr><td style="padding:4px 8px; font-weight:bold;">Profissional:</td><td style="padding:4px 8px;">{{terapeuta_nome}} — CRP {{terapeuta_crp}}</td></tr>\n</table>\n\n<h3>1. Exposição de motivos</h3>\n<p>[Descreva a questão ou consulta que originou o pedido de parecer, indicando quem solicitou e com qual finalidade.]</p>\n\n<h3>2. Análise fundamentada</h3>\n<p>[Apresente a análise técnica, com base em referencial teórico-científico da Psicologia, normas do CFP e legislação pertinente. O parecer deve se restringir ao campo de conhecimento do psicólogo.]</p>\n\n<h3>3. Conclusão</h3>\n<p>[Apresente a resposta técnica à questão formulada, de forma objetiva e fundamentada.]</p>\n\n<p style="margin-top:30px; font-size:10pt; color:#666;"><em>Parecer elaborado em conformidade com a Resolução CFP nº 06/2019.</em></p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Este documento é de caráter sigiloso, conforme Resolução CFP nº 06/2019.\n</div>',
ARRAY['numero_parecer','solicitante','assunto','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 11. Termo de Sigilo e Confidencialidade
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Sigilo e Confidencialidade',
'termo_sigilo',
'Termo reforçando o compromisso de sigilo entre profissional e paciente.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE SIGILO E CONFIDENCIALIDADE</h2>\n\n<p>Eu, <strong>{{terapeuta_nome}}</strong>, Psicólogo(a), CRP <strong>{{terapeuta_crp}}</strong>, declaro ao(à) paciente <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, que:</p>\n\n<ol style="line-height:2;">\n <li>Todas as informações verbais, escritas ou de qualquer natureza, obtidas durante o processo terapêutico, serão mantidas em <strong>sigilo absoluto</strong>;</li>\n <li>O prontuário psicológico é de acesso exclusivo do profissional e do paciente, nos termos da Resolução CFP nº 001/2009;</li>\n <li>Os dados pessoais serão tratados conforme a Lei Geral de Proteção de Dados (Lei nº 13.709/2018), sendo utilizados exclusivamente para fins de acompanhamento clínico;</li>\n <li>A quebra de sigilo somente ocorrerá nas hipóteses previstas no Código de Ética do Psicólogo:\n <ul>\n <li>Situações de risco à vida do paciente ou de terceiros;</li>\n <li>Determinação judicial;</li>\n <li>Atendimento de menor — informações necessárias aos responsáveis;</li>\n </ul>\n </li>\n <li>Dados anonimizados poderão ser utilizados para fins de estudo ou pesquisa, mediante consentimento prévio específico.</li>\n</ol>\n\n<p style="margin-top:30px;">Este termo entra em vigor na data de sua assinatura e permanece válido mesmo após o encerramento do acompanhamento.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pelo Código de Ética Profissional do Psicólogo e pela LGPD (Lei nº 13.709/2018).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 12. Declaração de Início de Tratamento
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Declaração de Início de Tratamento',
'declaracao_inicio_tratamento',
'Declara que o paciente iniciou acompanhamento psicológico a partir de determinada data.',
E'<h2 style="text-align:center; margin-bottom:30px;">DECLARAÇÃO DE INÍCIO DE TRATAMENTO</h2>\n\n<p>Declaro, para os devidos fins, que <strong>{{paciente_nome}}</strong>, portador(a) do CPF nº <strong>{{paciente_cpf}}</strong>, encontra-se em acompanhamento psicológico neste consultório/clínica desde <strong>{{data_inicio_tratamento}}</strong>, com frequência <strong>{{frequencia_sessoes}}</strong>.</p>\n\n<p>O presente documento é expedido a pedido do(a) interessado(a) e não contém informações de caráter diagnóstico ou sigiloso.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div class="signature-line" style="margin-top:80px; text-align:center;">\n <hr style="width:300px; margin:0 auto 8px; border:none; border-top:1px solid #333;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}} · {{clinica_telefone}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_inicio_tratamento','frequencia_sessoes','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 13. Termo de Alta Terapêutica
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Alta Terapêutica',
'termo_alta',
'Documento formalizando o encerramento do acompanhamento psicológico.',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE ALTA TERAPÊUTICA</h2>\n\n<p>Comunico que o(a) paciente <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, que esteve em acompanhamento psicológico desde <strong>{{data_inicio_tratamento}}</strong>, recebe <strong>alta terapêutica</strong> nesta data.</p>\n\n<h3>Motivo da alta</h3>\n<p>{{motivo_alta}}</p>\n\n<h3>Orientações</h3>\n<p>{{orientacoes_pos_alta}}</p>\n\n<p style="margin-top:20px;">O(A) paciente foi informado(a) de que poderá retornar ao acompanhamento a qualquer momento, caso considere necessário.</p>\n\n<p style="margin-top:40px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Ciente\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n {{clinica_nome}} · {{clinica_endereco}} · {{clinica_telefone}}\n</div>',
ARRAY['paciente_nome','paciente_cpf','data_inicio_tratamento','motivo_alta','orientacoes_pos_alta','cidade_estado','data_atual_extenso','terapeuta_nome','terapeuta_crp','clinica_nome','clinica_endereco','clinica_telefone'],
true, true
),
-- ────────────────────────────────────────────────────────────
-- 14. Termo de Consentimento para Atendimento Online
-- ────────────────────────────────────────────────────────────
(
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento para Atendimento Online',
'tcle_online',
'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018).',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA ATENDIMENTO PSICOLÓGICO ONLINE</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro que fui informado(a) e concordo com a realização de atendimento psicológico na modalidade <strong>online</strong>, por meio de Tecnologias da Informação e Comunicação (TICs), conforme a Resolução CFP nº 11/2018.</p>\n\n<h3>1. Plataforma utilizada</h3>\n<p>As sessões serão realizadas por meio de <strong>{{plataforma_online}}</strong>, devendo ambas as partes garantir ambiente adequado, com privacidade e conexão estável.</p>\n\n<h3>2. Condições do atendimento</h3>\n<ul>\n <li>O(A) profissional está cadastrado(a) no e-Psi (plataforma do CFP) para prestação de serviços psicológicos online;</li>\n <li>Todas as regras de sigilo profissional aplicam-se igualmente ao atendimento online;</li>\n <li>A plataforma utilizada oferece criptografia e segurança dos dados;</li>\n <li>Não é permitida a gravação das sessões por nenhuma das partes, salvo acordo prévio por escrito.</li>\n</ul>\n\n<h3>3. Situações excepcionais</h3>\n<p>Em caso de instabilidade técnica que comprometa a sessão, o(a) profissional e o(a) paciente acordarão a melhor forma de dar continuidade (reagendar, trocar de plataforma, ou realizar sessão presencial).</p>\n\n<h3>4. Limitações</h3>\n<p>O(A) profissional reserva-se o direito de indicar atendimento presencial quando avaliar que a modalidade online não é adequada ao caso clínico.</p>\n\n<p style="margin-top:30px;">Declaro que compreendi as condições acima e <strong>consinto</strong> com a realização das sessões na modalidade online.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Atendimento online regulamentado pela Resolução CFP nº 11/2018. Profissional cadastrado(a) no e-Psi.\n</div>',
ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
)
ON CONFLICT DO NOTHING;
-- ==========================================================================
-- FIM DO SEED — 14 templates globais
-- ==========================================================================

View File

@@ -0,0 +1,929 @@
-- ============================================================
-- SEED DE TESTE — AgenciaPsi
-- ============================================================
-- Cria 10 terapeutas com pacientes, agendamentos, sessões,
-- cancelamentos, remarcações, faltas, grupos, financeiro, etc.
--
-- IMPORTANTE:
-- 1) Os usuários (auth.users) devem ser criados via Supabase Auth
-- ou via auth.users INSERT direto (requer service_role).
-- Este script assume que os 10 users já existem no auth.users.
--
-- 2) Execute este script com a service_role key no SQL Editor
-- do Supabase (ou via psql com permissão).
--
-- 3) Todos os UUIDs são fixos e determinísticos para facilitar
-- depuração. Prefixo:
-- Terapeutas: aaaaaaaa-0001..0010
-- Tenants: bbbbbbbb-0001..0010
-- Pacientes: cccccccc-0001..0050
-- Eventos: dddddddd-0001..0200
-- Financeiro: eeeeeeee-0001..0200
-- Grupos: ffffffff-0001..0020
-- Recorrência: 11111111-0001..0010
--
-- ============================================================
-- ────────────────────────────────────────────────────────────
-- 0. LIMPAR DADOS DE TESTE (idempotente)
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_tenant_ids uuid[] := ARRAY[
'bbbbbbbb-0000-0000-0000-000000000001'::uuid,
'bbbbbbbb-0000-0000-0000-000000000002'::uuid,
'bbbbbbbb-0000-0000-0000-000000000003'::uuid,
'bbbbbbbb-0000-0000-0000-000000000004'::uuid,
'bbbbbbbb-0000-0000-0000-000000000005'::uuid,
'bbbbbbbb-0000-0000-0000-000000000006'::uuid,
'bbbbbbbb-0000-0000-0000-000000000007'::uuid,
'bbbbbbbb-0000-0000-0000-000000000008'::uuid,
'bbbbbbbb-0000-0000-0000-000000000009'::uuid,
'bbbbbbbb-0000-0000-0000-000000000010'::uuid
];
BEGIN
-- Financeiro
DELETE FROM public.financial_records WHERE tenant_id = ANY(_tenant_ids);
-- Eventos
DELETE FROM public.agenda_eventos WHERE tenant_id = ANY(_tenant_ids);
-- Recorrências
DELETE FROM public.recurrence_rules WHERE tenant_id = ANY(_tenant_ids);
-- Grupos
DELETE FROM public.patient_group_patient WHERE tenant_id = ANY(_tenant_ids);
DELETE FROM public.patient_groups WHERE tenant_id = ANY(_tenant_ids);
-- Pacientes
DELETE FROM public.patients WHERE tenant_id = ANY(_tenant_ids);
-- Notificações
DELETE FROM public.notification_channels WHERE tenant_id = ANY(_tenant_ids);
DELETE FROM public.notification_templates WHERE tenant_id = ANY(_tenant_ids);
DELETE FROM public.notification_logs WHERE tenant_id = ANY(_tenant_ids);
DELETE FROM public.notification_schedules WHERE tenant_id = ANY(_tenant_ids);
-- Regras semanais
DELETE FROM public.agenda_regras_semanais WHERE tenant_id = ANY(_tenant_ids);
-- Serviços
DELETE FROM public.services WHERE tenant_id = ANY(_tenant_ids);
-- Tenant members
DELETE FROM public.tenant_members WHERE tenant_id = ANY(_tenant_ids);
-- Company profiles
DELETE FROM public.company_profiles WHERE tenant_id = ANY(_tenant_ids);
-- Tenants
DELETE FROM public.tenants WHERE id = ANY(_tenant_ids);
-- Profiles
DELETE FROM public.profiles WHERE id IN (
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'aaaaaaaa-0000-0000-0000-000000000002'::uuid,
'aaaaaaaa-0000-0000-0000-000000000003'::uuid,
'aaaaaaaa-0000-0000-0000-000000000004'::uuid,
'aaaaaaaa-0000-0000-0000-000000000005'::uuid,
'aaaaaaaa-0000-0000-0000-000000000006'::uuid,
'aaaaaaaa-0000-0000-0000-000000000007'::uuid,
'aaaaaaaa-0000-0000-0000-000000000008'::uuid,
'aaaaaaaa-0000-0000-0000-000000000009'::uuid,
'aaaaaaaa-0000-0000-0000-000000000010'::uuid
);
-- Auth users (requer service_role)
DELETE FROM auth.users WHERE id IN (
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'aaaaaaaa-0000-0000-0000-000000000002'::uuid,
'aaaaaaaa-0000-0000-0000-000000000003'::uuid,
'aaaaaaaa-0000-0000-0000-000000000004'::uuid,
'aaaaaaaa-0000-0000-0000-000000000005'::uuid,
'aaaaaaaa-0000-0000-0000-000000000006'::uuid,
'aaaaaaaa-0000-0000-0000-000000000007'::uuid,
'aaaaaaaa-0000-0000-0000-000000000008'::uuid,
'aaaaaaaa-0000-0000-0000-000000000009'::uuid,
'aaaaaaaa-0000-0000-0000-000000000010'::uuid
);
END $$;
-- ────────────────────────────────────────────────────────────
-- 1. USUÁRIOS (auth.users) — 10 terapeutas
-- ────────────────────────────────────────────────────────────
-- Desabilitar triggers que chamam funções inexistentes
ALTER TABLE auth.users DISABLE TRIGGER ALL;
-- Senha: Test@1234 (hash bcrypt)
INSERT INTO auth.users (
id, instance_id, email, encrypted_password,
email_confirmed_at, aud, role,
raw_app_meta_data, raw_user_meta_data,
created_at, updated_at
) VALUES
('aaaaaaaa-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000000', 'terapeuta01@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dra. Ana Beatriz"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000000', 'terapeuta02@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dr. Carlos Eduardo"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000000', 'terapeuta03@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dra. Fernanda Lima"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000000', 'terapeuta04@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dr. Gabriel Santos"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000000', 'terapeuta05@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dra. Juliana Alves"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000006', '00000000-0000-0000-0000-000000000000', 'terapeuta06@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dr. Lucas Oliveira"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000000', 'terapeuta07@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dra. Mariana Costa"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000008', '00000000-0000-0000-0000-000000000000', 'terapeuta08@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dr. Pedro Henrique"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000009', '00000000-0000-0000-0000-000000000000', 'terapeuta09@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dra. Renata Souza"}', now(), now()),
('aaaaaaaa-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000000', 'terapeuta10@teste.agenciapsi.com', '$2a$10$PwGnTf1HMjHGZSM0YDi6ku7U7ZZZcXOiI4XOoFSqLJ3szGBTniCeG', now(), 'authenticated', 'authenticated', '{"provider":"email","providers":["email"]}', '{"full_name":"Dr. Thiago Nunes"}', now(), now())
ON CONFLICT (id) DO NOTHING;
-- Identities (necessárias para login via email)
INSERT INTO auth.identities (id, user_id, identity_data, provider, provider_id, last_sign_in_at, created_at, updated_at)
SELECT id, id, jsonb_build_object('sub', id::text, 'email', email), 'email', id::text, now(), now(), now()
FROM auth.users WHERE id IN (
'aaaaaaaa-0000-0000-0000-000000000001','aaaaaaaa-0000-0000-0000-000000000002',
'aaaaaaaa-0000-0000-0000-000000000003','aaaaaaaa-0000-0000-0000-000000000004',
'aaaaaaaa-0000-0000-0000-000000000005','aaaaaaaa-0000-0000-0000-000000000006',
'aaaaaaaa-0000-0000-0000-000000000007','aaaaaaaa-0000-0000-0000-000000000008',
'aaaaaaaa-0000-0000-0000-000000000009','aaaaaaaa-0000-0000-0000-000000000010'
)
ON CONFLICT DO NOTHING;
-- Reabilitar triggers
ALTER TABLE auth.users ENABLE TRIGGER ALL;
-- ────────────────────────────────────────────────────────────
-- 2. PROFILES
-- ────────────────────────────────────────────────────────────
INSERT INTO public.profiles (id, full_name, role, phone, bio, account_type) VALUES
('aaaaaaaa-0000-0000-0000-000000000001', 'Dra. Ana Beatriz', 'tenant_member', '(11) 91234-0001', 'Psicóloga clínica — TCC e EMDR', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000002', 'Dr. Carlos Eduardo', 'tenant_member', '(11) 91234-0002', 'Psicólogo — Psicanálise', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000003', 'Dra. Fernanda Lima', 'tenant_member', '(16) 91234-0003', 'Psicóloga infantil — Ludoterapia', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000004', 'Dr. Gabriel Santos', 'tenant_member', '(16) 91234-0004', 'Psicólogo — Gestalt-terapia', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000005', 'Dra. Juliana Alves', 'tenant_member', '(21) 91234-0005', 'Psicóloga — Neuropsicologia', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000006', 'Dr. Lucas Oliveira', 'tenant_member', '(21) 91234-0006', 'Psicólogo — Terapia Sistêmica', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000007', 'Dra. Mariana Costa', 'tenant_member', '(31) 91234-0007', 'Psicóloga — Humanismo', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000008', 'Dr. Pedro Henrique', 'tenant_member', '(31) 91234-0008', 'Psicólogo — Análise do Comportamento', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000009', 'Dra. Renata Souza', 'tenant_member', '(41) 91234-0009', 'Psicóloga — Psicossomática', 'therapist'),
('aaaaaaaa-0000-0000-0000-000000000010', 'Dr. Thiago Nunes', 'tenant_member', '(41) 91234-0010', 'Psicólogo — Terapia Cognitiva', 'therapist')
ON CONFLICT (id) DO UPDATE SET full_name = EXCLUDED.full_name, role = EXCLUDED.role;
-- ────────────────────────────────────────────────────────────
-- 3. TENANTS + MEMBERS
-- ────────────────────────────────────────────────────────────
INSERT INTO public.tenants (id, name, kind) VALUES
('bbbbbbbb-0000-0000-0000-000000000001', 'Consultório Dra. Ana Beatriz', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000002', 'Consultório Dr. Carlos Eduardo', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000003', 'Consultório Dra. Fernanda Lima', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000004', 'Consultório Dr. Gabriel Santos', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000005', 'Consultório Dra. Juliana Alves', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000006', 'Consultório Dr. Lucas Oliveira', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000007', 'Consultório Dra. Mariana Costa', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000008', 'Consultório Dr. Pedro Henrique', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000009', 'Consultório Dra. Renata Souza', 'therapist'),
('bbbbbbbb-0000-0000-0000-000000000010', 'Consultório Dr. Thiago Nunes', 'therapist')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.tenant_members (tenant_id, user_id, role, status)
SELECT
('bbbbbbbb-0000-0000-0000-00000000000' || i)::uuid,
('aaaaaaaa-0000-0000-0000-00000000000' || i)::uuid,
'tenant_admin', 'active'
FROM generate_series(1, 9) AS s(i)
UNION ALL
SELECT
'bbbbbbbb-0000-0000-0000-000000000010'::uuid,
'aaaaaaaa-0000-0000-0000-000000000010'::uuid,
'tenant_admin', 'active'
ON CONFLICT DO NOTHING;
-- ────────────────────────────────────────────────────────────
-- 4. SERVIÇOS (1 serviço padrão por terapeuta)
-- ────────────────────────────────────────────────────────────
INSERT INTO public.services (id, owner_id, tenant_id, name, price, duration_min, active)
SELECT
('99999999-0000-0000-0000-00000000000' || i)::uuid,
('aaaaaaaa-0000-0000-0000-00000000000' || i)::uuid,
('bbbbbbbb-0000-0000-0000-00000000000' || i)::uuid,
'Sessão Individual', (150 + i * 10)::numeric, 50, true
FROM generate_series(1, 9) AS s(i)
UNION ALL
SELECT
'99999999-0000-0000-0000-000000000010'::uuid,
'aaaaaaaa-0000-0000-0000-000000000010'::uuid,
'bbbbbbbb-0000-0000-0000-000000000010'::uuid,
'Sessão Individual', 250::numeric, 50, true
ON CONFLICT DO NOTHING;
-- ────────────────────────────────────────────────────────────
-- 5. PACIENTES — 5 por terapeuta = 50 pacientes
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_nomes text[] := ARRAY[
'Maria Silva','João Pereira','Camila Rocha','Rafael Dias','Larissa Melo',
'Bruno Costa','Amanda Ferreira','Felipe Souza','Isabela Nunes','Diego Almeida',
'Patrícia Lima','Rodrigo Santos','Letícia Araujo','Gustavo Ribeiro','Bianca Carvalho',
'Matheus Gomes','Natália Barbosa','André Vieira','Carolina Martins','Lucas Teixeira',
'Aline Pinto','Henrique Moura','Vanessa Cardoso','Marcos Fernandes','Tatiana Lopes',
'Thiago Nascimento','Juliana Duarte','Eduardo Correia','Fernanda Monteiro','Paulo Campos',
'Simone Reis','Ricardo Azevedo','Debora Mendes','Alexandre Freitas','Sandra Oliveira',
'Daniel Cunha','Priscila Moreira','Roberto Andrade','Cláudia Batista','Leandro Rezende',
'Adriana Sousa','Vinicius Castro','Luciana Pereira','Fabio Miranda','Elaine Barros',
'Renato Faria','Cristina Machado','Marcelo Prado','Gabriela Assis','Sergio Nogueira'
];
_phones text[] := ARRAY[
'(11) 98765-1001','(11) 98765-1002','(11) 98765-1003','(11) 98765-1004','(11) 98765-1005',
'(11) 98765-1006','(11) 98765-1007','(11) 98765-1008','(11) 98765-1009','(11) 98765-1010',
'(16) 98765-1011','(16) 98765-1012','(16) 98765-1013','(16) 98765-1014','(16) 98765-1015',
'(16) 98765-1016','(16) 98765-1017','(16) 98765-1018','(16) 98765-1019','(16) 98765-1020',
'(21) 98765-1021','(21) 98765-1022','(21) 98765-1023','(21) 98765-1024','(21) 98765-1025',
'(21) 98765-1026','(21) 98765-1027','(21) 98765-1028','(21) 98765-1029','(21) 98765-1030',
'(31) 98765-1031','(31) 98765-1032','(31) 98765-1033','(31) 98765-1034','(31) 98765-1035',
'(31) 98765-1036','(31) 98765-1037','(31) 98765-1038','(31) 98765-1039','(31) 98765-1040',
'(41) 98765-1041','(41) 98765-1042','(41) 98765-1043','(41) 98765-1044','(41) 98765-1045',
'(41) 98765-1046','(41) 98765-1047','(41) 98765-1048','(41) 98765-1049','(41) 98765-1050'
];
_statuses text[] := ARRAY['Ativo','Ativo','Ativo','Ativo','Inativo'];
_t int;
_p int;
_idx int;
_pid uuid;
_oid uuid;
_tid uuid;
_mid uuid;
BEGIN
FOR _t IN 1..10 LOOP
IF _t < 10 THEN
_oid := ('aaaaaaaa-0000-0000-0000-00000000000' || _t)::uuid;
_tid := ('bbbbbbbb-0000-0000-0000-00000000000' || _t)::uuid;
ELSE
_oid := 'aaaaaaaa-0000-0000-0000-000000000010'::uuid;
_tid := 'bbbbbbbb-0000-0000-0000-000000000010'::uuid;
END IF;
-- Buscar o id do tenant_member para este user/tenant
SELECT id INTO _mid FROM public.tenant_members
WHERE tenant_id = _tid AND user_id = _oid LIMIT 1;
FOR _p IN 1..5 LOOP
_idx := (_t - 1) * 5 + _p;
_pid := ('cccccccc-0000-0000-0000-' || lpad(_idx::text, 12, '0'))::uuid;
INSERT INTO public.patients (
id, nome_completo, email_principal, telefone,
status, owner_id, tenant_id, responsible_member_id,
data_nascimento, genero, cidade, estado
) VALUES (
_pid,
_nomes[_idx],
'paciente' || _idx || '@teste.agenciapsi.com',
_phones[_idx],
_statuses[_p],
_oid, _tid, _mid,
'1985-01-01'::date + (_idx * 120 || ' days')::interval,
CASE WHEN _idx % 2 = 0 THEN 'Feminino' ELSE 'Masculino' END,
CASE
WHEN _t <= 2 THEN 'são Paulo'
WHEN _t <= 4 THEN 'são Carlos'
WHEN _t <= 6 THEN 'Rio de Janeiro'
WHEN _t <= 8 THEN 'Belo Horizonte'
ELSE 'Curitiba'
END,
CASE
WHEN _t <= 2 THEN 'SP'
WHEN _t <= 4 THEN 'SP'
WHEN _t <= 6 THEN 'RJ'
WHEN _t <= 8 THEN 'MG'
ELSE 'PR'
END
) ON CONFLICT (id) DO NOTHING;
END LOOP;
END LOOP;
END $$;
-- ────────────────────────────────────────────────────────────
-- 6. GRUPOS DE PACIENTES — 2 por terapeuta
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_t int;
_oid uuid;
_tid uuid;
_g1 uuid;
_g2 uuid;
BEGIN
FOR _t IN 1..10 LOOP
IF _t < 10 THEN
_oid := ('aaaaaaaa-0000-0000-0000-00000000000' || _t)::uuid;
_tid := ('bbbbbbbb-0000-0000-0000-00000000000' || _t)::uuid;
ELSE
_oid := 'aaaaaaaa-0000-0000-0000-000000000010'::uuid;
_tid := 'bbbbbbbb-0000-0000-0000-000000000010'::uuid;
END IF;
_g1 := ('ffffffff-0000-0000-0000-' || lpad(((_t-1)*2+1)::text, 12, '0'))::uuid;
_g2 := ('ffffffff-0000-0000-0000-' || lpad(((_t-1)*2+2)::text, 12, '0'))::uuid;
INSERT INTO public.patient_groups (id, owner_id, tenant_id, nome, cor, is_active)
VALUES
(_g1, _oid, _tid, 'Adultos', '#6366f1', true),
(_g2, _oid, _tid, 'Crianças', '#f59e0b', true)
ON CONFLICT (id) DO NOTHING;
-- Adicionar 3 pacientes ao grupo Adultos, 2 ao grupo Crianças
INSERT INTO public.patient_group_patient (patient_id, patient_group_id, tenant_id)
SELECT
('cccccccc-0000-0000-0000-' || lpad(((_t-1)*5 + p)::text, 12, '0'))::uuid,
CASE WHEN p <= 3 THEN _g1 ELSE _g2 END,
_tid
FROM generate_series(1, 5) AS s(p)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- ────────────────────────────────────────────────────────────
-- 7. AGENDA — Eventos do último mês + próxima semana
-- Tipos: realizado, faltou, cancelado, agendado, remarcar
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_t int;
_p int;
_d int;
_idx int;
_oid uuid;
_tid uuid;
_pid uuid;
_eid uuid;
_status text;
_statuses text[] := ARRAY['realizado','realizado','realizado','faltou','cancelado','realizado','agendado','remarcar','realizado','realizado',
'realizado','realizado','faltou','realizado','cancelado','agendado','agendado','realizado','realizado','realizado'];
_dia date;
_hora time;
_horas time[] := ARRAY['08:00','09:00','10:00','11:00','14:00'];
_evt_counter int := 0;
_modalidade text;
BEGIN
FOR _t IN 1..10 LOOP
IF _t < 10 THEN
_oid := ('aaaaaaaa-0000-0000-0000-00000000000' || _t)::uuid;
_tid := ('bbbbbbbb-0000-0000-0000-00000000000' || _t)::uuid;
ELSE
_oid := 'aaaaaaaa-0000-0000-0000-000000000010'::uuid;
_tid := 'bbbbbbbb-0000-0000-0000-000000000010'::uuid;
END IF;
-- 4 semanas de sessões (seg-sex), 1 sessão por paciente por semana
FOR _d IN 0..27 LOOP
_dia := (current_date - interval '28 days' + (_d || ' days')::interval)::date;
-- Pular finais de semana
IF extract(dow FROM _dia) IN (0, 6) THEN CONTINUE; END IF;
FOR _p IN 1..5 LOOP
-- Cada paciente tem sessão 1x/semana (dias alternados)
IF (_d % 5) != (_p - 1) THEN CONTINUE; END IF;
_evt_counter := _evt_counter + 1;
_idx := (_t - 1) * 5 + _p;
_pid := ('cccccccc-0000-0000-0000-' || lpad(_idx::text, 12, '0'))::uuid;
_eid := ('dddddddd-0000-0000-0000-' || lpad(_evt_counter::text, 12, '0'))::uuid;
_hora := _horas[_p];
_status := _statuses[(_evt_counter - 1) % 20 + 1];
_modalidade := CASE WHEN _p <= 3 THEN 'presencial' ELSE 'online' END;
-- Eventos futuros devem ser 'agendado'
IF _dia > current_date THEN _status := 'agendado'; END IF;
INSERT INTO public.agenda_eventos (
id, owner_id, tenant_id, tipo, status,
patient_id, terapeuta_id,
inicio_em, fim_em,
titulo, modalidade
) VALUES (
_eid, _oid, _tid, 'sessao'::tipo_evento_agenda, _status::status_evento_agenda,
_pid, _oid,
(_dia || ' ' || _hora)::timestamptz,
(_dia || ' ' || _hora)::timestamptz + interval '50 minutes',
'Sessão — ' || (SELECT nome_completo FROM public.patients WHERE id = _pid),
_modalidade
) ON CONFLICT (id) DO NOTHING;
END LOOP;
END LOOP;
END LOOP;
RAISE NOTICE 'Total de eventos criados: %', _evt_counter;
END $$;
-- ────────────────────────────────────────────────────────────
-- 8. FINANCEIRO — 1 lançamento por evento realizado
-- ────────────────────────────────────────────────────────────
INSERT INTO public.financial_records (
id, owner_id, tenant_id, type, amount, final_amount, description,
category, payment_method, status, paid_at, due_date,
agenda_evento_id, patient_id
)
SELECT
gen_random_uuid(),
ae.owner_id,
ae.tenant_id,
'receita',
v.amt,
v.amt,
CASE
WHEN ae.status = 'realizado' THEN 'Sessão realizada'
WHEN ae.status = 'faltou' THEN 'Falta — cobrança parcial'
ELSE 'Sessão cancelada'
END,
'Sessão',
CASE WHEN random() > 0.5 THEN 'pix' ELSE 'cartao_credito' END,
CASE
WHEN ae.status = 'realizado' THEN 'paid'
WHEN ae.status = 'faltou' THEN 'pending'
ELSE 'cancelled'
END,
CASE WHEN ae.status = 'realizado' THEN ae.inicio_em ELSE NULL END,
ae.inicio_em::date,
ae.id,
ae.patient_id
FROM public.agenda_eventos ae
CROSS JOIN LATERAL (
SELECT CASE
WHEN ae.status = 'realizado' THEN (150 + (random() * 100)::int)::numeric
WHEN ae.status = 'faltou' THEN (75 + (random() * 50)::int)::numeric
ELSE 0
END AS amt
) v
WHERE ae.tenant_id IN (
SELECT id FROM public.tenants WHERE id::text LIKE 'bbbbbbbb%'
)
AND ae.status IN ('realizado', 'faltou', 'cancelado');
-- ────────────────────────────────────────────────────────────
-- 9. REGRAS SEMANAIS — horários de atendimento
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_t int;
_d int;
_oid uuid;
_tid uuid;
BEGIN
FOR _t IN 1..10 LOOP
IF _t < 10 THEN
_oid := ('aaaaaaaa-0000-0000-0000-00000000000' || _t)::uuid;
_tid := ('bbbbbbbb-0000-0000-0000-00000000000' || _t)::uuid;
ELSE
_oid := 'aaaaaaaa-0000-0000-0000-000000000010'::uuid;
_tid := 'bbbbbbbb-0000-0000-0000-000000000010'::uuid;
END IF;
-- Segunda a sexta, 8h-12h e 14h-18h
FOR _d IN 1..5 LOOP
INSERT INTO public.agenda_regras_semanais (owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, modalidade, ativo)
VALUES
(_oid, _tid, _d, '08:00', '12:00', 'ambos', true),
(_oid, _tid, _d, '14:00', '18:00', 'ambos', true)
ON CONFLICT DO NOTHING;
END LOOP;
END LOOP;
END $$;
-- ────────────────────────────────────────────────────────────
-- 10. RECORRÊNCIAS — 1 por terapeuta (paciente 1)
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_t int;
_oid uuid;
_tid uuid;
_pid uuid;
_rid uuid;
BEGIN
FOR _t IN 1..10 LOOP
IF _t < 10 THEN
_oid := ('aaaaaaaa-0000-0000-0000-00000000000' || _t)::uuid;
_tid := ('bbbbbbbb-0000-0000-0000-00000000000' || _t)::uuid;
ELSE
_oid := 'aaaaaaaa-0000-0000-0000-000000000010'::uuid;
_tid := 'bbbbbbbb-0000-0000-0000-000000000010'::uuid;
END IF;
_pid := ('cccccccc-0000-0000-0000-' || lpad(((_t-1)*5 + 1)::text, 12, '0'))::uuid;
_rid := ('11111111-0000-0000-0000-' || lpad(_t::text, 12, '0'))::uuid;
INSERT INTO public.recurrence_rules (
id, tenant_id, owner_id, patient_id,
type, weekdays, start_time, end_time,
start_date, open_ended, modalidade, status
) VALUES (
_rid, _tid, _oid, _pid,
'weekly', ARRAY[(_t % 5) + 1]::smallint[], '09:00', '09:50',
current_date - interval '30 days', true, 'presencial', 'ativo'
) ON CONFLICT (id) DO NOTHING;
END LOOP;
END $$;
-- ────────────────────────────────────────────────────────────
-- 11. NOTIFICATION SCHEDULES — 1 set por terapeuta
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_t int;
_oid uuid;
_tid uuid;
BEGIN
FOR _t IN 1..10 LOOP
IF _t < 10 THEN
_oid := ('aaaaaaaa-0000-0000-0000-00000000000' || _t)::uuid;
_tid := ('bbbbbbbb-0000-0000-0000-00000000000' || _t)::uuid;
ELSE
_oid := 'aaaaaaaa-0000-0000-0000-000000000010'::uuid;
_tid := 'bbbbbbbb-0000-0000-0000-000000000010'::uuid;
END IF;
INSERT INTO public.notification_schedules (
tenant_id, owner_id, schedule_key, event_type,
trigger_type, offset_minutes,
whatsapp_enabled, email_enabled, is_active
) VALUES
(_tid, _oid, 'lembrete_24h', 'lembrete_sessao', 'before_event', 1440, true, true, true),
(_tid, _oid, 'lembrete_2h', 'lembrete_sessao', 'before_event', 120, true, false, true),
(_tid, _oid, 'confirmacao', 'confirmacao_sessao', 'immediate', 0, true, true, true),
(_tid, _oid, 'cancelamento', 'cancelamento_sessao', 'immediate', 0, true, true, true)
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- ════════════════════════════════════════════════════════════
-- 12. DADOS PARA terapeuta@agenciapsi.com.br
-- ════════════════════════════════════════════════════════════
-- 20 pacientes, 10+ grupos, 10+ eventos de cada status,
-- 10+ lançamentos, 10+ regras semanais, 10+ recorrências,
-- 10+ notificações
-- ────────────────────────────────────────────────────────────
-- 12a. LIMPEZA dos dados de teste deste usuário
DO $$
DECLARE
_oid uuid;
_tid uuid;
BEGIN
SELECT id INTO _oid FROM auth.users WHERE email = 'terapeuta@agenciapsi.com.br';
IF _oid IS NULL THEN
RAISE NOTICE 'Usuário terapeuta@agenciapsi.com.br não encontrado — pulando limpeza';
RETURN;
END IF;
SELECT t.id INTO _tid
FROM public.tenants t
JOIN public.tenant_members tm ON tm.tenant_id = t.id
WHERE tm.user_id = _oid
LIMIT 1;
IF _tid IS NULL THEN
RAISE NOTICE 'Tenant não encontrado para terapeuta@agenciapsi.com.br — pulando limpeza';
RETURN;
END IF;
-- Limpar apenas dados de seed (pacientes com email @seed.teste)
DELETE FROM public.financial_records WHERE tenant_id = _tid AND description LIKE '%[SEED]%';
DELETE FROM public.agenda_eventos WHERE tenant_id = _tid AND titulo LIKE '%[SEED]%';
DELETE FROM public.recurrence_rules WHERE tenant_id = _tid AND observacoes = '[SEED]';
DELETE FROM public.patient_group_patient WHERE tenant_id = _tid AND patient_id IN (
SELECT id FROM public.patients WHERE tenant_id = _tid AND email_principal LIKE '%@seed.teste.agenciapsi.com'
);
DELETE FROM public.patient_groups WHERE tenant_id = _tid AND nome LIKE '%[SEED]%';
DELETE FROM public.patients WHERE tenant_id = _tid AND email_principal LIKE '%@seed.teste.agenciapsi.com';
DELETE FROM public.agenda_regras_semanais WHERE tenant_id = _tid AND owner_id = _oid;
DELETE FROM public.notification_schedules WHERE tenant_id = _tid AND owner_id = _oid;
RAISE NOTICE 'Limpeza concluída para terapeuta@agenciapsi.com.br (tenant: %)', _tid;
END $$;
-- 12b. INSERIR DADOS
DO $$
DECLARE
_oid uuid;
_tid uuid;
_mid uuid;
_pid uuid;
_eid uuid;
_gid uuid;
_i int;
_d int;
_dia date;
_hora time;
_status text;
_evt_counter int := 0;
_nomes text[] := ARRAY[
'Amanda Rodrigues','Bruno Ferreira','Camila Teixeira','Diego Nascimento','Elisa Cardoso',
'Felipe Moura','Giovana Ribeiro','Hugo Martins','Isabela Correia','João Vitor Almeida',
'Karen Oliveira','Leonardo Duarte','Marina Santos','Nicolas Costa','Olívia Mendes',
'Paulo Ricardo','Quésia Lima','Rafael Araújo','Sofia Carvalho','Tomás Barbosa'
];
_phones text[] := ARRAY[
'(16) 99001-0001','(16) 99001-0002','(16) 99001-0003','(16) 99001-0004','(16) 99001-0005',
'(16) 99001-0006','(16) 99001-0007','(16) 99001-0008','(16) 99001-0009','(16) 99001-0010',
'(16) 99001-0011','(16) 99001-0012','(16) 99001-0013','(16) 99001-0014','(16) 99001-0015',
'(16) 99001-0016','(16) 99001-0017','(16) 99001-0018','(16) 99001-0019','(16) 99001-0020'
];
_cidades text[] := ARRAY[
'são Carlos','são Carlos','Araraquara','Araraquara','Ribeirão Preto',
'Ribeirão Preto','são Carlos','são Carlos','Araraquara','Araraquara',
'são Carlos','são Carlos','Ribeirão Preto','Ribeirão Preto','são Carlos',
'são Carlos','Araraquara','Araraquara','Ribeirão Preto','são Carlos'
];
_generos text[] := ARRAY[
'Feminino','Masculino','Feminino','Masculino','Feminino',
'Masculino','Feminino','Masculino','Feminino','Masculino',
'Feminino','Masculino','Feminino','Masculino','Feminino',
'Masculino','Feminino','Masculino','Feminino','Masculino'
];
_grupo_nomes text[] := ARRAY[
'Adultos [SEED]','Adolescentes [SEED]','Crianças [SEED]','Casais [SEED]','Idosos [SEED]',
'TCC [SEED]','Psicanálise [SEED]','Gestalt [SEED]','EMDR [SEED]','Grupo Familiar [SEED]'
];
_grupo_cores text[] := ARRAY[
'#6366f1','#f59e0b','#10b981','#ef4444','#8b5cf6',
'#3b82f6','#ec4899','#14b8a6','#f97316','#64748b'
];
-- Status cíclicos para eventos passados
_statuses text[] := ARRAY[
'realizado','realizado','realizado','faltou','cancelado',
'realizado','realizado','remarcar','realizado','faltou',
'realizado','realizado','realizado','cancelado','realizado',
'realizado','faltou','realizado','realizado','realizado'
];
_horas time[] := ARRAY['08:00','09:00','10:00','11:00','14:00','15:00','16:00','17:00'];
BEGIN
-- Buscar user
SELECT id INTO _oid FROM auth.users WHERE email = 'terapeuta@agenciapsi.com.br';
IF _oid IS NULL THEN
RAISE NOTICE 'Usuário terapeuta@agenciapsi.com.br não encontrado — abortando seed';
RETURN;
END IF;
-- Buscar tenant
SELECT t.id INTO _tid
FROM public.tenants t
JOIN public.tenant_members tm ON tm.tenant_id = t.id
WHERE tm.user_id = _oid
LIMIT 1;
IF _tid IS NULL THEN
RAISE NOTICE 'Tenant não encontrado — abortando seed';
RETURN;
END IF;
-- Buscar member_id
SELECT id INTO _mid FROM public.tenant_members
WHERE tenant_id = _tid AND user_id = _oid LIMIT 1;
RAISE NOTICE 'Seed para: % / tenant: % / member: %', _oid, _tid, _mid;
-- ── 20 PACIENTES ──────────────────────────────────────────
FOR _i IN 1..20 LOOP
_pid := gen_random_uuid();
INSERT INTO public.patients (
id, nome_completo, email_principal, telefone,
status, owner_id, tenant_id, responsible_member_id,
data_nascimento, genero, cidade, estado
) VALUES (
_pid,
_nomes[_i],
'seed.paciente' || _i || '@seed.teste.agenciapsi.com',
_phones[_i],
CASE WHEN _i <= 16 THEN 'Ativo' WHEN _i <= 18 THEN 'Inativo' ELSE 'Alta' END,
_oid, _tid, _mid,
('1980-01-01'::date + (_i * 400 || ' days')::interval)::date,
_generos[_i],
_cidades[_i],
'SP'
);
END LOOP;
-- ── 10 GRUPOS ─────────────────────────────────────────────
FOR _i IN 1..10 LOOP
_gid := gen_random_uuid();
INSERT INTO public.patient_groups (id, owner_id, tenant_id, nome, cor, is_active)
VALUES (_gid, _oid, _tid, _grupo_nomes[_i], _grupo_cores[_i], true);
-- Adicionar 2 pacientes por grupo
INSERT INTO public.patient_group_patient (patient_id, patient_group_id, tenant_id)
SELECT p.id, _gid, _tid
FROM public.patients p
WHERE p.tenant_id = _tid
AND p.email_principal LIKE '%@seed.teste.agenciapsi.com'
ORDER BY p.nome_completo
OFFSET ((_i - 1) * 2)
LIMIT 2;
END LOOP;
-- ── EVENTOS (últimos 42 dias + próxima semana) ────────────
-- Garante pelo menos: 10 realizado, 10 faltou, 10 cancelado,
-- 10 agendado, 10 remarcar
_evt_counter := 0;
FOR _d IN 0..48 LOOP
_dia := (current_date - interval '42 days' + (_d || ' days')::interval)::date;
IF extract(dow FROM _dia) IN (0, 6) THEN CONTINUE; END IF;
FOR _i IN 1..4 LOOP
_evt_counter := _evt_counter + 1;
_hora := _horas[((_evt_counter - 1) % 8) + 1];
-- Pegar um paciente cíclico
SELECT id INTO _pid FROM public.patients
WHERE tenant_id = _tid AND email_principal LIKE '%@seed.teste.agenciapsi.com'
ORDER BY nome_completo
OFFSET ((_evt_counter - 1) % 20)
LIMIT 1;
IF _pid IS NULL THEN CONTINUE; END IF;
-- Status: passado = cíclico, futuro = agendado
IF _dia > current_date THEN
_status := 'agendado';
ELSE
_status := _statuses[((_evt_counter - 1) % 20) + 1];
END IF;
_eid := gen_random_uuid();
BEGIN
INSERT INTO public.agenda_eventos (
id, owner_id, tenant_id, tipo, status,
patient_id, terapeuta_id,
inicio_em, fim_em,
titulo, modalidade
) VALUES (
_eid, _oid, _tid,
'sessao'::tipo_evento_agenda,
_status::status_evento_agenda,
_pid, _oid,
(_dia || ' ' || _hora)::timestamptz,
(_dia || ' ' || _hora)::timestamptz + interval '50 minutes',
'[SEED] Sessão — ' || _nomes[((_evt_counter - 1) % 20) + 1],
CASE WHEN _i <= 2 THEN 'presencial' ELSE 'online' END
);
EXCEPTION WHEN exclusion_violation THEN
-- Horário já ocupado, pular
NULL;
END;
END LOOP;
END LOOP;
RAISE NOTICE 'Eventos tentados: % (alguns podem ter sido pulados por sobreposição)', _evt_counter;
-- ── FINANCEIRO ────────────────────────────────────────────
INSERT INTO public.financial_records (
id, owner_id, tenant_id, type, amount, final_amount, description,
category, payment_method, status, paid_at, due_date,
agenda_evento_id, patient_id
)
SELECT
gen_random_uuid(),
ae.owner_id,
ae.tenant_id,
'receita',
v.amt,
v.amt,
'[SEED] ' || CASE
WHEN ae.status = 'realizado'::status_evento_agenda THEN 'Sessão realizada'
WHEN ae.status = 'faltou'::status_evento_agenda THEN 'Falta — cobrança parcial'
ELSE 'Sessão cancelada'
END,
'Sessão',
CASE WHEN random() > 0.5 THEN 'pix' ELSE 'cartao_credito' END,
CASE
WHEN ae.status = 'realizado'::status_evento_agenda THEN 'paid'
WHEN ae.status = 'faltou'::status_evento_agenda THEN 'pending'
ELSE 'cancelled'
END,
CASE WHEN ae.status = 'realizado'::status_evento_agenda THEN ae.inicio_em ELSE NULL END,
ae.inicio_em::date,
ae.id,
ae.patient_id
FROM public.agenda_eventos ae
CROSS JOIN LATERAL (
SELECT CASE
WHEN ae.status = 'realizado'::status_evento_agenda THEN (150 + (random() * 100)::int)::numeric
WHEN ae.status = 'faltou'::status_evento_agenda THEN (75 + (random() * 50)::int)::numeric
ELSE 0
END AS amt
) v
WHERE ae.tenant_id = _tid
AND ae.titulo LIKE '[SEED]%'
AND ae.status IN ('realizado'::status_evento_agenda, 'faltou'::status_evento_agenda, 'cancelado'::status_evento_agenda);
-- ── REGRAS SEMANAIS (10 — manhã e tarde, seg-sex) ────────
INSERT INTO public.agenda_regras_semanais (owner_id, tenant_id, dia_semana, hora_inicio, hora_fim, modalidade, ativo)
VALUES
(_oid, _tid, 1, '08:00', '12:00', 'ambos', true),
(_oid, _tid, 1, '14:00', '18:00', 'ambos', true),
(_oid, _tid, 2, '08:00', '12:00', 'ambos', true),
(_oid, _tid, 2, '14:00', '18:00', 'ambos', true),
(_oid, _tid, 3, '08:00', '12:00', 'ambos', true),
(_oid, _tid, 3, '14:00', '18:00', 'ambos', true),
(_oid, _tid, 4, '08:00', '12:00', 'ambos', true),
(_oid, _tid, 4, '14:00', '18:00', 'ambos', true),
(_oid, _tid, 5, '08:00', '12:00', 'ambos', true),
(_oid, _tid, 5, '14:00', '18:00', 'ambos', true)
ON CONFLICT DO NOTHING;
-- ── RECORRÊNCIAS (10 pacientes com sessão semanal) ────────
FOR _i IN 1..10 LOOP
SELECT id INTO _pid FROM public.patients
WHERE tenant_id = _tid AND email_principal LIKE '%@seed.teste.agenciapsi.com'
ORDER BY nome_completo
OFFSET (_i - 1) LIMIT 1;
IF _pid IS NULL THEN CONTINUE; END IF;
INSERT INTO public.recurrence_rules (
id, tenant_id, owner_id, patient_id, type, weekdays,
start_time, end_time, start_date, open_ended, modalidade, status, observacoes
) VALUES (
gen_random_uuid(), _tid, _oid, _pid, 'weekly',
ARRAY[_i % 5]::int[],
('08:00'::time + ((_i - 1) * 60 || ' minutes')::interval),
('08:50'::time + ((_i - 1) * 60 || ' minutes')::interval),
current_date - interval '30 days',
true,
CASE WHEN _i <= 5 THEN 'presencial' ELSE 'online' END,
'ativo',
'[SEED]'
);
END LOOP;
-- ── NOTIFICATION SCHEDULES (10) ───────────────────────────
INSERT INTO public.notification_schedules (
tenant_id, owner_id, schedule_key, event_type,
trigger_type, offset_minutes,
whatsapp_enabled, email_enabled, is_active
) VALUES
(_tid, _oid, 'lembrete_24h', 'lembrete_sessao', 'before_event', 1440, true, true, true),
(_tid, _oid, 'lembrete_2h', 'lembrete_sessao', 'before_event', 120, true, false, true),
(_tid, _oid, 'lembrete_30min', 'lembrete_sessao', 'before_event', 30, true, false, true),
(_tid, _oid, 'confirmacao', 'confirmacao_sessao', 'immediate', 0, true, true, true),
(_tid, _oid, 'cancelamento', 'cancelamento_sessao', 'immediate', 0, true, true, true),
(_tid, _oid, 'lembrete_48h', 'lembrete_sessao', 'before_event', 2880, false, true, true),
(_tid, _oid, 'lembrete_1h', 'lembrete_sessao', 'before_event', 60, true, false, true),
(_tid, _oid, 'confirmacao_email', 'confirmacao_sessao', 'immediate', 0, false, true, true),
(_tid, _oid, 'cancel_email', 'cancelamento_sessao', 'immediate', 0, false, true, true),
(_tid, _oid, 'lembrete_1semana', 'lembrete_sessao', 'before_event', 10080,false, true, false)
ON CONFLICT DO NOTHING;
RAISE NOTICE '══════════════════════════════════════════';
RAISE NOTICE ' SEED terapeuta@agenciapsi.com.br COMPLETO';
RAISE NOTICE ' Pacientes: 20';
RAISE NOTICE ' Grupos: 10';
RAISE NOTICE ' Eventos: %', _evt_counter;
RAISE NOTICE ' Regras semanais: 10';
RAISE NOTICE ' Recorrências: 10';
RAISE NOTICE ' Notificações: 10';
RAISE NOTICE '══════════════════════════════════════════';
END $$;
-- ────────────────────────────────────────────────────────────
-- 13. RESUMO
-- ────────────────────────────────────────────────────────────
DO $$
DECLARE
_users int;
_tenants int;
_patients int;
_events int;
_records int;
_groups int;
_recurrences int;
BEGIN
SELECT count(*) INTO _users FROM auth.users WHERE id::text LIKE 'aaaaaaaa%';
SELECT count(*) INTO _tenants FROM public.tenants WHERE id::text LIKE 'bbbbbbbb%';
SELECT count(*) INTO _patients FROM public.patients WHERE tenant_id::text LIKE 'bbbbbbbb%';
SELECT count(*) INTO _events FROM public.agenda_eventos WHERE tenant_id::text LIKE 'bbbbbbbb%';
SELECT count(*) INTO _records FROM public.financial_records WHERE tenant_id::text LIKE 'bbbbbbbb%';
SELECT count(*) INTO _groups FROM public.patient_groups WHERE tenant_id::text LIKE 'bbbbbbbb%';
SELECT count(*) INTO _recurrences FROM public.recurrence_rules WHERE tenant_id::text LIKE 'bbbbbbbb%';
RAISE NOTICE '══════════════════════════════════════════';
RAISE NOTICE ' SEED COMPLETO — RESUMO';
RAISE NOTICE '══════════════════════════════════════════';
RAISE NOTICE ' Terapeutas: %', _users;
RAISE NOTICE ' Tenants: %', _tenants;
RAISE NOTICE ' Pacientes: %', _patients;
RAISE NOTICE ' Eventos agenda: %', _events;
RAISE NOTICE ' Lançamentos fin.: %', _records;
RAISE NOTICE ' Grupos: %', _groups;
RAISE NOTICE ' Recorrências: %', _recurrences;
RAISE NOTICE '══════════════════════════════════════════';
RAISE NOTICE ' Login: terapeuta01@teste.agenciapsi.com';
RAISE NOTICE ' Senha: Test@1234';
RAISE NOTICE '══════════════════════════════════════════';
END $$;

View File

@@ -0,0 +1,231 @@
-- ============================================================
-- CLEANUP — Remove TODOS os dados de seed/teste
-- AgenciaPsi — 2026-03-22
-- ============================================================
-- Remove dados criados por seed_test_data.sql:
-- - 10 terapeutas fictícios (aaaaaaaa-*)
-- - 10 tenants fictícios (bbbbbbbb-*)
-- - 50 pacientes fictícios (cccccccc-*)
-- - Eventos, financeiro, grupos, recorrências
-- - Dados seed do terapeuta@agenciapsi.com.br ([SEED])
-- - Notification queue/logs dos tenants de teste
-- - Addon credits/transactions dos tenants de teste
--
-- SEGURO: Não apaga dados reais (apenas UUIDs/emails conhecidos)
-- ============================================================
DO $$
DECLARE
_tenant_ids uuid[] := ARRAY[
'bbbbbbbb-0000-0000-0000-000000000001'::uuid,
'bbbbbbbb-0000-0000-0000-000000000002'::uuid,
'bbbbbbbb-0000-0000-0000-000000000003'::uuid,
'bbbbbbbb-0000-0000-0000-000000000004'::uuid,
'bbbbbbbb-0000-0000-0000-000000000005'::uuid,
'bbbbbbbb-0000-0000-0000-000000000006'::uuid,
'bbbbbbbb-0000-0000-0000-000000000007'::uuid,
'bbbbbbbb-0000-0000-0000-000000000008'::uuid,
'bbbbbbbb-0000-0000-0000-000000000009'::uuid,
'bbbbbbbb-0000-0000-0000-000000000010'::uuid
];
_user_ids uuid[] := ARRAY[
'aaaaaaaa-0000-0000-0000-000000000001'::uuid,
'aaaaaaaa-0000-0000-0000-000000000002'::uuid,
'aaaaaaaa-0000-0000-0000-000000000003'::uuid,
'aaaaaaaa-0000-0000-0000-000000000004'::uuid,
'aaaaaaaa-0000-0000-0000-000000000005'::uuid,
'aaaaaaaa-0000-0000-0000-000000000006'::uuid,
'aaaaaaaa-0000-0000-0000-000000000007'::uuid,
'aaaaaaaa-0000-0000-0000-000000000008'::uuid,
'aaaaaaaa-0000-0000-0000-000000000009'::uuid,
'aaaaaaaa-0000-0000-0000-000000000010'::uuid
];
_real_oid uuid;
_real_tid uuid;
_deleted int;
_total int := 0;
BEGIN
RAISE NOTICE '══════════════════════════════════════════';
RAISE NOTICE ' CLEANUP — Removendo dados de teste...';
RAISE NOTICE '══════════════════════════════════════════';
-- ── Notification queue e logs (tenants fictícios) ──────────
DELETE FROM public.notification_logs WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_logs: % removidos', _deleted;
DELETE FROM public.notification_queue WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_queue: % removidos', _deleted;
DELETE FROM public.notification_preferences WHERE owner_id = ANY(_user_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_preferences: % removidos', _deleted;
DELETE FROM public.notification_schedules WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_schedules: % removidos', _deleted;
DELETE FROM public.notification_channels WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_channels: % removidos', _deleted;
DELETE FROM public.notification_templates WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_templates: % removidos', _deleted;
-- ── Addon credits/transactions (tenants fictícios) ─────────
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'addon_transactions') THEN
DELETE FROM public.addon_transactions WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' addon_transactions: % removidos', _deleted;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'addon_credits') THEN
DELETE FROM public.addon_credits WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' addon_credits: % removidos', _deleted;
END IF;
-- ── Financeiro ─────────────────────────────────────────────
DELETE FROM public.financial_records WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' financial_records: % removidos', _deleted;
-- ── Eventos agenda ─────────────────────────────────────────
DELETE FROM public.agenda_eventos WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' agenda_eventos: % removidos', _deleted;
-- ── Recorrências ───────────────────────────────────────────
DELETE FROM public.recurrence_rules WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' recurrence_rules: % removidos', _deleted;
-- ── Grupos ─────────────────────────────────────────────────
DELETE FROM public.patient_group_patient WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' patient_group_patient: % removidos', _deleted;
DELETE FROM public.patient_groups WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' patient_groups: % removidos', _deleted;
-- ── Pacientes ──────────────────────────────────────────────
DELETE FROM public.patients WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' patients: % removidos', _deleted;
-- ── Regras semanais ────────────────────────────────────────
DELETE FROM public.agenda_regras_semanais WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' agenda_regras_semanais: % removidos', _deleted;
-- ── Serviços ───────────────────────────────────────────────
DELETE FROM public.services WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' services: % removidos', _deleted;
-- ── Tenant members ─────────────────────────────────────────
DELETE FROM public.tenant_members WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' tenant_members: % removidos', _deleted;
-- ── Company profiles ───────────────────────────────────────
DELETE FROM public.company_profiles WHERE tenant_id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' company_profiles: % removidos', _deleted;
-- ── Tenants ────────────────────────────────────────────────
DELETE FROM public.tenants WHERE id = ANY(_tenant_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' tenants: % removidos', _deleted;
-- ── Profiles ───────────────────────────────────────────────
DELETE FROM public.profiles WHERE id = ANY(_user_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' profiles: % removidos', _deleted;
-- ── Auth users ─────────────────────────────────────────────
ALTER TABLE auth.users DISABLE TRIGGER ALL;
DELETE FROM auth.identities WHERE user_id = ANY(_user_ids);
DELETE FROM auth.users WHERE id = ANY(_user_ids);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' auth.users: % removidos', _deleted;
ALTER TABLE auth.users ENABLE TRIGGER ALL;
-- ══════════════════════════════════════════════════════════
-- DADOS [SEED] DO terapeuta@agenciapsi.com.br
-- ══════════════════════════════════════════════════════════
SELECT id INTO _real_oid FROM auth.users WHERE email = 'terapeuta@agenciapsi.com.br';
IF _real_oid IS NOT NULL THEN
SELECT t.id INTO _real_tid
FROM public.tenants t
JOIN public.tenant_members tm ON tm.tenant_id = t.id
WHERE tm.user_id = _real_oid
LIMIT 1;
IF _real_tid IS NOT NULL THEN
RAISE NOTICE '';
RAISE NOTICE ' Limpando [SEED] de terapeuta@agenciapsi.com.br...';
-- Notification queue/logs deste tenant (apenas os de teste)
DELETE FROM public.notification_logs WHERE tenant_id = _real_tid
AND queue_id IN (SELECT id FROM public.notification_queue WHERE tenant_id = _real_tid);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_logs (real): % removidos', _deleted;
DELETE FROM public.notification_queue WHERE tenant_id = _real_tid;
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notification_queue (real):% removidos', _deleted;
-- Financeiro [SEED]
DELETE FROM public.financial_records WHERE tenant_id = _real_tid AND description LIKE '[SEED]%';
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' financial_records [SEED]: % removidos', _deleted;
-- Eventos [SEED]
DELETE FROM public.agenda_eventos WHERE tenant_id = _real_tid AND titulo LIKE '[SEED]%';
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' agenda_eventos [SEED]: % removidos', _deleted;
-- Recorrências [SEED]
DELETE FROM public.recurrence_rules WHERE tenant_id = _real_tid AND observacoes = '[SEED]';
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' recurrence_rules [SEED]: % removidos', _deleted;
-- Grupos [SEED]
DELETE FROM public.patient_group_patient WHERE tenant_id = _real_tid AND patient_id IN (
SELECT id FROM public.patients WHERE tenant_id = _real_tid AND email_principal LIKE '%@seed.teste.agenciapsi.com'
);
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' group_patient [SEED]: % removidos', _deleted;
DELETE FROM public.patient_groups WHERE tenant_id = _real_tid AND nome LIKE '%[SEED]%';
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' patient_groups [SEED]: % removidos', _deleted;
-- Pacientes seed
DELETE FROM public.patients WHERE tenant_id = _real_tid AND email_principal LIKE '%@seed.teste.agenciapsi.com';
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' patients [SEED]: % removidos', _deleted;
-- Regras semanais e schedules (reseta tudo deste owner)
DELETE FROM public.agenda_regras_semanais WHERE tenant_id = _real_tid AND owner_id = _real_oid;
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' regras_semanais (real): % removidos', _deleted;
DELETE FROM public.notification_schedules WHERE tenant_id = _real_tid AND owner_id = _real_oid;
GET DIAGNOSTICS _deleted = ROW_COUNT; _total := _total + _deleted;
RAISE NOTICE ' notif_schedules (real): % removidos', _deleted;
END IF;
ELSE
RAISE NOTICE ' terapeuta@agenciapsi.com.br não encontrado — pulando';
END IF;
RAISE NOTICE '';
RAISE NOTICE '══════════════════════════════════════════';
RAISE NOTICE ' CLEANUP COMPLETO — % registros removidos', _total;
RAISE NOTICE '══════════════════════════════════════════';
END $$;

566
docs/USER_ARCHETYPES.html Normal file
View File

@@ -0,0 +1,566 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AgenciaPsi — Arquétipos de Usuário</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
padding: 2rem 1rem 4rem;
}
/* ── Header ── */
.page-header {
text-align: center;
margin-bottom: 3rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: 800;
letter-spacing: -0.04em;
background: linear-gradient(135deg, #818cf8, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-header p {
margin-top: .5rem;
color: #64748b;
font-size: .95rem;
}
/* ── Grid ── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
/* ── Card ── */
.card {
background: #1e2330;
border: 1px solid #2d3548;
border-radius: 1.25rem;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: border-color .2s, transform .2s;
}
.card:hover {
border-color: #4f6ef7;
transform: translateY(-2px);
}
/* ── Card header ── */
.card-header {
display: flex;
align-items: center;
gap: .75rem;
}
.card-icon {
width: 2.75rem;
height: 2.75rem;
border-radius: .75rem;
display: grid;
place-items: center;
font-size: 1.25rem;
flex-shrink: 0;
}
.card-title { font-size: 1.05rem; font-weight: 700; line-height: 1.2; }
.card-subtitle { font-size: .75rem; color: #64748b; margin-top: 2px; font-family: monospace; }
/* ── Tree ── */
.tree {
background: #0f1117;
border-radius: .75rem;
padding: 1rem 1.1rem;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: .78rem;
line-height: 1.8;
}
.tree-root { color: #a5b4fc; font-weight: 600; }
.tree-branch { color: #475569; }
.tree-leaf { color: #cbd5e1; }
.tree-comment { color: #475569; font-style: italic; }
.tree-key { color: #f472b6; }
.tree-val { color: #34d399; }
.tree-warn { color: #fb923c; }
.tree-new { color: #facc15; }
/* ── Badges ── */
.badges { display: flex; flex-wrap: wrap; gap: .4rem; }
.badge {
font-size: .68rem;
font-weight: 600;
padding: .2rem .6rem;
border-radius: 9999px;
border: 1px solid transparent;
letter-spacing: .02em;
}
.badge-purple { background: #312e81; border-color: #4f46e5; color: #a5b4fc; }
.badge-green { background: #064e3b; border-color: #059669; color: #6ee7b7; }
.badge-blue { background: #1e3a5f; border-color: #2563eb; color: #93c5fd; }
.badge-orange { background: #431407; border-color: #ea580c; color: #fdba74; }
.badge-yellow { background: #422006; border-color: #ca8a04; color: #fde047; }
.badge-pink { background: #500724; border-color: #db2777; color: #f9a8d4; }
.badge-gray { background: #1e293b; border-color: #475569; color: #94a3b8; }
.badge-red { background: #450a0a; border-color: #dc2626; color: #fca5a5; }
/* ── Notes ── */
.note {
font-size: .75rem;
color: #64748b;
line-height: 1.5;
border-left: 2px solid #2d3548;
padding-left: .75rem;
}
.note strong { color: #94a3b8; }
/* ── Phase tag ── */
.phase {
display: inline-flex;
align-items: center;
gap: .3rem;
font-size: .7rem;
font-weight: 700;
padding: .15rem .55rem;
border-radius: 9999px;
margin-left: auto;
}
.phase-1 { background: #064e3b; color: #6ee7b7; border: 1px solid #059669; }
.phase-2 { background: #422006; color: #fde047; border: 1px solid #ca8a04; }
/* ── Section label ── */
.section-label {
grid-column: 1 / -1;
font-size: .7rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: #475569;
padding: .25rem 0;
border-bottom: 1px solid #2d3548;
margin-bottom: -.25rem;
}
/* ── Legend ── */
.legend {
max-width: 1400px;
margin: 2.5rem auto 0;
background: #1e2330;
border: 1px solid #2d3548;
border-radius: 1.25rem;
padding: 1.25rem 1.5rem;
}
.legend h3 { font-size: .8rem; font-weight: 700; color: #64748b; letter-spacing: .08em; text-transform: uppercase; margin-bottom: .75rem; }
.legend-grid { display: flex; flex-wrap: wrap; gap: 1rem 2rem; }
.legend-item { display: flex; align-items: center; gap: .5rem; font-size: .78rem; color: #94a3b8; }
.legend-dot { width: .65rem; height: .65rem; border-radius: 50%; }
</style>
</head>
<body>
<div class="page-header">
<h1>AgenciaPsi — Arquétipos de Usuário</h1>
<p>Como cada tipo de usuário está estruturado no banco de dados e no sistema de permissões.</p>
</div>
<div class="grid">
<!-- ════════════════════════════════════════ PLATAFORMA ══ -->
<div class="section-label">🏛️ Plataforma</div>
<!-- SaaS Admin -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#312e81">🛡️</div>
<div>
<div class="card-title">SaaS Admin</div>
<div class="card-subtitle">profiles.role = 'saas_admin'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (saas@agenciapsi.com.br)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'saas_admin'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// sem memberships de tenant</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /saas/*</span></div>
</div>
<div class="badges">
<span class="badge badge-purple">role: saas_admin</span>
<span class="badge badge-gray">sem tenant</span>
</div>
<p class="note">Acesso total à plataforma. Gerencia planos, features, assinaturas e usuários. Nunca entra em contexto de tenant.</p>
</div>
<!-- Editor -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#1e3a5f">✍️</div>
<div>
<div class="card-title">Editor de Conteúdo</div>
<div class="card-subtitle">profiles.platform_roles[] = 'editor'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (editor@agenciapsi.com.br)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">profiles.platform_roles</span> <span class="tree-val">['editor']</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// sem memberships de tenant</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /editor/*</span></div>
</div>
<div class="badges">
<span class="badge badge-blue">platform_roles: editor</span>
<span class="badge badge-gray">sem tenant</span>
</div>
<p class="note">Papel de plataforma (não de tenant). Gerencia conteúdo público, landing pages, textos. Verificado via <code>platform_roles[]</code>.</p>
</div>
<!-- ════════════════════════════════════════ CLÍNICA ══ -->
<div class="section-label">🏥 Clínica</div>
<!-- Clinic Admin -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#064e3b">🏥</div>
<div>
<div class="card-title">Admin da Clínica</div>
<div class="card-subtitle">tenant.kind = 'clinic'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (admin@clinicax.com.br)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'clinic_admin'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">clinic_free | clinic_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /admin/*</span></div>
</div>
<div class="badges">
<span class="badge badge-green">role: clinic_admin</span>
<span class="badge badge-blue">tenant: clinic</span>
<span class="badge badge-gray">clinic_free</span>
<span class="badge badge-purple">clinic_pro</span>
</div>
<p class="note">Dono ou gestor de uma clínica. Gerencia profissionais, pacientes, agenda e módulos da clínica.</p>
</div>
<!-- Terapeuta da Clínica -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#1e3a5f">🧑‍⚕️</div>
<div>
<div class="card-title">Terapeuta da Clínica</div>
<div class="card-subtitle">tenant.kind = 'clinic' / role = 'therapist'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (terapeuta@clinicax.com.br)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">entitlements</span> <span class="tree-val">via plano da clínica</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /therapist/*</span></div>
</div>
<div class="badges">
<span class="badge badge-green">role: therapist</span>
<span class="badge badge-blue">tenant: clinic</span>
<span class="badge badge-gray">entitlements da clínica</span>
</div>
<p class="note">Terapeuta vinculado a uma clínica. Seus entitlements vêm do plano do tenant (clínica), não de assinatura pessoal.</p>
</div>
<!-- ════════════════════════════════════════ TERAPEUTA INDEPENDENTE ══ -->
<div class="section-label">🧑‍💼 Terapeuta Independente</div>
<!-- Terapeuta Solo -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#064e3b">🧑‍💼</div>
<div>
<div class="card-title">Terapeuta Solo</div>
<div class="card-subtitle">tenant.kind = 'saas'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (terapeuta@gmail.com)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-val">Tenant Pessoal</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_free | therapist_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// entitlements via v_user_entitlements</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /therapist/*</span></div>
</div>
<div class="badges">
<span class="badge badge-green">role: therapist</span>
<span class="badge badge-gray">tenant: saas (pessoal)</span>
<span class="badge badge-gray">therapist_free</span>
<span class="badge badge-purple">therapist_pro</span>
</div>
<p class="note">Terapeuta autônomo sem clínica. Assina diretamente. Entitlements vêm de <code>v_user_entitlements</code> (assinatura pessoal).</p>
</div>
<!-- Terapeuta Solo + Clínica -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#1e3a5f">🔀</div>
<div>
<div class="card-title">Terapeuta Solo + Clínica</div>
<div class="card-subtitle">2 memberships / contexto switcher</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (terapeuta@gmail.com)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Membership A: <span class="tree-val">Tenant Pessoal</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_pro</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership B: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// switcher de contexto no topbar</span></div>
</div>
<div class="badges">
<span class="badge badge-green">role: therapist</span>
<span class="badge badge-blue">2 tenants</span>
<span class="badge badge-purple">therapist_pro</span>
<span class="badge badge-gray">switcher de contexto</span>
</div>
<p class="note">Atua em dois contextos. No tenant pessoal usa PRO. Na clínica usa os entitlements da clínica. Precisa de switcher de tenant no topbar.</p>
</div>
<!-- ════════════════════════════════════════ SUPERVISOR ══ -->
<div class="section-label">🎓 Supervisor (Fase 1 — novo)</div>
<!-- Supervisor Solo -->
<div class="card" style="border-color: #ca8a04">
<div class="card-header">
<div class="card-icon" style="background:#422006">🎓</div>
<div>
<div class="card-title">Supervisor Solo</div>
<div class="card-subtitle">tenant.kind = 'supervisor'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (supervisor@gmail.com)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'tenant_member'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership: <span class="tree-new">Tenant Supervisão (novo)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_free | supervisor_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">max_supervisees</span> <span class="tree-new">3 | 20</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /supervisor/*</span></div>
</div>
<div class="badges">
<span class="badge badge-yellow">role: supervisor</span>
<span class="badge badge-yellow">tenant: supervisor</span>
<span class="badge badge-yellow">supervisor_free</span>
<span class="badge badge-orange">supervisor_pro</span>
</div>
<p class="note"><strong>Novo.</strong> Supervisor independente. Tem sua própria sala de supervisão. O plano define o limite de terapeutas supervisionados (<code>plans.max_supervisees</code>).</p>
</div>
<!-- Terapeuta + Supervisor -->
<div class="card" style="border-color: #ca8a04">
<div class="card-header">
<div class="card-icon" style="background:#422006">🔀🎓</div>
<div>
<div class="card-title">Terapeuta + Supervisor</div>
<div class="card-subtitle">2 tenants / 2 papéis</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (terapeuta@gmail.com)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Membership A: <span class="tree-val">Tenant Pessoal</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'saas'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-val">therapist_pro</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership B: <span class="tree-new">Tenant Supervisão (novo)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// switcher: "Meu consultório" / "Minha supervisão"</span></div>
</div>
<div class="badges">
<span class="badge badge-green">therapist</span>
<span class="badge badge-yellow">supervisor</span>
<span class="badge badge-blue">2 tenants</span>
<span class="badge badge-purple">therapist_pro</span>
<span class="badge badge-orange">supervisor_pro</span>
</div>
<p class="note"><strong>O caso mais comum.</strong> Atua como terapeuta no tenant pessoal e como supervisor no tenant de supervisão. Switcher de contexto no topbar.</p>
</div>
<!-- Terapeuta (clínica) + Supervisor -->
<div class="card" style="border-color: #ca8a04">
<div class="card-header">
<div class="card-icon" style="background:#422006">🏥🎓</div>
<div>
<div class="card-title">Terapeuta (Clínica) + Supervisor</div>
<div class="card-subtitle">3 tenants possíveis</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (terapeuta@clinicax.com.br)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Membership A: <span class="tree-val">Clínica X</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-val">'clinic'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">role</span> <span class="tree-val">'therapist'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Membership B: <span class="tree-new">Tenant Supervisão (novo)</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">tenant.kind</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">role</span> <span class="tree-new">'supervisor'</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-key">plano</span> <span class="tree-new">supervisor_free | supervisor_pro</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// supervisão é INDEPENDENTE da clínica</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// colegas da clínica podem ser supervisionados</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// via convite no tenant de supervisão</span></div>
</div>
<div class="badges">
<span class="badge badge-green">therapist (clínica)</span>
<span class="badge badge-yellow">supervisor (independente)</span>
<span class="badge badge-orange">supervisor_pro</span>
</div>
<p class="note">Trabalha na clínica como terapeuta <strong>e</strong> supervisiona outros terapeutas (inclusive colegas da clínica) de forma independente. A clínica não interfere na supervisão.</p>
</div>
<!-- ════════════════════════════════════════ FASE 2 ══ -->
<div class="section-label">🚀 Fase 2 — Marketplace de Supervisão</div>
<!-- Clínica com Supervisor Associado -->
<div class="card" style="border-color: #475569; opacity: .8">
<div class="card-header">
<div class="card-icon" style="background:#1e293b">🏥🤝🎓</div>
<div>
<div class="card-title">Clínica com Supervisor Contratado</div>
<div class="card-subtitle">repasse financeiro AgenciaPsi</div>
</div>
<span class="phase phase-2">Fase 2</span>
</div>
<div class="tree">
<div class="tree-root">Clínica X</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Ativa módulo <span class="tree-key">supervisao</span> (feature)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Associa supervisor externo via convite</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> Sessões registradas na plataforma</div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Pagamento via AgenciaPsi</div>
<div>&nbsp;</div>
<div class="tree-root">Fluxo financeiro</div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> Clínica paga <span class="tree-warn">R$ 200/sessão</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">├──</span> Supervisor recebe <span class="tree-val">R$ 180</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-branch">└──</span> AgenciaPsi retém <span class="tree-warn">R$ 20 (10%)</span></div>
</div>
<div class="badges">
<span class="badge badge-pink">split de pagamento</span>
<span class="badge badge-gray">Stripe Connect / Iugu</span>
<span class="badge badge-red">Fase 2</span>
</div>
<p class="note"><strong>Futuro.</strong> A clínica contrata supervisão via plataforma. AgenciaPsi faz o split automático. Requer gateway com marketplace split (Stripe Connect, Iugu, Pagar.me).</p>
</div>
<!-- ════════════════════════════════════════ PACIENTE ══ -->
<div class="section-label">👤 Paciente / Portal</div>
<!-- Paciente -->
<div class="card">
<div class="card-header">
<div class="card-icon" style="background:#500724">👤</div>
<div>
<div class="card-title">Paciente</div>
<div class="card-subtitle">profiles.role = 'portal_user'</div>
</div>
<span class="phase phase-1">Fase 1</span>
</div>
<div class="tree">
<div class="tree-root">Usuário (paciente@gmail.com)</div>
<div>&nbsp;&nbsp;<span class="tree-branch">├──</span> <span class="tree-key">profiles.role</span> <span class="tree-val">'portal_user'</span></div>
<div>&nbsp;&nbsp;<span class="tree-branch">└──</span> <span class="tree-comment">// sem memberships de tenant</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// acessa /portal/*</span></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span class="tree-comment">// identidade global, não tenant</span></div>
</div>
<div class="badges">
<span class="badge badge-pink">role: portal_user</span>
<span class="badge badge-gray">sem tenant</span>
</div>
<p class="note">Acessa apenas o portal do paciente. Vê suas sessões, agenda e documentos. Nunca entra na área de tenant-app.</p>
</div>
</div><!-- /grid -->
<!-- ── LEGEND ── -->
<div class="legend">
<h3>Legenda</h3>
<div class="legend-grid">
<div class="legend-item"><div class="legend-dot" style="background:#facc15"></div> Novo — Fase 1 (supervisor)</div>
<div class="legend-item"><div class="legend-dot" style="background:#6ee7b7"></div> Existente — Fase 1</div>
<div class="legend-item"><div class="legend-dot" style="background:#fb923c"></div> Planejado — Fase 2</div>
<div class="legend-item"><div class="legend-dot" style="background:#a5b4fc"></div> Plataforma (sem tenant)</div>
<div class="legend-item"><div class="legend-dot" style="background:#94a3b8"></div> profiles.role → identidade global</div>
<div class="legend-item"><div class="legend-dot" style="background:#f9a8d4"></div> memberships.role → contexto de tenant</div>
</div>
</div>
<!-- ── RESUMO TÉCNICO ── -->
<div class="legend" style="margin-top: 1rem">
<h3>Resumo Técnico — Como o Guard decide o menu</h3>
<div style="font-family: monospace; font-size: .8rem; line-height: 2; color: #94a3b8;">
<div><span style="color:#a5b4fc">profiles.role</span> = identidade global (saas_admin | tenant_member | portal_user)</div>
<div><span style="color:#6ee7b7">memberships.role</span> = papel dentro do tenant (clinic_admin | therapist | supervisor | editor)</div>
<div><span style="color:#f9a8d4">tenant.kind</span> = tipo do tenant (clinic | saas | supervisor) → define qual menu e contexto</div>
<div><span style="color:#fde047">plans.target</span> = para quem é o plano (clinic | therapist | supervisor)</div>
<div><span style="color:#fdba74">plans.max_supervisees</span> = limite de supervisionados (novo — Fase 1)</div>
<div style="margin-top:.5rem; color: #475569">
Entitlements: v_tenant_entitlements (plano do tenant) UNION v_user_entitlements (assinatura pessoal)
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentos & Arquivos — Status de Implementacao</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-card-hover: #1c2129;
--border: #30363d;
--border-light: #21262d;
--text: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-dim: #1f6feb33;
--green: #3fb950;
--green-dim: #23863633;
--orange: #d29922;
--orange-dim: #9e6a0333;
--red: #f85149;
--red-dim: #da363333;
--purple: #bc8cff;
--purple-dim: #8957e533;
--cyan: #39d2c0;
--cyan-dim: #1b7c6e33;
--pink: #f778ba;
--pink-dim: #db61a233;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--radius-lg: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 32px 24px 64px;
max-width: 1100px;
margin: 0 auto;
}
/* ── Header ─────────────────────────── */
.page-header {
text-align: center;
margin-bottom: 36px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 6px;
}
.page-header .subtitle {
font-size: 13px;
color: var(--text-secondary);
}
.page-header .meta {
display: flex;
gap: 10px;
margin-top: 14px;
justify-content: center;
flex-wrap: wrap;
}
.meta-tag {
font-size: 11px;
font-family: var(--font-mono);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-card);
}
/* ── Summary stats ─────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 36px;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 12px;
text-align: center;
}
.summary-card .number { font-size: 24px; font-weight: 700; line-height: 1.2; }
.summary-card .label { font-size: 10px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
.c-green .number { color: var(--green); }
.c-orange .number { color: var(--orange); }
.c-red .number { color: var(--red); }
.c-accent .number { color: var(--accent); }
.c-purple .number { color: var(--purple); }
.c-cyan .number { color: var(--cyan); }
/* ── Section ────────────────────────── */
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.section-icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.section-icon.blue { background: var(--accent-dim); color: var(--accent); }
.section-icon.green { background: var(--green-dim); color: var(--green); }
.section-icon.orange { background: var(--orange-dim); color: var(--orange); }
.section-icon.purple { background: var(--purple-dim); color: var(--purple); }
.section-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
.section-icon.pink { background: var(--pink-dim); color: var(--pink); }
.section-icon.red { background: var(--red-dim); color: var(--red); }
.section-title { font-size: 16px; font-weight: 600; }
/* ── Layout: cards + sidebar ────────── */
.content-row {
display: grid;
grid-template-columns: 1fr 260px;
gap: 14px;
align-items: start;
}
@media (max-width: 800px) {
.content-row { grid-template-columns: 1fr; }
}
/* ── Cards ──────────────────────────── */
.cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 18px;
transition: border-color .15s;
}
.card:hover { border-color: #484f58; }
.card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.card-title {
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.55;
}
.card-fields {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.field {
font-size: 10px;
font-family: var(--font-mono);
padding: 2px 7px;
border-radius: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--bg);
}
.card-file {
font-size: 11px;
font-family: var(--font-mono);
color: var(--accent);
margin-top: 6px;
opacity: .8;
}
/* ── Badges ─────────────────────────── */
.badge {
font-size: 10px;
font-weight: 600;
padding: 2px 9px;
border-radius: 20px;
white-space: nowrap;
flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-done { background: var(--green-dim); color: var(--green); }
.badge-partial { background: var(--orange-dim); color: var(--orange); }
.badge-pending { background: var(--red-dim); color: var(--red); }
.badge-db { background: var(--accent-dim); color: var(--accent); }
/* ── Sidebar ────────────────────────── */
.sidebar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
position: sticky;
top: 20px;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 12px;
color: var(--text-secondary);
}
.sidebar-item .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-green { background: var(--green); }
.dot-orange { background: var(--orange); }
.dot-red { background: var(--red); }
.sidebar-item .label { flex: 1; }
.sidebar-item .status {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-muted);
}
.sidebar-divider {
height: 1px;
background: var(--border-light);
margin: 10px 0;
}
/* ── Note box ──────────────────────── */
.note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 12px;
color: var(--text-secondary);
margin-top: 12px;
line-height: 1.6;
}
.note strong { color: var(--text); }
.note.warn { border-left-color: var(--orange); }
/* ── Legend ─────────────────────────── */
.legend {
display: flex;
gap: 18px;
justify-content: center;
margin-bottom: 28px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-secondary);
}
.legend-item .dot { width: 8px; height: 8px; border-radius: 50%; }
</style>
</head>
<body>
<!-- ══════════════════════════════════ HEADER ══════════════════════════════════ -->
<div class="page-header">
<h1>Documentos & Arquivos</h1>
<div class="subtitle">Status de implementacao confrontado com o banco de dados</div>
<div class="meta">
<span class="meta-tag">AgenciaPsi v5</span>
<span class="meta-tag">Vue 3 + Supabase</span>
<span class="meta-tag">Atualizado: 2026-03-30</span>
</div>
</div>
<!-- ══════════════════════════════════ STATS ═══════════════════════════════════ -->
<div class="summary-grid">
<div class="summary-card c-green">
<div class="number">6/6</div>
<div class="label">Tabelas</div>
</div>
<div class="summary-card c-green">
<div class="number">2/2</div>
<div class="label">Buckets</div>
</div>
<div class="summary-card c-green">
<div class="number">7/7</div>
<div class="label">Services</div>
</div>
<div class="summary-card c-green">
<div class="number">3/3</div>
<div class="label">Composables</div>
</div>
<div class="summary-card c-green">
<div class="number">10/10</div>
<div class="label">Componentes</div>
</div>
<div class="summary-card c-green">
<div class="number">14</div>
<div class="label">Templates seed</div>
</div>
</div>
<!-- ══════════════════════════════════ LEGENDA ═════════════════════════════════ -->
<div class="legend">
<div class="legend-item"><span class="dot dot-green"></span> Implementado</div>
<div class="legend-item"><span class="dot dot-orange"></span> Parcial / Migration pendente</div>
<div class="legend-item"><span class="dot dot-red"></span> Nao implementado</div>
</div>
<!-- ══════════════════════════════════ 1. UPLOAD & ORGANIZACAO ═════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">1</div>
<div class="section-title">Upload & Organizacao de Arquivos</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Upload de arquivo ao paciente</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">PDF, imagem, DOCX. Vinculado ao patient_id. Supabase Storage com path estruturado. Drag & drop + seletor. Validacao de tamanho (50MB) e tipo MIME.</div>
<div class="card-fields">
<span class="field">patient_id</span><span class="field">bucket_path</span><span class="field">storage_bucket</span>
<span class="field">nome_original</span><span class="field">mime_type</span><span class="field">tamanho_bytes</span>
<span class="field">uploaded_by</span><span class="field">uploaded_at</span>
</div>
<div class="card-file">Documents.service.js &rarr; uploadDocument()</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Tipo, categoria & tags</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">11 tipos (laudo, receita, exame, atestado, declaracao, recibo, etc.). Categoria livre. Tags[] com autocomplete. Filtros na listagem.</div>
<div class="card-fields">
<span class="field">tipo_documento</span><span class="field">categoria</span><span class="field">descricao</span><span class="field">tags[]</span>
</div>
<div class="card-file">DB: CHECK constraint + GIN index em tags</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Vinculo com sessao</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Arquivo linkado a agenda_eventos (sessao) ou session_note. Colunas nullable — nem todo arquivo tem sessao.</div>
<div class="card-fields">
<span class="field">agenda_evento_id</span><span class="field">session_note_id</span>
</div>
<div class="card-file">DB: FK para agenda_eventos (ON DELETE SET NULL)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Visibilidade & controle de acesso</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Privado, compartilhado com supervisor, ou visivel no portal do paciente. Granular por arquivo. Expiracao de compartilhamento.</div>
<div class="card-fields">
<span class="field">visibilidade</span><span class="field">compartilhado_portal</span><span class="field">compartilhado_supervisor</span>
<span class="field">compartilhado_em</span><span class="field">expira_compartilhamento</span>
</div>
<div class="card-file">DB: CHECK (privado | compartilhado_supervisor | compartilhado_portal)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Soft delete com retencao LGPD</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Arquivo "excluido" some da UI mas fica retido por 5 anos (CFP). Colunas de controle + index parcial para listagem ativa.</div>
<div class="card-fields">
<span class="field">deleted_at</span><span class="field">deleted_by</span><span class="field">retencao_ate</span>
</div>
<div class="card-file">DB: idx_documents_active (WHERE deleted_at IS NULL)</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Preview & download</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Preview inline de PDF e imagens via dialog. Download com URL assinada (60s). Suporte a storage_bucket dinamico (documents ou generated-docs).</div>
<div class="card-file">DocumentPreviewDialog.vue + getDownloadUrl(path, expires, bucket)</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">documents</span><span class="status">27 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS owner_id</span><span class="status">ativo</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Indexes</span><span class="status">9</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Trigger timeline</span><span class="status">insert</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Storage</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">documents</span><span class="status">50MB</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">generated-docs</span><span class="status">20MB</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentsListPage</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentCard</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentUploadDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentPreviewDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">DocumentTagsInput</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">useDocuments.js</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 2. GERACAO DE DOCUMENTOS ════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">2</div>
<div class="section-title">Geracao de Documentos (PDF)</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Templates de documentos</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">16 tipos de template. 14 templates globais no seed. Corpo HTML com {{variaveis}}. Cabecalho/rodape personalizaveis. Templates por tenant + globais do sistema.</div>
<div class="card-fields">
<span class="field">nome_template</span><span class="field">tipo</span><span class="field">corpo_html</span>
<span class="field">cabecalho_html</span><span class="field">rodape_html</span><span class="field">variaveis[]</span>
<span class="field">is_global</span><span class="field">logo_url</span><span class="field">ativo</span>
</div>
<div class="card-file">document_templates (DB) + seed_015_document_templates.sql</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Geracao de PDF (client-side)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">jsPDF + html2canvas-pro (substituiu pdfmake por incompatibilidade com Vite). Renderiza HTML preenchido em canvas, converte para PDF A4 com paginacao. JPEG 85%, scale 1.5. ~200-400KB por documento.</div>
<div class="card-fields">
<span class="field">buildFullHtml()</span><span class="field">htmlToPdfBlob()</span><span class="field">fillTemplate()</span>
</div>
<div class="card-file">pdf.service.js + DocumentGenerate.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Documento gerado (instancia + listagem)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Cada PDF gerado: salva snapshot em document_generated (dados preenchidos para auditoria) E automaticamente registra na tabela documents (para aparecer na listagem do paciente). Bucket: generated-docs. Nomes sanitizados (sem acentos).</div>
<div class="card-fields">
<span class="field">template_id</span><span class="field">dados_preenchidos</span><span class="field">pdf_path</span>
<span class="field">gerado_em</span><span class="field">gerado_por</span><span class="field">&rarr; documents</span>
</div>
<div class="card-file">saveGeneratedDocument() &rarr; document_generated + documents</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Fluxo de geracao (UI)</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Dialog 3 etapas: selecionar template &rarr; editar variaveis (auto-preenchidas com dados paciente/sessao/terapeuta/clinica) &rarr; preview via iframe sandbox &rarr; "Salvar documento" (online) ou "So baixar" (local).</div>
<div class="card-file">DocumentGenerateDialog.vue + useDocumentGenerate.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Dados da clinica no template</div>
<span class="badge badge-partial">parcial</span>
</div>
<div class="card-desc">loadClinicData() usa select('*') na tabela tenants. Atualmente so retorna name. Campos phone, contact_email, logradouro, numero, bairro, cidade, estado dependem da migration 003_tenants_address_fields.sql ser aplicada.</div>
<div class="card-file">Migration pendente: 003_tenants_address_fields.sql</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Editor de templates</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Editor rich text para corpo HTML. Insercao de variaveis via dropdown. Preview ao vivo. Config de cabecalho/rodape/logo. Gestao de templates globais e por tenant.</div>
<div class="card-file">DocumentTemplateEditor.vue + DocumentTemplatesPage.vue</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_templates</span><span class="status">15 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_generated</span><span class="status">10 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS</span><span class="status">ativo</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">14 seeds globais</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Motor PDF</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">jsPDF</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">html2canvas-pro</span></div>
<div class="sidebar-item"><span class="dot dot-red"></span><span class="label">pdfmake</span><span class="status">removido</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Pendencias</div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">tenants address</span><span class="status">migration</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">terapeuta_crp</span><span class="status">campo</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 3. ASSINATURA ELETRONICA ════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon purple">3</div>
<div class="section-title">Assinatura Eletronica</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> TCLE & consentimento</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Tabela document_signatures com rastreamento completo: IP, timestamp, hash SHA-256, user_agent. Suporte a 3 tipos de signatario (paciente, responsavel_legal, terapeuta). 5 status possiveis.</div>
<div class="card-fields">
<span class="field">documento_id</span><span class="field">signatario_tipo</span><span class="field">signatario_id</span>
<span class="field">ordem</span><span class="field">status</span><span class="field">ip</span>
<span class="field">hash_documento</span><span class="field">assinado_em</span>
</div>
<div class="card-file">document_signatures (DB) + DocumentSignatures.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> UI de assinatura</div>
<span class="badge badge-partial">parcial</span>
</div>
<div class="card-desc">Componente DocumentSignatureDialog.vue existe. Service DocumentSignatures.service.js existe. Fluxo completo de envio por link e assinatura pelo paciente ainda precisa ser validado end-to-end.</div>
<div class="card-file">DocumentSignatureDialog.vue</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_signatures</span><span class="status">14 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS tenant</span><span class="status">ativo</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Trigger timeline</span><span class="status">assinado</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">SignatureDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Signatures.service</span></div>
<div class="sidebar-item"><span class="dot dot-orange"></span><span class="label">Fluxo e2e</span><span class="status">validar</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 4. COMPARTILHAMENTO ═════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">4</div>
<div class="section-title">Compartilhamento & Portal do Paciente</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Links temporarios de acesso</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Token hex 32 bytes, prazo de expiracao, limite de usos. RLS publica por token valido. Link seguro sem necessidade de login.</div>
<div class="card-fields">
<span class="field">token</span><span class="field">expira_em</span><span class="field">usos_max</span>
<span class="field">usos</span><span class="field">ativo</span><span class="field">criado_por</span>
</div>
<div class="card-file">document_share_links (DB) + DocumentShareLinks.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Documentos compartilhados com paciente</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Terapeuta decide quais arquivos ficam visiveis pro paciente. Campos compartilhado_portal e expira_compartilhamento na tabela documents.</div>
<div class="card-fields">
<span class="field">compartilhado_portal</span><span class="field">compartilhado_em</span><span class="field">expira_compartilhamento</span>
</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Upload pelo paciente</div>
<span class="badge badge-done">db pronto</span>
</div>
<div class="card-desc">Paciente envia exames/laudos pelo portal. Fila de "pendentes de revisao" para o terapeuta aprovar.</div>
<div class="card-fields">
<span class="field">enviado_pelo_paciente</span><span class="field">status_revisao</span>
<span class="field">revisado_por</span><span class="field">revisado_em</span>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_share_links</span><span class="status">10 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS token publico</span><span class="status">ativo</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Frontend</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">ShareDialog</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">ShareLinks.service</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">SharedDocumentPage</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ 5. AUDITORIA ═══════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon red">5</div>
<div class="section-title">Auditoria & Conformidade</div>
</div>
<div class="content-row">
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Log de acesso a arquivos</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Tabela imutavel (somente INSERT + SELECT, sem UPDATE/DELETE). Cada visualizacao ou download registrado. Conformidade CFP e LGPD. Integrado no composable useDocuments (logAccess automatico).</div>
<div class="card-fields">
<span class="field">documento_id</span><span class="field">acao</span><span class="field">user_id</span>
<span class="field">ip</span><span class="field">user_agent</span><span class="field">acessado_em</span>
</div>
<div class="card-file">document_access_logs (DB) + DocumentAuditLog.service.js</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-green"></span> Timeline do paciente</div>
<span class="badge badge-done">pronto</span>
</div>
<div class="card-desc">Triggers automaticos registram na patient_timeline quando: documento uploadado (INSERT em documents) e documento assinado (UPDATE em document_signatures).</div>
<div class="card-file">DB Triggers: trg_documents_timeline_insert + trg_ds_timeline</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Banco de Dados</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">document_access_logs</span><span class="status">8 cols</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">Imutavel</span><span class="status">no UPDATE</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">RLS tenant</span><span class="status">ativo</span></div>
<div class="sidebar-divider"></div>
<div class="sidebar-title">Acoes rastreadas</div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">visualizou</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">baixou</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">imprimiu</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">compartilhou</span></div>
<div class="sidebar-item"><span class="dot dot-green"></span><span class="label">assinou</span></div>
</div>
</div>
</div>
<!-- ══════════════════════════════════ PENDENCIAS ══════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">!</div>
<div class="section-title">Pendencias & Migrations Nao Aplicadas</div>
</div>
<div class="cards">
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Migration: tenants address fields</div>
<span class="badge badge-partial">pendente</span>
</div>
<div class="card-desc">003_tenants_address_fields.sql — adiciona cep, logradouro, numero, complemento, bairro, cidade, estado a tabela tenants. Tambem faltam phone e contact_email. Necessario para preencher variaveis clinica_endereco, clinica_telefone nos templates.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Campo CRP do terapeuta</div>
<span class="badge badge-partial">pendente</span>
</div>
<div class="card-desc">Variavel terapeuta_crp nos templates retorna vazio. O campo CRP nao existe na tabela profiles nem em tenant_members. Precisa de migration para adicionar coluna crp em profiles.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Fluxo de assinatura end-to-end</div>
<span class="badge badge-partial">validar</span>
</div>
<div class="card-desc">Tabela, service e componente existem. Falta validar: envio de link por email/whatsapp, pagina publica de assinatura, registro de IP/hash, notificacao ao terapeuta quando assinado.</div>
</div>
<div class="card">
<div class="card-top">
<div class="card-title"><span class="dot dot-orange"></span> Portal do paciente — visualizacao de docs</div>
<span class="badge badge-partial">validar</span>
</div>
<div class="card-desc">Campos compartilhado_portal e visibilidade existem no banco. SharedDocumentPage.vue existe. Falta validar se o portal do paciente (CadastroPacienteExterno) exibe corretamente os documentos compartilhados.</div>
</div>
</div>
<div class="note warn" style="margin-top: 14px;">
<strong>Decisao tecnica — Motor PDF:</strong> pdfmake foi substituido por jsPDF + html2canvas-pro. O pdfmake (UMD) trava silenciosamente com Vite (ESM) — createPdf().getBlob()/getBuffer() nunca retornam. html2canvas-pro e um fork open source (MIT) com suporte a cores oklch usadas pelo PrimeVue/Tailwind.
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,870 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plano de Implementacao — Modulo Documentos & Arquivos</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-card-hover: #1c2129;
--bg-table-head: #1c2129;
--bg-table-row: #161b22;
--bg-table-row-alt: #0d1117;
--border: #30363d;
--border-light: #21262d;
--text: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent: #58a6ff;
--accent-dim: #1f6feb33;
--green: #3fb950;
--green-dim: #23863633;
--orange: #d29922;
--orange-dim: #9e6a0333;
--purple: #bc8cff;
--purple-dim: #8957e533;
--red: #f85149;
--red-dim: #da363333;
--cyan: #39d2c0;
--cyan-dim: #1b7c6e33;
--pink: #f778ba;
--pink-dim: #db61a233;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
--radius: 8px;
--radius-lg: 12px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
padding: 32px 24px 64px;
max-width: 1200px;
margin: 0 auto;
}
/* ── Header ─────────────────────────── */
.page-header {
margin-bottom: 40px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.page-header h1 {
font-size: 28px;
font-weight: 700;
color: var(--text);
margin-bottom: 6px;
letter-spacing: -0.02em;
}
.page-header .subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.page-header .meta {
display: flex;
gap: 16px;
margin-top: 12px;
flex-wrap: wrap;
}
.meta-tag {
font-size: 12px;
font-family: var(--font-mono);
padding: 3px 10px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-card);
}
/* ── Summary cards ──────────────────── */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-bottom: 40px;
}
.summary-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
text-align: center;
}
.summary-card .number {
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.summary-card .label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2px;
}
.summary-card.c-blue .number { color: var(--accent); }
.summary-card.c-green .number { color: var(--green); }
.summary-card.c-orange .number { color: var(--orange); }
.summary-card.c-purple .number { color: var(--purple); }
.summary-card.c-cyan .number { color: var(--cyan); }
.summary-card.c-pink .number { color: var(--pink); }
/* ── Sections ───────────────────────── */
.section {
margin-bottom: 36px;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-light);
}
.section-icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
.section-icon.blue { background: var(--accent-dim); color: var(--accent); }
.section-icon.green { background: var(--green-dim); color: var(--green); }
.section-icon.orange { background: var(--orange-dim); color: var(--orange); }
.section-icon.purple { background: var(--purple-dim); color: var(--purple); }
.section-icon.cyan { background: var(--cyan-dim); color: var(--cyan); }
.section-icon.pink { background: var(--pink-dim); color: var(--pink); }
.section-icon.red { background: var(--red-dim); color: var(--red); }
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.section-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: -6px;
margin-bottom: 14px;
padding-left: 38px;
}
/* ── Tables ─────────────────────────── */
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
thead th {
background: var(--bg-table-head);
color: var(--text-secondary);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
}
tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--border-light);
vertical-align: top;
}
tbody tr:nth-child(odd) { background: var(--bg-table-row); }
tbody tr:nth-child(even) { background: var(--bg-table-row-alt); }
tbody tr:hover { background: var(--bg-card-hover); }
tbody tr:last-child td { border-bottom: none; }
.col-file {
font-family: var(--font-mono);
font-size: 12px;
color: var(--accent);
white-space: nowrap;
}
.col-table {
font-family: var(--font-mono);
font-size: 12px;
color: var(--green);
white-space: nowrap;
}
.col-route {
font-family: var(--font-mono);
font-size: 12px;
color: var(--orange);
}
.col-key {
font-family: var(--font-mono);
font-size: 12px;
color: var(--purple);
}
.col-bucket {
font-family: var(--font-mono);
font-size: 12px;
color: var(--cyan);
}
/* ── Field chips ────────────────────── */
.fields {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.field {
font-size: 10px;
font-family: var(--font-mono);
padding: 2px 7px;
border-radius: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--bg);
}
/* ── Notes ──────────────────────────── */
.note {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius);
padding: 12px 16px;
font-size: 13px;
color: var(--text-secondary);
margin-top: 12px;
line-height: 1.6;
}
/* ── Responsive ─────────────────────── */
@media (max-width: 700px) {
body { padding: 16px 12px 40px; }
.summary-grid { grid-template-columns: repeat(3, 1fr); }
table { font-size: 12px; }
thead th, tbody td { padding: 8px 10px; }
}
</style>
</head>
<body>
<!-- ════════════════════════════════════════ HEADER ════════════════════════════════════════ -->
<div class="page-header">
<h1>Plano de Implementacao — Documentos & Arquivos</h1>
<div class="subtitle">Modulo completo: upload, templates, geracao PDF, assinatura eletronica, portal do paciente, auditoria</div>
<div class="meta">
<span class="meta-tag">AgenciaPsi v5</span>
<span class="meta-tag">Vue 3 + Supabase</span>
<span class="meta-tag">2026-03-30</span>
<span class="meta-tag">Status: em andamento</span>
</div>
</div>
<!-- ════════════════════════════════════════ RESUMO ═══════════════════════════════════════ -->
<div class="summary-grid">
<div class="summary-card c-blue">
<div class="number">6</div>
<div class="label">Tabelas</div>
</div>
<div class="summary-card c-cyan">
<div class="number">2</div>
<div class="label">Buckets</div>
</div>
<div class="summary-card c-green">
<div class="number">7</div>
<div class="label">Services</div>
</div>
<div class="summary-card c-orange">
<div class="number">3</div>
<div class="label">Composables</div>
</div>
<div class="summary-card c-purple">
<div class="number">~10</div>
<div class="label">Componentes</div>
</div>
<div class="summary-card c-pink">
<div class="number">5</div>
<div class="label">Feature flags</div>
</div>
</div>
<!-- ════════════════════════════════════════ 1. BANCO ═════════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">1</div>
<div class="section-title">Banco de Dados — Migrations</div>
</div>
<div class="section-desc">Tabelas, RLS policies, indexes, triggers</div>
<table>
<thead>
<tr>
<th>Migration</th>
<th>Tabela / Objeto</th>
<th>O que faz</th>
<th>Campos principais</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file" rowspan="4">005_create_documents_tables.sql</td>
<td class="col-table">documents</td>
<td>Arquivo vinculado a paciente. Path no Supabase Storage, tipo/categoria, visibilidade, tags, soft delete com retencao LGPD. Tabela central do modulo. O campo storage_bucket indica qual bucket do Storage contem o arquivo (documents ou generated-docs), permitindo que PDFs gerados aparecam na mesma listagem.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">patient_id</span>
<span class="field">tenant_id</span>
<span class="field">owner_id</span>
<span class="field">bucket_path</span>
<span class="field">storage_bucket</span>
<span class="field">nome_original</span>
<span class="field">mime_type</span>
<span class="field">tamanho_bytes</span>
<span class="field">tipo_documento</span>
<span class="field">categoria</span>
<span class="field">descricao</span>
<span class="field">tags[]</span>
<span class="field">visibilidade</span>
<span class="field">compartilhado_portal</span>
<span class="field">compartilhado_supervisor</span>
<span class="field">agenda_evento_id</span>
<span class="field">session_note_id</span>
<span class="field">enviado_pelo_paciente</span>
<span class="field">status_revisao</span>
<span class="field">revisado_por</span>
<span class="field">revisado_em</span>
<span class="field">uploaded_by</span>
<span class="field">uploaded_at</span>
<span class="field">deleted_at</span>
<span class="field">deleted_by</span>
<span class="field">retencao_ate</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_access_logs</td>
<td>Log imutavel de quem visualizou ou baixou cada arquivo. Conformidade CFP e LGPD. Sem UPDATE/DELETE — somente INSERT e SELECT.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">documento_id</span>
<span class="field">acao</span>
<span class="field">user_id</span>
<span class="field">ip</span>
<span class="field">user_agent</span>
<span class="field">acessado_em</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_signatures</td>
<td>Assinaturas eletronicas. Cada signatario (paciente, responsavel, terapeuta) tem seu registro com IP, timestamp e hash do documento.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">documento_id</span>
<span class="field">signatario_tipo</span>
<span class="field">signatario_id</span>
<span class="field">ordem</span>
<span class="field">status</span>
<span class="field">ip</span>
<span class="field">user_agent</span>
<span class="field">assinado_em</span>
<span class="field">hash_documento</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_share_links</td>
<td>Links temporarios assinados para compartilhar documento com profissional externo sem conta no sistema. Prazo e limite de usos.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">documento_id</span>
<span class="field">token</span>
<span class="field">expira_em</span>
<span class="field">usos_max</span>
<span class="field">usos</span>
<span class="field">criado_por</span>
<span class="field">criado_em</span>
</div>
</td>
</tr>
<tr>
<td class="col-file" rowspan="2">006_create_document_templates.sql</td>
<td class="col-table">document_templates</td>
<td>Templates de documentos (declaracao de comparecimento, atestado, recibo etc.). Corpo HTML com variaveis. Templates globais do sistema + personalizados por tenant com logo/cabecalho.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">tenant_id</span>
<span class="field">nome_template</span>
<span class="field">tipo</span>
<span class="field">corpo_html</span>
<span class="field">variaveis[]</span>
<span class="field">is_global</span>
<span class="field">owner_id</span>
<span class="field">logo_url</span>
<span class="field">cabecalho_html</span>
<span class="field">rodape_html</span>
<span class="field">ativo</span>
</div>
</td>
</tr>
<tr>
<td class="col-table">document_generated</td>
<td>Cada PDF gerado a partir de um template. Guarda os dados usados no preenchimento e o path do PDF resultante no Storage.</td>
<td>
<div class="fields">
<span class="field">id</span>
<span class="field">template_id</span>
<span class="field">patient_id</span>
<span class="field">tenant_id</span>
<span class="field">dados_preenchidos</span>
<span class="field">pdf_path</span>
<span class="field">gerado_em</span>
<span class="field">gerado_por</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 2. STORAGE ═══════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">2</div>
<div class="section-title">Supabase Storage — Buckets</div>
</div>
<table>
<thead>
<tr>
<th>Bucket</th>
<th>Uso</th>
<th>Path pattern</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-bucket">documents</td>
<td>Arquivos enviados por terapeuta ou paciente (PDF, imagem, DOCX, etc.)</td>
<td class="col-file">{tenant_id}/{patient_id}/{timestamp}-{filename}</td>
</tr>
<tr>
<td class="col-bucket">generated-docs</td>
<td>PDFs gerados pelo sistema a partir de templates. Referenciado tanto por document_generated (snapshot) quanto por documents (listagem do paciente) via campo storage_bucket.</td>
<td class="col-file">{tenant_id}/{patient_id}/{template_nome_sanitizado}_{timestamp}.pdf</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 3. SERVICES ══════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">3</div>
<div class="section-title">Services — Camada de dados</div>
</div>
<div class="section-desc">src/services/ — seguem o padrao Medicos.service.js (getOwnerId + getActiveTenantId + CRUD)</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>O que faz</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">Documents.service.js</td>
<td>CRUD completo de documentos: upload ao Storage + insert no banco, listagem por paciente com filtros (tipo, categoria, tags), soft delete com retencao, restauracao, download com URL assinada</td>
</tr>
<tr>
<td class="col-file">DocumentTemplates.service.js</td>
<td>CRUD de templates: criar/editar templates (globais e por tenant), listar variaveis disponiveis, duplicar template, ativar/desativar</td>
</tr>
<tr>
<td class="col-file">DocumentGenerate.service.js</td>
<td>Gerar PDF a partir de template: preencher variaveis com dados do paciente/sessao, renderizar HTML para PDF via pdf.service.js (jsPDF + html2canvas-pro), salvar no bucket generated-docs, registrar em document_generated E automaticamente na tabela documents (para aparecer na listagem do paciente). Nomes de arquivo sanitizados (sem acentos) para compatibilidade com Supabase Storage.</td>
</tr>
<tr>
<td class="col-file">pdf.service.js</td>
<td>Servico de geracao de PDF client-side usando jsPDF + html2canvas-pro. Substitui pdfmake que apresenta incompatibilidade com Vite (UMD vs ESM — getBlob/getBuffer travam silenciosamente). Recebe HTML completo, renderiza em canvas oculto (scale 1.5, JPEG 85%), gera PDF A4 com paginacao automatica. Retorna Blob para upload/download.</td>
</tr>
<tr>
<td class="col-file">DocumentSignatures.service.js</td>
<td>Criar solicitacao de assinatura, registrar assinatura (IP, hash, timestamp, user_agent), consultar status de cada signatario, verificar integridade via hash</td>
</tr>
<tr>
<td class="col-file">DocumentShareLinks.service.js</td>
<td>Gerar link temporario com token, validar token no acesso, registrar uso, expirar link</td>
</tr>
<tr>
<td class="col-file">DocumentAuditLog.service.js</td>
<td>Registrar log de acesso (visualizacao/download) e consultar historico de acessos por documento</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 4. COMPOSABLES ═══════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">4</div>
<div class="section-title">Composables — Logica reativa</div>
</div>
<div class="section-desc">src/features/documents/composables/</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>O que faz</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">useDocuments.js</td>
<td>State reativo: lista de documentos do paciente, loading, filtros ativos (tipo, categoria, tags), operacoes CRUD, refresh automatico apos upload/delete</td>
</tr>
<tr>
<td class="col-file">useDocumentTemplates.js</td>
<td>State reativo: lista de templates disponiveis (globais + tenant), preview com dados ficticios, variaveis extraidas do corpo HTML</td>
</tr>
<tr>
<td class="col-file">useDocumentGenerate.js</td>
<td>Logica de geracao: carregar dados do paciente/sessao, mapear variaveis, chamar servico de geracao, retornar URL do PDF</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 5. PAGINAS & COMPONENTES ═════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon purple">5</div>
<div class="section-title">Paginas & Componentes Vue</div>
</div>
<div class="section-desc">src/features/documents/</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>Tipo</th>
<th>O que faz</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">DocumentsListPage.vue</td>
<td>Pagina</td>
<td>Pagina principal — lista todos os documentos do paciente com DataTable, filtros (tipo, categoria, tags), botoes de upload, preview, download. Hero header sticky com stats rapidos.</td>
</tr>
<tr>
<td class="col-file">DocumentUploadDialog.vue</td>
<td>Dialog</td>
<td>Upload de arquivo — drag & drop ou seletor, campos: tipo do documento, categoria, descricao, tags, vinculo com sessao (opcional), visibilidade. Validacao de tamanho e tipo de arquivo.</td>
</tr>
<tr>
<td class="col-file">DocumentPreviewDialog.vue</td>
<td>Dialog</td>
<td>Preview inline — renderiza PDF/imagem no dialog. Botoes: download, compartilhar, solicitar assinatura, excluir. Exibe metadados (tipo, tags, quem enviou, data).</td>
</tr>
<tr>
<td class="col-file">DocumentTemplatesPage.vue</td>
<td>Pagina</td>
<td>Gestao de templates — lista templates disponiveis (globais + do tenant), criar novo, editar, duplicar, ativar/desativar. Cards com preview do template.</td>
</tr>
<tr>
<td class="col-file">DocumentTemplateEditor.vue</td>
<td>Componente</td>
<td>Editor de template — edicao do corpo HTML (editor rich text), insercao de variaveis via dropdown, preview ao vivo com dados ficticios, config de cabecalho/rodape/logo.</td>
</tr>
<tr>
<td class="col-file">DocumentGenerateDialog.vue</td>
<td>Dialog</td>
<td>Gerar documento — selecionar template, campos preenchidos automaticamente com dados do paciente/sessao, edicao manual se necessario, preview final via iframe sandbox, botao "Salvar documento" (salva online, sem download automatico). Botao "So baixar" gera PDF local sem salvar no banco.</td>
</tr>
<tr>
<td class="col-file">DocumentSignatureDialog.vue</td>
<td>Dialog</td>
<td>Solicitar assinatura — adicionar signatarios (paciente, responsavel, terapeuta), definir ordem, enviar link por email/whatsapp, acompanhar status de cada signatario.</td>
</tr>
<tr>
<td class="col-file">DocumentShareDialog.vue</td>
<td>Dialog</td>
<td>Compartilhar — gerar link temporario com prazo (24h, 48h, 7d) e limite de usos, copiar link, enviar por email. Exibe links ja criados com status.</td>
</tr>
<tr>
<td class="col-file">components/DocumentCard.vue</td>
<td>Componente</td>
<td>Card reutilizavel de documento — thumbnail (icone por tipo ou preview de imagem), nome, tipo, data, tags, menu de acoes (3 dots).</td>
</tr>
<tr>
<td class="col-file">components/DocumentTagsInput.vue</td>
<td>Componente</td>
<td>Input de tags livres — chips editaveis com autocomplete baseado em tags ja usadas pelo terapeuta. Criacao de novas tags inline.</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 6. INTEGRACAO PRONTUARIO ═════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon pink">6</div>
<div class="section-title">Integracao com Prontuario (arquivo existente)</div>
</div>
<table>
<thead>
<tr>
<th>Arquivo existente</th>
<th>Alteracao</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">src/features/patients/prontuario/PatientProntuario.vue</td>
<td>Adicionar aba/secao "Documentos" que renderiza DocumentsListPage filtrada pelo patient_id atual. Botao rapido de upload direto do prontuario.</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 7. ROTAS ═════════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon orange">7</div>
<div class="section-title">Rotas</div>
</div>
<div class="section-desc">Adicionadas em routes.therapist.js e routes.clinic.js</div>
<table>
<thead>
<tr>
<th>Rota</th>
<th>Pagina</th>
<th>Descricao</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-route">/therapist/documents</td>
<td class="col-file">DocumentsListPage.vue</td>
<td>Lista geral de documentos (todos os pacientes do terapeuta)</td>
</tr>
<tr>
<td class="col-route">/therapist/documents/templates</td>
<td class="col-file">DocumentTemplatesPage.vue</td>
<td>Gestao de templates do terapeuta</td>
</tr>
<tr>
<td class="col-route">/therapist/patients/:id/documents</td>
<td class="col-file">DocumentsListPage.vue</td>
<td>Documentos de um paciente especifico (via props)</td>
</tr>
<tr>
<td class="col-route">/clinic/documents/templates</td>
<td class="col-file">DocumentTemplatesPage.vue</td>
<td>Templates da clinica (admin configura templates compartilhados)</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 8. MENUS ═════════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon green">8</div>
<div class="section-title">Menus de Navegacao</div>
</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>Item adicionado</th>
<th>Onde no menu</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">therapist.menu.js</td>
<td>"Documentos" — icon: pi-file, to: /therapist/documents</td>
<td>Grupo "Pacientes", abaixo de "Tags"</td>
</tr>
<tr>
<td class="col-file">therapist.menu.js</td>
<td>"Templates" — icon: pi-file-edit, to: /therapist/documents/templates</td>
<td>Sub-item de Documentos</td>
</tr>
<tr>
<td class="col-file">clinic.menu.js</td>
<td>"Templates de Documentos" — icon: pi-file-edit, to: /clinic/documents/templates</td>
<td>Grupo "Configuracoes"</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 9. SAAS FEATURES ═════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon red">9</div>
<div class="section-title">SaaS — Feature Flags</div>
</div>
<div class="section-desc">Inseridas em saas_features e vinculadas aos planos via plan_features</div>
<table>
<thead>
<tr>
<th>Feature key</th>
<th>Descricao</th>
<th>Planos</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-key">documents.upload</td>
<td>Upload de arquivos a pacientes — funcionalidade base</td>
<td>Free + Pro</td>
</tr>
<tr>
<td class="col-key">documents.templates</td>
<td>Templates de documentos (declaracao, atestado, recibo etc.)</td>
<td>Pro</td>
</tr>
<tr>
<td class="col-key">documents.signatures</td>
<td>Assinatura eletronica (TCLE, consentimentos)</td>
<td>Pro</td>
</tr>
<tr>
<td class="col-key">documents.share_links</td>
<td>Links temporarios para compartilhamento externo</td>
<td>Pro</td>
</tr>
<tr>
<td class="col-key">documents.patient_portal</td>
<td>Paciente visualiza e envia documentos pelo portal</td>
<td>Pro</td>
</tr>
</tbody>
</table>
</div>
<!-- ════════════════════════════════════════ 10. SEED DATA ════════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon cyan">10</div>
<div class="section-title">Seed Data — Templates Padrao</div>
</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>O que insere</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-file">seed_015_document_templates.sql</td>
<td>
4 templates globais (is_global = true) com corpo HTML e variaveis mapeadas:
<div class="fields" style="margin-top: 8px;">
<span class="field">Declaracao de Comparecimento</span>
<span class="field">Atestado Psicologico</span>
<span class="field">Relatorio de Acompanhamento</span>
<span class="field">Recibo de Pagamento</span>
</div>
</td>
</tr>
</tbody>
</table>
<div class="note">
<strong>Variaveis dos templates:</strong> {{paciente_nome}}, {{paciente_cpf}}, {{data_sessao}}, {{hora_inicio}}, {{hora_fim}}, {{terapeuta_nome}}, {{terapeuta_crp}}, {{clinica_nome}}, {{clinica_endereco}}, {{valor}}, {{data_atual}}, entre outras. Cada template define quais variaveis utiliza no campo variaveis[].
</div>
<div class="note" style="border-left-color: var(--orange); margin-top: 8px;">
<strong>Decisao tecnica — Motor PDF:</strong> pdfmake foi substituido por jsPDF + html2canvas-pro. O pdfmake (UMD) trava silenciosamente com Vite (ESM) — createPdf().getBlob()/getBuffer() nunca retornam, mesmo com optimizeDeps configurado. A solucao final usa html2canvas-pro (fork com suporte a cores oklch do PrimeVue/Tailwind) para renderizar o HTML preenchido em canvas, e jsPDF para converter em PDF A4 com paginacao. Resultado: ~200-400KB por documento (JPEG 85%, scale 1.5).
</div>
</div>
<!-- ════════════════════════════════════════ ORDEM DE EXECUCAO ════════════════════════════ -->
<div class="section">
<div class="section-header">
<div class="section-icon blue">!</div>
<div class="section-title">Ordem de Execucao Sugerida</div>
</div>
<table>
<thead>
<tr>
<th>Fase</th>
<th>O que</th>
<th>Depende de</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>1</strong></td>
<td>Migrations (tabelas, RLS, triggers, indexes)</td>
<td></td>
</tr>
<tr>
<td><strong>2</strong></td>
<td>Buckets no Supabase Storage</td>
<td>Fase 1</td>
</tr>
<tr>
<td><strong>3</strong></td>
<td>Services (camada de dados)</td>
<td>Fase 1 + 2</td>
</tr>
<tr>
<td><strong>4</strong></td>
<td>Composables (logica reativa)</td>
<td>Fase 3</td>
</tr>
<tr>
<td><strong>5</strong></td>
<td>Componentes e Paginas Vue</td>
<td>Fase 4</td>
</tr>
<tr>
<td><strong>6</strong></td>
<td>Rotas, menus, feature flags</td>
<td>Fase 5</td>
</tr>
<tr>
<td><strong>7</strong></td>
<td>Integracao com Prontuario</td>
<td>Fase 5</td>
</tr>
<tr>
<td><strong>8</strong></td>
<td>Seed data (templates padrao)</td>
<td>Fase 1</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,372 @@
<style>
* { box-sizing: border-box; }
.page { padding: 0 0 32px; }
.section { margin-bottom: 28px; }
.section-title { font-size: 11px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-tertiary); margin: 0 0 10px; padding-bottom: 6px; border-bottom: 0.5px solid var(--color-border-tertiary); }
.cards { display: grid; gap: 10px; }
.cards-2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.cards-3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
.card { background: var(--color-background-primary); border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); padding: 14px 16px; }
.card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card-title { font-size: 13px; font-weight: 500; color: var(--color-text-primary); margin: 0; display: flex; align-items: center; gap: 7px; }
.card-desc { font-size: 12px; color: var(--color-text-secondary); line-height: 1.55; margin: 0; }
.card-fields { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 5px; }
.field { font-size: 11px; padding: 3px 8px; border-radius: 20px; border: 0.5px solid var(--color-border-secondary); color: var(--color-text-secondary); background: var(--color-background-secondary); font-family: var(--font-mono); }
.field-has { background: #EAF3DE; border-color: #C0DD97; color: #27500A; }
.field-miss { background: #FCEBEB; border-color: #F7C1C1; color: #791F1F; }
.badge { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 20px; white-space: nowrap; flex-shrink: 0; align-self: flex-start; margin-top: 1px; }
.badge-has { background: #EAF3DE; color: #27500A; }
.badge-part { background: #FAEEDA; color: #633806; }
.badge-miss { background: #FCEBEB; color: #791F1F; }
.badge-diff { background: #E6F1FB; color: #0C447C; }
@media (prefers-color-scheme: dark) {
.field-has { background: #173404; border-color: #27500A; color: #C0DD97; }
.field-miss { background: #501313; border-color: #791F1F; color: #F7C1C1; }
.badge-has { background: #173404; color: #C0DD97; }
.badge-part { background: #412402; color: #FAC775; }
.badge-miss { background: #501313; color: #F7C1C1; }
.badge-diff { background: #042C53; color: #B5D4F4; }
}
.icon-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; display: inline-block; margin-top: 3px; }
.dot-has { background: #639922; }
.dot-part { background: #EF9F27; }
.dot-miss { background: #E24B4A; }
.dot-diff { background: #378ADD; }
.legend { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-secondary); }
.sub { font-size: 11px; color: var(--color-text-tertiary); margin: 2px 0 6px; font-family: var(--font-mono); }
.note { font-size: 12px; color: var(--color-text-secondary); background: var(--color-background-secondary); border-left: 2px solid var(--color-border-secondary); padding: 8px 12px; margin-top: 10px; line-height: 1.5; border-radius: 0; }
</style>
<div class="page">
<div class="legend">
<div class="legend-item"><span class="icon-dot dot-has"></span> você já tem</div>
<div class="legend-item"><span class="icon-dot dot-part"></span> tem parcialmente</div>
<div class="legend-item"><span class="icon-dot dot-miss"></span> faltando</div>
<div class="legend-item"><span class="icon-dot dot-diff"></span> diferencial de mercado</div>
</div>
<div class="section">
<div class="section-title">1 · Identificação & dados pessoais</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Dados básicos de identificação</div><div class="sub">núcleo do cadastro</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Nome, email, telefone, data de nascimento, CPF, RG, gênero, naturalidade, estado civil, escolaridade e profissão.</div>
<div class="card-fields">
<span class="field field-has">nome_completo</span><span class="field field-has">email_principal</span><span class="field field-has">telefone</span><span class="field field-has">data_nascimento</span><span class="field field-has">cpf</span><span class="field field-has">rg</span><span class="field field-has">genero</span><span class="field field-has">estado_civil</span><span class="field field-has">escolaridade</span><span class="field field-has">profissao</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Gênero & pronomes</div><div class="sub">campo genero existe, pronomes não</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem o campo <span style="font-family:var(--font-mono);font-size:11px">genero</span> como texto livre. Faltam pronomes preferidos (ele/ela/eles) — padrão nos sistemas modernos de saúde mental, especialmente para público LGBTQIA+.</div>
<div class="card-fields">
<span class="field field-has">genero</span>
<span class="field field-miss">pronomes</span>
<span class="field field-miss">nome_social</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Endereço completo</div><div class="sub">CEP, cidade, estado, complemento</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">CEP, endereço, número, bairro, complemento, cidade, estado e país. Estrutura adequada.</div>
<div class="card-fields">
<span class="field field-has">cep</span><span class="field field-has">endereco</span><span class="field field-has">numero</span><span class="field field-has">bairro</span><span class="field field-has">cidade</span><span class="field field-has">estado</span><span class="field field-has">pais</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Dados socioeconômicos</div><div class="sub">renda e contexto social</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Faixa de renda, religião/espiritualidade, etnia. Campos opcionais mas relevantes clinicamente e para política de precificação solidária. SimplePractice e Psicologia Viva coletam isso.</div>
<div class="card-fields">
<span class="field field-miss">faixa_renda</span>
<span class="field field-miss">etnia</span>
<span class="field field-miss">religiao</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">2 · Contatos & rede de suporte</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Contato de emergência</div><div class="sub">só um contato, sem estrutura</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem <span style="font-family:var(--font-mono);font-size:11px">nome_parente</span>, <span style="font-family:var(--font-mono);font-size:11px">grau_parentesco</span> e <span style="font-family:var(--font-mono);font-size:11px">telefone_parente</span> como campos soltos na tabela. Falta suporte a múltiplos contatos e campo de email do contato.</div>
<div class="card-fields">
<span class="field field-has">nome_parente</span><span class="field field-has">grau_parentesco</span><span class="field field-has">telefone_parente</span>
<span class="field field-miss">email_contato</span><span class="field field-miss">multiplos_contatos</span><span class="field field-miss">contato_primario</span>
</div>
<div class="note">Ideal: tabela separada <span style="font-family:var(--font-mono)">patient_contacts</span> com N contatos por paciente.</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Responsável legal</div><div class="sub">para menores de idade</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Nome, CPF, telefone do responsável e flag de cobrança no responsável. Cobre bem o caso de pacientes menores.</div>
<div class="card-fields">
<span class="field field-has">nome_responsavel</span><span class="field field-has">telefone_responsavel</span><span class="field field-has">cpf_responsavel</span><span class="field field-has">cobranca_no_responsavel</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Outros profissionais de saúde</div><div class="sub">psiquiatra, médico, nutricionista</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Nome e contato do psiquiatra, médico ou outros profissionais que acompanham o paciente. Essencial para coordenação de cuidados. Presente no SimplePractice e TheraNest.</div>
<div class="card-fields">
<span class="field field-miss">nome_profissional</span><span class="field field-miss">especialidade</span><span class="field field-miss">telefone_profissional</span><span class="field field-miss">email_profissional</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Preferências de comunicação</div><div class="sub">como o paciente quer ser contatado</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Canal preferido (WhatsApp, email, SMS), horário preferido para contato, idioma preferido. Alimenta diretamente os lembretes automáticos com as preferências do paciente.</div>
<div class="card-fields">
<span class="field field-miss">canal_preferido</span><span class="field field-miss">horario_contato</span><span class="field field-miss">idioma</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">3 · Origem & encaminhamento</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Como chegou ao terapeuta</div><div class="sub">campos existem mas são texto livre</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem <span style="font-family:var(--font-mono);font-size:11px">onde_nos_conheceu</span> e <span style="font-family:var(--font-mono);font-size:11px">encaminhado_por</span> como texto livre. Ideal ser enum + texto opcional para permitir filtros e relatórios de origem.</div>
<div class="card-fields">
<span class="field field-has">onde_nos_conheceu</span><span class="field field-has">encaminhado_por</span>
<span class="field field-miss">origem_enum</span><span class="field field-miss">agendador_publico_ref</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Motivo de inatividade ou alta</div><div class="sub">por que o paciente saiu</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Quando paciente vai para "Alta", "Inativo" ou "Encaminhado" — qual o motivo? Alta terapêutica, abandono, encaminhamento, mudança de cidade. Essencial para relatórios e qualidade clínica.</div>
<div class="card-fields">
<span class="field field-miss">motivo_saida</span><span class="field field-miss">data_saida</span><span class="field field-miss">encaminhado_para</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">4 · Status & ciclo de vida do paciente</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Status do paciente</div><div class="sub">Ativo, Inativo, Alta, Encaminhado, Arquivado</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Enum bem definido com os 5 status mais relevantes. Constraint no banco garante integridade.</div>
<div class="card-fields">
<span class="field field-has">Ativo</span><span class="field field-has">Inativo</span><span class="field field-has">Alta</span><span class="field field-has">Encaminhado</span><span class="field field-has">Arquivado</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Histórico de mudanças de status</div><div class="sub">trilha de auditoria do ciclo de vida</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Quando o status mudou, quem mudou e por quê. Permite ver o histórico completo: "Ativo → Inativo (01/03) → Ativo (15/04)". Exigência de auditoria clínica.</div>
<div class="card-fields">
<span class="field field-miss">status_anterior</span><span class="field field-miss">status_novo</span><span class="field field-miss">motivo</span><span class="field field-miss">alterado_por</span><span class="field field-miss">alterado_em</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Escopo do paciente (clínica vs. terapeuta)</div><div class="sub">patient_scope bem modelado</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Distinção entre paciente da clínica (qualquer terapeuta pode atender) e paciente particular do terapeuta. Com constraint de consistência.</div>
<div class="card-fields">
<span class="field field-has">patient_scope</span><span class="field field-has">therapist_member_id</span><span class="field field-has">responsible_member_id</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Alerta & flag de risco</div><div class="sub">sinalização visível no topo do cadastro</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Flag booleano de risco elevado com nota associada. Exibe alerta vermelho no topo do cadastro e do prontuário. Terapeuta sinaliza pacientes que precisam de atenção especial (ideação, crise recente).</div>
<div class="card-fields">
<span class="field field-miss">risco_elevado</span><span class="field field-miss">nota_risco</span><span class="field field-miss">sinalizado_em</span><span class="field field-miss">sinalizado_por</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">5 · Organização & segmentação</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Tags de paciente</div><div class="sub">patient_tags + patient_patient_tag</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Tags com nome e cor, por tenant, com many-to-many. Bem estruturado.</div>
<div class="card-fields">
<span class="field field-has">patient_tags</span><span class="field field-has">patient_patient_tag</span><span class="field field-has">cor</span><span class="field field-has">is_padrao</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Grupos de pacientes</div><div class="sub">patient_groups com many-to-many</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Grupos com nome, cor, descrição, status ativo e flag de sistema. Relação many-to-many com <span style="font-family:var(--font-mono);font-size:11px">patient_group_patient</span>.</div>
<div class="card-fields">
<span class="field field-has">patient_groups</span><span class="field field-has">patient_group_patient</span><span class="field field-has">is_system</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Cor de identificação</div><div class="sub">identification_color na agenda</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Cor atribuída ao paciente para visualização rápida na agenda. Diferencial visual que poucos sistemas brasileiros têm.</div>
<div class="card-fields">
<span class="field field-has">identification_color</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Score de engajamento</div><div class="sub">calculado automaticamente</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Score calculado por view/função baseado em: frequência de sessões, taxa de comparecimento, dias desde última sessão, pagamentos em dia. Exibido como indicador no card do paciente. Ajuda a identificar quem precisa de atenção.</div>
<div class="card-fields">
<span class="field field-miss">engajamento_score</span><span class="field field-miss">taxa_comparecimento</span><span class="field field-miss">dias_sem_sessao</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">6 · Financeiro vinculado ao paciente</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-has"></span> Descontos individuais</div><div class="sub">patient_discounts bem modelado</div></div>
<span class="badge badge-has">completo</span>
</div>
<div class="card-desc">Desconto percentual ou fixo por paciente, com período de validade e motivo. Bem estruturado com active_from e active_to.</div>
<div class="card-fields">
<span class="field field-has">discount_pct</span><span class="field field-has">discount_flat</span><span class="field field-has">active_from</span><span class="field field-has">active_to</span><span class="field field-has">reason</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Limite de sessões por período</div><div class="sub">controle de plano ou convênio</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">Pacientes de convênio frequentemente têm limite de sessões autorizadas por mês. Campo para registrar o limite e controlar o consumo — alerta quando está próximo do teto.</div>
<div class="card-fields">
<span class="field field-miss">limite_sessoes_mes</span><span class="field field-miss">sessoes_usadas</span><span class="field field-miss">periodo_referencia</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-miss"></span> Método de pagamento preferido</div><div class="sub">como esse paciente costuma pagar</div></div>
<span class="badge badge-miss">faltando</span>
</div>
<div class="card-desc">PIX, cartão, dinheiro, convênio. Aparece como sugestão padrão ao registrar cobrança. Evita perguntar toda vez como o paciente paga.</div>
<div class="card-fields">
<span class="field field-miss">metodo_pagamento_preferido</span><span class="field field-miss">dados_pagamento_obs</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> LTV & métricas financeiras do paciente</div><div class="sub">calculado por view</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Total pago desde o início, ticket médio por sessão, total de sessões realizadas. Calculado por view em cima de financial_records — sem armazenar, sem inconsistência.</div>
<div class="card-fields">
<span class="field field-miss">v_patient_ltv</span><span class="field field-miss">total_pago</span><span class="field field-miss">ticket_medio</span><span class="field field-miss">total_sessoes</span>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">7 · Observações & notas internas</div>
<div class="cards cards-2">
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-part"></span> Observações gerais</div><div class="sub">dois campos de texto soltos</div></div>
<span class="badge badge-part">parcial</span>
</div>
<div class="card-desc">Você tem <span style="font-family:var(--font-mono);font-size:11px">observacoes</span> e <span style="font-family:var(--font-mono);font-size:11px">notas_internas</span> como campos de texto livre. Funciona, mas sem distinção clara de propósito ou histórico de edições.</div>
<div class="card-fields">
<span class="field field-has">observacoes</span><span class="field field-has">notas_internas</span>
<span class="field field-miss">historico_edicoes</span><span class="field field-miss">editado_por</span>
</div>
</div>
<div class="card">
<div class="card-header">
<div><div class="card-title"><span class="icon-dot dot-diff"></span> Linha do tempo do paciente</div><div class="sub">feed cronológico de tudo que aconteceu</div></div>
<span class="badge badge-diff">diferencial</span>
</div>
<div class="card-desc">Feed automático com eventos relevantes: "Primeira sessão", "Mudança de status", "Documento assinado", "Escala respondida", "Pagamento em atraso". Visível no topo do cadastro como timeline. SimplePractice tem isso.</div>
<div class="card-fields">
<span class="field field-miss">patient_timeline</span><span class="field field-miss">evento_tipo</span><span class="field field-miss">descricao</span><span class="field field-miss">ocorrido_em</span>
</div>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More