Compare commits

...

27 Commits

Author SHA1 Message Date
Leonardo 2644e60bb6 CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════

3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.

3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).

3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).

3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.

3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".

═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════

- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
  Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
  1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
  whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
  reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
  envolve envio em dedução atômica + rollback). Consumido por Evolution E
  Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
  MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand

═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════

Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
  transactions, packages, purchases) + RPCs add_whatsapp_credits e
  deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
  de 11 ou 14 dígitos)

Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
  QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
  quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance

Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
  utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
  persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
  error.context.json()

Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
  - Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
    toggle is_active inline, dialog de edição com validação
  - Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
    add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
    das últimas 20 transações topup/adjustment/refund

═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════

2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)

2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha

═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════

5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords

═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════

- contact_types + contact_phones (entity_type + entity_id) — migration
  20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
  remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
  unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
  legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
  e cria/atualiza phone como WhatsApp vinculado

═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════

- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
  .dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
  (fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
  error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
  Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
  sino + popup + browser notification)

═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════

20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj

═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════

Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound

═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════

Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits

Stores: conversationDrawerStore

Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor

Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)

Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados

═══════════════════════════════════════════════════════════════════════════
NOTAS

- Após subir, rodar supabase functions serve --no-verify-jwt
  --env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
  providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 07:05:24 -03:00
Leonardo 037ba3721f HANDOFF.md atualizado para Sessoes 1-10 + proxima sessao A#31-rev
Documento "ler primeiro ao voltar" reflete estado atual: 15 areas auditadas,
zero critico/alto aberto, A#31 reformulada como "Preparacao pra deploy"
(MVP nao tem cloud Supabase nem secrets reais).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:02:48 -03:00
Leonardo d6eb992f71 Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS
Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.

Ver commit.md para descricao completa por sessao.

# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15

# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)

# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql

Total acumulado: 18 migrations (Sessoes 1-10).

# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:00:06 -03:00
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00
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
656 changed files with 351287 additions and 25469 deletions
+2
View File
@@ -1,2 +1,4 @@
VITE_SUPABASE_URL=http://127.0.0.1:54321 VITE_SUPABASE_URL=http://127.0.0.1:54321
VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH VITE_SUPABASE_ANON_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
VITE_QA_MODE=true
VITE_QA_PASS=123Mudar@
+27 -3
View File
@@ -5,12 +5,36 @@ coverage
.nitro .nitro
.cache .cache
.output .output
# .env .env
dist .env.local
.env.*.local
dist/
dist-*/
.DS_Store .DS_Store
.idea .idea
.eslintcache .eslintcache
api-generator/typedoc.json api-generator/typedoc.json
**/.DS_Store **/.DS_Store
Dev-documentacao/ Dev-documentacao/
supabase/ supabase/*
!supabase/functions/
# Mas os .env dentro de functions NÃO vão pro git (sobrescreve a negação acima)
supabase/functions/.env
supabase/functions/.env.local
supabase/functions/.env.*
evolution-api/
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
database-novo/backups/
# Outputs do Playwright
test-results/
playwright-report/
# Config local do Claude Code (cada dev tem o seu)
.claude/settings.local.json
# Notas locais do dev e rascunhos de commit — não subir
informacoes Gerais.txt
pasteds.txt
commit.txt
-3
View File
@@ -1,3 +0,0 @@
[submodule "src/assets"]
path = src/assets
url = https://github.com/primefaces/sakai-assets.git
+15
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)"
]
}
]
}
}
+124 -39
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*
+138
View File
@@ -0,0 +1,138 @@
# Docker Setup — Projetos Locais
## Tabela Resumo
| Projeto | Container(s) | Porta Host | Rede | Volume(s) |
|---|---|---|---|---|
| **AgenciaPsi** | `agenciapsi_app` | `5173` → Vite dev | `agenciapsi_net` | `agenciapsi_node_modules` |
| | `agenciapsi_mysql` | `3307` → MySQL | `agenciapsi_net` | `agenciapsi_mysql_data` |
| **Evolution API** | `evolution_api` | `8080` → API | `agenciapsi_net` (external) | — |
| | `evolution_db` | interno | `agenciapsi_net` | `evolution_db_data` |
| | `evolution_redis` | interno | `agenciapsi_net` | — |
| | `evolution_mailpit` | `1025` SMTP / `8025` Web | `agenciapsi_net` | — |
| **Supabase AgenciaPsi** | `supabase_*_agenciapsi-primesakai` | `54321` API / `54322` PG / `54323` Studio | — | volumes internos |
| **Sakai-Vue** | `sakaivue_app` | `5174` → Vite dev | `sakaivue_net` | `sakaivue_node_modules` |
| | `sakaivue_mysql` | `3308` → MySQL | `sakaivue_net` | `sakaivue_mysql_data` |
| **Supabase Sakai-Vue** | `supabase_*_sakai-vue` | `54331` API / `54332` PG / `54333` Studio | — | volumes internos |
| **Gisaf Local** | `gisaf_mysql` | `3309` → MySQL | `gisaf_net` | `gisaf_mysql_data` |
## Mapa de Portas
| Porta | Serviço |
|---|---|
| 3307 | AgenciaPsi MySQL |
| 3308 | Sakai-Vue MySQL |
| 3309 | Gisaf MySQL |
| 5173 | AgenciaPsi Vite dev |
| 5174 | Sakai-Vue Vite dev |
| 8080 | Evolution API |
| 1025 | Mailpit SMTP |
| 8025 | Mailpit Web UI |
| 54321 | Supabase AgenciaPsi — Kong (API) |
| 54322 | Supabase AgenciaPsi — PostgreSQL |
| 54323 | Supabase AgenciaPsi — Studio |
| 54327 | Supabase AgenciaPsi — Analytics |
| 54331 | Supabase Sakai-Vue — Kong (API) |
| 54332 | Supabase Sakai-Vue — PostgreSQL |
| 54333 | Supabase Sakai-Vue — Studio |
| 54337 | Supabase Sakai-Vue — Analytics |
## Ordem de Start
```bash
# 1. AgenciaPsi (cria a rede agenciapsi_net)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
docker compose up -d
# 2. Supabase AgenciaPsi (porta 54321)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
npx supabase start
# 3. Evolution API (depende da rede agenciapsi_net)
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api"
docker compose up -d
# 4. Sakai-Vue
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
docker compose up -d
# 5. Supabase Sakai-Vue (porta 54331)
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
npx supabase start
# 6. Gisaf Local
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local"
docker compose up -d
```
## Parar tudo
```bash
# Na ordem inversa
cd "D:/leonohama/UniaoApp.com.br/Gisaf Local" && docker compose down
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && npx supabase stop
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue" && docker compose down
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/evolution-api" && docker compose down
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && npx supabase stop
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai" && docker compose down
```
## Caminhos dos docker-compose.yml
| Projeto | Caminho |
|---|---|
| AgenciaPsi | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\docker-compose.yml` |
| Evolution API | `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\evolution-api\docker-compose.yml` |
| Sakai-Vue | `D:\leonohama\UniaoApp.com.br\Sistema\sakai-vue\docker-compose.yml` |
| Gisaf Local | `D:\leonohama\UniaoApp.com.br\Gisaf Local\docker-compose.yml` |
## DBeaver — Conexões MySQL
| Conexão | Host | Port | Database | User | Password |
|---|---|---|---|---|---|
| Gisaf | `localhost` | `3309` | `sindsp` | `sindsp` | `marlboro` |
| AgenciaPsi | `localhost` | `3307` | `agenciapsi` | `agenciapsi` | `agenciapsi123` |
| Sakai-Vue | `localhost` | `3308` | `sakaivue` | `sakaivue` | `sakaivue123` |
Para criar cada conexão: **Database → New Database Connection → MySQL → preencher dados → Test Connection → Finish**
## Supabase — Instancias Locais
Cada projeto tem sua propria instancia Supabase (schemas diferentes, nao podem compartilhar).
| Projeto | API URL | Studio | PostgreSQL | Anon Key |
|---|---|---|---|---|
| AgenciaPsi | `http://127.0.0.1:54321` | `http://127.0.0.1:54323` | `127.0.0.1:54322` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
| Sakai-Vue | `http://127.0.0.1:54331` | `http://127.0.0.1:54333` | `127.0.0.1:54332` | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
**Resetar banco (aplica migrations + seed):**
```bash
# AgenciaPsi
cd "D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai"
npx supabase db reset
# Sakai-Vue
cd "D:/leonohama/UniaoApp.com.br/Sistema/sakai-vue"
npx supabase db reset
```
### Sakai-Vue — Usuarios de teste
| Email | Senha | Role |
|---|---|---|
| `dev@sistema.com.br` | `Dev@12345` | dev |
| `master@tenant.com.br` | `Master@12345` | master |
| `admin@tenant.com.br` | `Admin@12345` | admin |
| `chefe@tenant.com.br` | `Chefe@12345` | chefe_setor |
| `servidor@tenant.com.br` | `Servidor@12345` | servidor |
| `leitura@tenant.com.br` | `Leitura@12345` | leitura |
## Importar dump SQL no Gisaf
```bash
# Via CLI (já feito)
docker exec -i gisaf_mysql mysql -usindsp -pmarlboro sindsp < "D:/leonohama/UniaoApp.com.br/Gisaf Local/Dump20260330.sql"
```
Ou via DBeaver: conectar no banco `sindsp`**Tools → Execute SQL Script** → selecionar `Dump20260330.sql`
+194
View File
@@ -0,0 +1,194 @@
# HANDOFF — 2026-04-21 (CRM WhatsApp Grupo 3 + Marco B/credits + Asaas + polimento)
Documento de continuidade. **Quando voltar, comece lendo esta página.**
Todo o estado vive no banco (`/saas/desenvolvimento` → Auditoria/Verificações/Testes).
---
## 📊 Estado atual
| | |
|---|---|
| **🔴 Críticos** | **0** ✅ |
| **🟠 Altos** | **0** ✅ |
| 🟡 Médios adiados | 8 |
| 🟢 Baixos adiados | 7 |
| Vitest | 208/208 |
| SQL integration | 33/33 |
| E2E (Playwright) | 5/5 |
| Migrations totais | **36** (23 → 36) |
| Edge functions | **20** |
| Fases Opção C concluídas | **5 + 5b + Grupo 3 inteiro + Marco A + Marco B (Asaas) + admin SaaS** |
---
## 🎯 O que rolou hoje (2026-04-21)
### ✅ Grupo 3 completo — Workflow / CRM
- **3.1 Tags** — migration `conversation_tags` + 5 system tags seed · composable `useConversationTags.js` · popover + pills no drawer · pills nos cards do Kanban
- **3.2 Atribuição de conversa a terapeuta** (HOJE de tarde) — migration `conversation_assignments` (PK `(tenant_id, thread_key)`, UPSERT, RLS membro-ativo + valida assignee como membro do mesmo tenant) + view `conversation_threads` expandida com `assigned_to` · composable `useConversationAssignment.js` · drawer com Select filtrável + "Assumir" · inbox com filtro aside "Todas/Minhas/Não atribuídas" + chip no card
- **3.3 Notas internas** — `conversation_notes` + composable + seção colapsável no drawer
- **3.5 Converter número desconhecido em paciente** — botão + dialog quick-cadastro e "Vincular existente" com Select filter + 500 pacientes
- **3.6 Histórico de conversa no prontuário** (HOJE) — nova aba "Conversas" no `PatientProntuario.vue` com `PatientConversationsTab.vue` (stats + filter + timeline + mídia + "Abrir no CRM")
### ✅ Marco A — Unificação WhatsApp (dois providers)
- **Evolution (pessoal, free)** + **Twilio (AgenciaPSI Oficial, créditos)** — mutuamente exclusivos por tenant
- Página chooser `ConfiguracoesWhatsappChooserPage.vue` com 2 cards + deactivate via edge `deactivate-notification-channel`
- `send-whatsapp-message` refatorada — roteamento por provider; Twilio deduz crédito ANTES e refunda em falha
- **Paridade Twilio** (HOJE) — módulo compartilhado `supabase/functions/_shared/whatsapp-hooks.ts` (provider-agnóstico) consumido por Evolution **e** Twilio inbound. Hooks: opt-in/opt-out/auto-reply + schedule helpers + `makeTwilioCreditedSendFn` (dedução + rollback). Evolution refatorado (~290 linhas duplicadas removidas). Twilio agora roda mesmo pipeline de hooks (antes só inseria a mensagem e saía)
### ✅ Marco B — Sistema de créditos WhatsApp + Asaas
- Migration `whatsapp_credits` (4 tabelas: balance, transactions, packages, purchases) + 2 RPCs atômicas (`add_whatsapp_credits`, `deduct_whatsapp_credits`)
- Edge `create-whatsapp-credit-charge` — integração Asaas v3 (PIX sandbox + prod); `getOrCreateAsaasCustomer` patcha customer existente com CPF quando falta
- Edge `asaas-webhook` — recebe `PAYMENT_RECEIVED/CONFIRMED` e credita balance
- Página tenant `/configuracoes/creditos-whatsapp` — saldo + loja + histórico + dialog PIX com QR code
- **CPF/CNPJ no dialog de compra** (HOJE) — migration `20260421000013_tenant_cpf_cnpj.sql` (coluna + CHECK 11/14 dígitos), dialog de confirmação com validação (`isValidCPF`/`isValidCNPJ` de `utils/validators`), formatação on-blur, pré-fill de `tenants.cpf_cnpj`, persiste automaticamente no primeiro uso. Fallback sandbox removido
### ✅ Admin SaaS — gestão de créditos (HOJE)
Integrado em `/saas/addons` (reuso do pattern existente, não criou página paralela):
- **Aba 4 "Pacotes WhatsApp"** — CRUD `whatsapp_credit_packages` com DataTable (destaque, posição, preço + BRL/msg), toggle `is_active` inline, dialog de edição com validação
- **Aba 5 "Topup WhatsApp"** — tenant Select filtrável com saldo ao vivo; RPC `add_whatsapp_credits` com `p_admin_id = auth.uid()` (auditoria); histórico das últimas 20 transações topup/adjustment/refund
### ✅ Grupo 2 Automação
- **2.3 Auto-reply** — `conversation_autoreply_settings` + `conversation_autoreply_log`; schedule por modo (`agenda` / `business_hours` / `custom`); cooldown por thread; respeita opt-out; agora funciona em **ambos** providers
- **2.4 Lembretes de sessão** — `conversation_session_reminders_*`; edge `send-session-reminders` (cron); já trata Twilio com dedução + rollback
### ✅ Grupo 5 Compliance (LGPD Art. 18 §2)
- **5.2 Opt-out** — `conversation_optouts` + `conversation_optout_keywords` (10 keywords system + custom); detecção por regex word-boundary; ack automático; opt-in via "voltar/retornar/reativar/restart"
- Página `/configuracoes/conversas-optouts`
### ✅ Refactor polimórfico — telefones + emails
- `contact_types` + `contact_phones` (polimórfico: `entity_type` + `entity_id`) — migration 20260421000008
- `contact_email_types` + `contact_emails` — migration 20260421000011
- Componentes `ContactPhonesEditor.vue` + `ContactEmailsEditor.vue`
- Trocado em `PatientsCadastroPage.vue`, `MedicosPage.vue`
- Migration retroativa v2: detecta conversas e cria/atualiza phone como WhatsApp (vinculado)
### ✅ Polimento visual — sessão manhã
- Skeletons simplificados no dashboard terapeuta · animações fade-up com stagger por `[--delay:Xms]` (fix specificity sobre `.dash-card`)
- ConfirmDialog com `group="conversation-drawer"` (evita duplicata)
- Image preview PrimeVue com botão **download** injetado via MutationObserver (fetch + blob pra cross-origin)
- Audio/video com `preload="metadata"`, controles de velocidade herdados do browser
- `friendlySendError()` no drawer store — mapeia códigos pt-BR via `error.context.json()`
- Teleport `#cfg-page-actions` pra ações globais da Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
### ✅ Infra geral
- Bucket privado `whatsapp-media` + decrypt via Evolution `getBase64FromMediaMessage` + upload + signed URLs on-demand
- Realtime em `conversation_messages` via publication `supabase_realtime`
- `AppLayout` consolidado (removido duplicatas por área) + `RouterPassthrough`
- `console.trace` debug removido (watch router/Supabase) que degradava perf
---
## 🎯 Próxima sessão (começar por aqui)
Do CRM WhatsApp ainda restam:
### Grupo 3 (resto)
- **3.4 SLA / alerta** — conversa sem resposta > X min. Trigger `conversation_sla_rules` + worker cron
- **3.7 Bot auto-triagem** — pergunta nome/horário antes de sair pro humano
### Grupo 6 — Conexão resiliente
- **6.1 Heartbeat** — cron verifica Evolution; dispara incident se desconectado > N min
- **6.2 Alerta** quando celular desconecta (notification + e-mail admin tenant)
- **6.3 Reconnect automático** (tentar re-init da instância)
### Grupo 7 — Analytics
- **7.1 Tempo médio de primeira resposta** (card no ClinicDashboard + filtro por terapeuta)
### Grupo 8 — Integrações
- **8.2 Botão na agenda** "Lembrar paciente da sessão" — dispara `send-whatsapp-message` com template `lembrete_sessao`
- **8.3 Status sessão dispara mensagem** (ex: cancelada → aviso auto)
- **8.4 Link agendador cria lead** — quando paciente preenche intake mas não finaliza, aparece no CRM como thread
### Outros blocos
- **Notificação de saldo baixo WhatsApp** — trigger em `whatsapp_credits_balance` quando `balance < low_balance_threshold`; e-mail + toast
- **Dashboard saas de receita créditos** — total arrecadado Asaas por mês, pacotes mais vendidos
- **Retention policy 5.1** — apagar/anonimizar conversas > X dias (configurável por tenant)
- **5.4** — seção de conversas no LGPD export do paciente
---
## 🔧 Setup Evolution/WhatsApp / Asaas
Tudo em **`WHATSAPP_SETUP.md`**. Resumo crítico:
1. `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env` em terminal separado
2. `.env` do functions tem: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `ASAAS_API_KEY`, `ASAAS_API_URL=https://api-sandbox.asaas.com/v3`
3. Evolution: `/saas/whatsapp` cadastra creds global → `/configuracoes/whatsapp-pessoal` conecta QR
4. Twilio: `/saas/twilio-whatsapp` provisiona subconta → tenant ativa em `/configuracoes/whatsapp-oficial` (usa créditos)
⚠️ Após editar qualquer `supabase/functions/**` precisa reiniciar o `supabase functions serve` — ele não tem hot reload.
---
## 🌲 Deploy options (guardadas pra depois)
- **(a)** Smoke test de infra — subir pra Supabase cloud + hospedagem só pra testar sozinho. ~2-3h.
- **(b)** Beta fechado com clínicas — precisa: 3.4 SLA, 6.1 heartbeat, 7.1 analytics, retention policy, tour/onboarding refinamento.
- **(c)** [em andamento] Fechar gaps funcionais.
---
## 📦 Commits
Tem um **`commit.txt`** pronto na raiz com mensagem consolidada pra um commit único de tudo que está pendente. `git status` mostra ~160 arquivos modificados/criados. Conferir `commit.txt` antes de usar.
Se preferir quebrar em commits menores, os grupos lógicos são:
1. Migrations CRM + créditos + polimorfismo (pasta `database-novo/migrations/20260420*` + `20260421*`)
2. Edge functions (pasta `supabase/functions/`)
3. Frontend CRM (`src/components/conversations/`, `src/features/conversations/`, `src/features/patients/prontuario/PatientConversationsTab.vue`)
4. Composables novos (`src/composables/useConversation*.js`, `useWhatsappCredits.js`, `useContact*.js`, `useAutoReplySettings.js`, `useSessionReminders.js`)
5. Páginas config novas (`src/layout/configuracoes/*`)
6. Admin SaaS (`SaasAddonsPage.vue` com 2 tabs novas)
7. Refactors (PatientsCadastro/Medicos trocando pra ContactEditors; AppLayout; router)
---
## ⚠️ Pendências conhecidas
- **15 V# adiados** (8 médios + 7 baixos) — sprint de polimento depois do beta
- **Tour guiado / onboarding wizard** — refino deixado pro fim
- **Dashboard SaaS de receita Asaas** — falta página
- **Rotação de credenciais Twilio** (segurança) — se subconta vazar, precisa de flow pra regenerar
---
## 🛠️ Stack lembretes
- **DB local:** `docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres`
- **DB como supabase_admin (ALTER POLICY em tabelas owned):**
```bash
docker exec -i -e PGPASSWORD=postgres -e PGCLIENTENCODING=UTF8 \
supabase_db_agenciapsi-primesakai \
psql -U supabase_admin -d postgres -h localhost -f migration.sql
```
- **Vitest:** `npx vitest run`
- **SQL integration:** `node database-novo/tests/run.cjs`
- **Edge functions serve:** `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env`
- **Evolution Manager:** `http://localhost:8080/manager/`
- **Supabase Studio:** `http://localhost:54323`
- **Asaas sandbox:** `https://sandbox.asaas.com` (login separado do prod)
---
## 📚 Memória persistente (carregada automaticamente)
Já saved em `MEMORY.md`:
- Project overview · MVP Assessment · Deploy options
- Sanitização sempre · Priorização por severidade · Self-hosted > provider externo
- Gotcha supabase_admin · Tracking dev_*_items
---
## 📌 Bom descanso, até amanhã!
-101
View File
@@ -1,101 +0,0 @@
O que foi feito (até agora)
Usuários de teste criados
admin@agenciapsi.com.br
— senha: 123Mudar@
patient@agenciapsi.com.br
— senha: 123Mudar@
therapist@agenciapsi.com.br
— senha: 123Mudar@
Base funcionando
✅ Auth (Supabase) está funcionando
✅ Tabela profiles criada e ok
✅ Trigger automático cria profile após signup
✅ Campo role definido (admin | therapist | patient)
✅ RLS básico ativo
✅ Login funcionando
✅ Logout funcionando
✅ Guard de rota implementado e ativo
✅ RBAC básico operando via meta.role + redirect para painel correto
✅ Home pública / com 3 cards (Admin | Therapist | Patient) levando ao login
✅ Pós-login: busca profiles.role e redireciona para:
/admin
/therapist
/patient
Estrutura implementada agora (menus e sessão para o Sakai)
Sessão central (evita menu errado e if(role) espalhado)
✅ Criado src/app/session.js com:
sessionUser, sessionRole, sessionReady (refs globais)
initSession() (carrega user + role antes de renderizar o layout)
listenAuthChanges() (atualiza sessão ao logar/deslogar)
✅ Ajustado src/main.js para usar bootstrap async:
chama await initSession() antes de app.mount()
liga listenAuthChanges()
mantém PrimeVue, tema Aura, ToastService e ConfirmationService
mantém imports de CSS existentes
Menu dinâmico por role no Sakai
✅ Menus foram estruturados no formato do Sakai (sections com label + items) e separados por role:
src/navigation/menus/admin.menu.js
src/navigation/menus/therapist.menu.js
src/navigation/menus/patient.menu.js
✅ Criado src/navigation/index.js com getMenuByRole(role) para centralizar a escolha do menu (sem if(role) em componentes).
✅ Ajustado o AppMenu.vue (menu do Sakai) para:
usar computed() com sessionRole/sessionReady
carregar dinamicamente getMenuByRole(sessionRole.value)
evitar “piscar” menu errado antes de carregar (sessionReady)
Menu demo do Sakai mantido sem quebrar o produto
✅ Mantivemos o menu demo (UIKit/Blocks/Start etc.) em arquivo separado para não perder as páginas do template:
src/navigation/menus/sakai.demo.menu.js (conteúdo original do template)
✅ Estratégia adotada:
Admin pode ver o menu demo (idealmente só em DEV)
Therapist/Patient ficam com menu limpo (clínico)
Rotas demo do Sakai corrigidas (arquivos com sufixo Doc)
✅ Problema resolvido: itens do menu demo davam 404 porque as rotas/imports não existiam com os nomes esperados (Input.vue etc.).
✅ Ajuste aplicado: rotas demo apontam para arquivos *Doc.vue (ex.: ButtonDoc.vue, InputDoc.vue).
📌 Criado/ajustado src/router/routes.demo.js para mapear:
/uikit/* → @/views/uikit/*Doc.vue
e demais demos conforme existirem
✅ Incluído demoRoutes no router principal para o menu demo funcionar.
Testes
✅ Confirmado que localStorage.clear() limpa sessão para testar outros usuários/roles rapidamente.
+232
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 */)
})
```
+518
View File
@@ -0,0 +1,518 @@
# WhatsApp Setup — CRM de Conversas + Créditos + Automações
Guia end-to-end do subsistema de WhatsApp do AgenciaPSI. Cobre **WhatsApp Pessoal** (Evolution, gratuito), **WhatsApp Oficial AgenciaPSI** (Twilio com créditos), **Asaas** (gateway de pagamento), e todas as automações (auto-reply, lembretes, opt-out, tags, notas).
---
## 🎯 Arquitetura
### Dois provedores, escolha exclusiva por tenant
```
┌─────────────────────────────────────────────────┐
│ Tenant escolhe 1 canal em /configuracoes/whatsapp │
└─────────────────────────────────────────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ WhatsApp Pessoal │ │ WhatsApp Oficial │
│ (Evolution) │ │ AgenciaPSI (Twilio) │
│ │ │ │
│ • Gratuito │ │ • Consome créditos │
│ • QR code │ │ • API oficial Meta │
│ • Celular real │ │ • Zero ban risk │
│ • Docker self-host │ │ • Cloud gerenciado │
│ • Tier free do SaaS │ │ • Tier pago do SaaS │
└────────────────────┘ └──────────────────────┘
│ │
└───────────┬────────────────┘
┌─────────────────────────┐
│ Edge Functions │
│ │
│ • send-whatsapp-message │ ← rota por provider
│ • send-session-reminders │ ← idem
│ • evolution-whatsapp-inbound (auto-reply, opt-out)
│ • twilio-whatsapp-inbound (⚠ sem auto-reply ainda)
│ • create-whatsapp-credit-charge (Asaas PIX)
│ • asaas-webhook (credita saldo)
└─────────────────────────┘
┌─────────────────────────┐
│ PostgreSQL │
│ │
│ conversation_messages │
│ conversation_notes │
│ conversation_tags │
│ conversation_optouts │
│ conversation_autoreply_* │
│ session_reminder_* │
│ whatsapp_credits_* │
│ whatsapp_credit_packages │
└─────────────────────────┘
```
### Dedução de créditos
```
Usuário envia pelo drawer / lembrete dispara / auto-reply
Edge function detecta provider do canal ativo
Evolution? Twilio?
↓ ↓
Envia direto deduct_whatsapp_credits(1) ← atômico, lock, valida saldo
↓ ↓
Registra msg ┌──── OK ────┐ ┌── insufficient ──┐
↓ ↓
send Twilio return 402
┌── ok ──┐ ┌── fail ──┐
↓ ↓
Registra add_whatsapp_credits(1, 'refund')
msg
```
---
## 🔧 Setup completo (dev local)
### 1. Supabase local + edge functions
```bash
# Subir stack Supabase local (Postgres + Auth + Storage + etc)
npx supabase start
# Em outro terminal: rodar edge functions
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```
**Funções carregadas:**
| Função | URL | Uso |
|---|---|---|
| `evolution-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Evolution: inbound msgs + auto-reply + opt-out |
| `evolution-webhook-provision` | — | Configura webhook na Evolution |
| `twilio-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Twilio (inbound only; sem auto-reply ainda) |
| `send-whatsapp-message` | — | Envio unificado: detecta provider, deduz crédito se Twilio |
| `send-session-reminders` | — | Cron/manual: dispara lembretes 24h e 2h antes |
| `create-whatsapp-credit-charge` | — | Cria PIX Asaas pra compra de créditos |
| `asaas-webhook` | — | Recebe eventos Asaas e credita saldo |
| `deactivate-notification-channel` | — | Desativa canal (usado ao trocar provider) |
**Flag `--no-verify-jwt`:** necessária porque webhooks externos (Twilio, Evolution, Asaas) não mandam JWT.
### 2. Evolution API (WhatsApp Pessoal — tier gratuito)
```bash
# Subir Evolution + Postgres + Redis
docker compose -f evolution-api/docker-compose.yml up -d
# Verificar status
docker ps --filter name=evolution_api
```
Evolution roda em `http://localhost:8080` com API key `minha_chave_123` (ver `evolution-api/docker-compose.yml`).
### 3. Asaas (pagamentos — tier pago)
Ativa só em prod ou quando quiser testar compra de créditos end-to-end.
**Passo 1 — Criar conta sandbox:**
1. https://sandbox.asaas.com (gratuito, CPF qualquer)
2. Menu → Integrações → Integrações Avançadas → API → copia a API key (começa com `$aact_...`)
**Passo 2 — Configurar env:**
Edita `supabase/functions/.env`:
```env
ASAAS_API_KEY=$aact_sua_chave_aqui
ASAAS_API_URL=https://sandbox.asaas.com/api/v3
ASAAS_WEBHOOK_TOKEN= # opcional, pra autenticar webhook
```
**Passo 3 — Reiniciar functions serve:**
```bash
# Ctrl+C no terminal do serve
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```
**Passo 4 — (Opcional) Expor webhook via ngrok pro Asaas alcançar:**
```bash
# Outro terminal
ngrok http 54321
# Copia a URL (ex: https://abc123.ngrok.app)
```
Configura no Asaas:
- Dashboard → Integrações → Webhooks → **Adicionar**
- URL: `https://abc123.ngrok.app/functions/v1/asaas-webhook`
- Eventos: marca **Cobranças** (PAYMENT_RECEIVED, PAYMENT_CONFIRMED, PAYMENT_OVERDUE, PAYMENT_DELETED, PAYMENT_REFUNDED)
- Token (opcional): cadastra o mesmo valor de `ASAAS_WEBHOOK_TOKEN`
**Em produção:**
```bash
supabase secrets set ASAAS_API_KEY="$aact_prod_key"
supabase secrets set ASAAS_API_URL="https://api.asaas.com/v3"
supabase secrets set ASAAS_WEBHOOK_TOKEN="token_seguro"
```
Webhook da prod aponta pro URL real do Supabase cloud (sem ngrok).
---
## 📋 Features & como testar
### A. Envio manual via drawer
**Onde:** drawer de qualquer conversa (clica no card do Kanban em `/therapist/conversas`)
**Fluxo:**
1. Compose no drawer → `store.sendMessage()` → chama `send-whatsapp-message` edge function
2. Function detecta provider ativo em `notification_channels`
3. Evolution: envia direto via `/message/sendText/{instance}`
4. Twilio: `deduct_whatsapp_credits(1)` → se OK envia via Twilio API → se falhar, refunda
5. Registra em `conversation_messages` (direction=outbound, delivery_status=sent/queued)
**Testar sem Twilio real** (valida dedução + rollback):
- Topup 100 créditos via SQL (ver seção 🧪 mais abaixo)
- Criar canal Twilio fake via SQL
- Enviar msg → deduz, tenta enviar, falha com 401, refunda → saldo volta ao original
### B. Lembretes automáticos de sessão (2.4)
**Onde:** `/configuracoes/lembretes-sessao`
**Config:**
- Toggle on/off
- Ativa lembretes 24h e/ou 2h antes da sessão
- Templates com variáveis: `{{nome_paciente}}`, `{{data_sessao}}`, `{{hora_sessao}}`, `{{modalidade}}`, `{{nome_clinica}}`
- Quiet hours (default 22h-8h SP)
- Respeitar opt-out (LGPD — recomendado ON)
**Como dispara:**
- Cron hit `send-session-reminders` a cada 15min (pg_cron comentado na migration; em prod configure via Supabase Dashboard → Database → Cron Jobs)
- Em dev: botão **"Testar agora"** na página dispara manualmente
**Query do worker:** busca `agenda_eventos` com `status='agendado'` dentro de:
- Janela 24h: `inicio_em` entre `now+23h45min` e `now+24h15min`
- Janela 2h: `inicio_em` entre `now+1h45min` e `now+2h15min`
**Anti-dup:** UNIQUE `(event_id, reminder_type)` no `session_reminder_logs`.
**Testar:**
```sql
-- Cria evento daqui a ~2h (no horário de SP)
-- Pelo UI da agenda é mais fácil; via SQL:
INSERT INTO agenda_eventos (tenant_id, owner_id, patient_id, inicio_em, fim_em, status, modalidade, tipo, titulo)
VALUES (
'<tenant>',
'<user>',
(SELECT id FROM patients WHERE tenant_id='<tenant>' LIMIT 1),
now() + interval '2 hours',
now() + interval '3 hours',
'agendado',
'presencial',
'session',
'Teste lembrete'
);
```
Depois clica **"Testar agora"** em `/configuracoes/lembretes-sessao`.
### C. Auto-reply fora do horário (2.3)
**Onde:** `/configuracoes/conversas-autoreply`
**Config:**
- Toggle on/off + mensagem + cooldown (minutos entre auto-replies pra mesma thread)
- 3 modos:
- **Seguir agenda** — usa `agenda_regras_semanais` dos membros ativos do tenant
- **Horário de funcionamento** — janela semanal editável (armazena em JSONB `business_hours`)
- **Custom** — janela específica pro auto-reply (`custom_window`)
**Como dispara:**
- Webhook Evolution `evolution-whatsapp-inbound` recebe msg
- Depois de inserir msg, chama `maybeSendAutoReply()`
- Checa: enabled, não está em horário útil, não está em cooldown
- Se OK → envia via Evolution (futuro: rotear pra Twilio se provider='twilio')
**⚠ Limitação:** atualmente **só funciona com Evolution**. Pra Twilio precisa implementar a mesma lógica em `twilio-whatsapp-inbound` (dívida técnica).
**Testar:**
- Ativa feature + define janela custom (ex: seg-sex 9h-18h)
- Fora dessa janela, paciente manda msg → chega no inbox + auto-reply é enviado de volta em ~1s
### D. Opt-out LGPD (5.2)
**Onde:** `/configuracoes/conversas-optouts`
**Como funciona:**
- Paciente envia "PARAR", "SAIR", "CANCELAR", "STOP", etc (keyword match case-insensitive sem acentos)
- Edge function `evolution-whatsapp-inbound` detecta → registra em `conversation_optouts` → envia msg de confirmação
- Paciente envia "VOLTAR" / "RETORNAR" → reativa (opted_back_in_at preenchido)
- Auto-reply e lembretes **respeitam opt-out** automaticamente (skip + log `opted_out`)
- Envio manual do terapeuta NÃO é bloqueado (relação terapêutica existe)
**Keywords padrão:** 10 palavras (configuráveis na página — pode adicionar custom do tenant).
**Testar:**
- Manda mensagem com "parar" pelo WhatsApp conectado
- Volta em `/configuracoes/conversas-optouts` → número aparece na lista
- Nova mensagem que dispararia auto-reply → não dispara mais
### E. Notas internas (3.3)
**Onde:** dentro do drawer de conversa, seção "Notas internas" collapsible
**Como funciona:**
- CRUD simples por thread
- Visível apenas pra membros ativos do tenant
- Edição/remoção só pelo criador (ou SaaS admin)
- Soft delete (`deleted_at`)
- **NÃO vai pro paciente** — apenas anotação interna da equipe
### F. Tags na conversa (3.1)
**Onde:**
- **Gestão:** `/configuracoes/conversas-tags` (CRUD de tags custom; system tags são read-only)
- **Aplicação:** dentro do drawer de conversa + pills visíveis nos cards do Kanban
**Tags system (seedadas):** Urgente (🔴), Primeira consulta (🔵), Remarcação (🟡), Confirmada (🟢), Follow-up (🟣)
**Custom:** tenant cria suas próprias com nome + slug + cor + ícone (primeicons).
### G. Mídia (áudio / imagem / vídeo / documento)
**Arquitetura:**
- Evolution manda URLs encriptadas do Meta CDN (não tocam direto)
- Edge function `evolution-whatsapp-inbound` chama `/chat/getBase64FromMediaMessage/{instance}` do Evolution → decripta
- Decoda base64 → faz upload no bucket **privado `whatsapp-media`**
- Path: `<tenant_id>/<yyyy>/<mm>/<msg_id>_<timestamp>.<ext>`
- Salva apenas o PATH em `media_url`, NÃO URL pública
- Frontend (`ConversationDrawer`) gera **signed URL on-demand** (1h TTL) ao renderizar
**LGPD:** bucket privado, RLS só permite membros ativos do tenant; path tenant-scoped; signed URLs expiram.
**Player de áudio:** `<audio min-w-[260px] controls>` + `preload="metadata"` pra mostrar duração sem baixar tudo.
**Preview de imagem:** `<Image preview>` do PrimeVue (fullscreen com zoom/rotate) + **botão download injetado via MutationObserver** (fetch blob → download attr → force download com nome do arquivo).
### H. Créditos WhatsApp (Marco B)
**Onde:** `/configuracoes/creditos-whatsapp`
**Fluxo de compra:**
1. User clica "Comprar" num pacote → `create-whatsapp-credit-charge` cria:
- Customer Asaas (ou reutiliza via `externalReference=tenant_id`)
- Pagamento PIX (`billingType=PIX`, value, dueDate)
- Busca QR Code em `/payments/{id}/pixQrCode`
2. Dialog mostra QR + copia-cola + link cartão
3. User paga
4. Asaas → webhook `asaas-webhook` recebe `PAYMENT_RECEIVED`/`CONFIRMED`
5. Webhook chama `add_whatsapp_credits(tenant, credits, 'purchase')` → saldo atualiza
**Pacotes seedados:** Iniciante (100/R$49,90), Profissional (500/R$199,90, ⭐ featured), Clínica (1500/R$499,90), Enterprise (5000/R$1499,90) — editáveis via DB.
**RPCs atômicas (SECURITY DEFINER):**
- `add_whatsapp_credits(tenant, amount, kind, purchase_id, admin_id, note)` → retorna novo saldo
- `deduct_whatsapp_credits(tenant, amount, msg_id, note)` → atômico com `SELECT FOR UPDATE`; lança `insufficient_credits` se saldo < amount
**CPF:** fallback hardcoded `24971563792` em sandbox. Em produção, frontend deve coletar CPF real do user (TODO — adicionar input no dialog + salvar em profile/tenant).
---
## 🧪 Testar sem provedor real
### Pré-requisito: creditar saldo
No Supabase Studio (`http://localhost:54323`) → SQL Editor (**desativa LIMIT 100 no dropdown**):
```sql
SELECT public.add_whatsapp_credits(
tm.tenant_id,
100,
'topup_manual',
NULL,
auth.uid(),
'Topup de teste'
) AS novo_saldo
FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL'
AND tm.status = 'active'
LIMIT 1;
```
### Simular canal Twilio fake (pra testar dedução)
```sql
-- Desativa Evolution
UPDATE public.notification_channels
SET is_active = false, deleted_at = now()
WHERE tenant_id = (
SELECT tm.tenant_id FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active' LIMIT 1
)
AND channel = 'whatsapp' AND deleted_at IS NULL;
-- Cria Twilio fake
INSERT INTO public.notification_channels (
tenant_id, owner_id, channel, provider, is_active,
twilio_subaccount_sid, twilio_phone_number, credentials
)
SELECT
tm.tenant_id, u.id, 'whatsapp', 'twilio', true,
'ACfake0000000000000000000000000000',
'+15557775555',
'{"subaccount_auth_token": "fake_token"}'::jsonb
FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active'
LIMIT 1;
```
Agora qualquer envio:
- Deduz 1 crédito (`usage -1`)
- Twilio API retorna 401 (creds fake)
- Refunda automaticamente (`refund +1`)
- Saldo final: igual ao inicial
Resultado esperado no extrato:
```
+1 Estorno — Refund envio falhou: Twilio 401: ...
-1 Uso — Envio manual WhatsApp
+100 Topup manual — Topup de teste
```
### Simular mensagem inbound via curl
```bash
curl -X POST "http://localhost:54321/functions/v1/evolution-whatsapp-inbound?tenant_id=<uuid>" \
-H "Content-Type: application/json" \
-d '{
"event": "messages.upsert",
"data": {
"key": { "remoteJid": "5516912345678@s.whatsapp.net", "fromMe": false, "id": "MSG_TEST" },
"message": { "conversation": "parar" },
"messageTimestamp": '"$(date +%s)"'
}
}'
```
Isso vai testar detecção de opt-out ("parar" é keyword) → registra na lista + envia ACK.
### Testar Twilio sandbox real (opcional)
1. Cria conta trial em https://www.twilio.com/try-twilio (US$15 grátis)
2. Dashboard → Messaging → Try it out → **Send a WhatsApp Message**
3. Segue instruções pra join sandbox (`join <frase>` do seu celular pro número Twilio)
4. Pega Account SID + Auth Token + From number do sandbox
5. Atualiza o canal Twilio no SQL com valores reais
6. Envia mensagem do drawer → chega no seu celular
---
## 🆘 Troubleshooting
| Sintoma | Causa provável | Fix |
|---|---|---|
| 404 no webhook | `supabase functions serve` não rodando OU function não carregada | Reinicia serve com `--env-file` |
| 502 edge function | Crash antes do `console.error` — erro de sintaxe, env var faltando, ou API externa timeout | Olha logs do serve; adiciona `console.log` em cada etapa |
| Asaas 400 "CPF obrigatório" | Customer existente sem CPF | Fix em `getOrCreateAsaasCustomer` faz PATCH com CPF quando falta |
| QR code PIX não aparece | Asaas sandbox sem PIX habilitado OU endpoint `/pixQrCode` falhou | Ativa PIX em Asaas → Recebimentos → Chaves PIX |
| Webhook Asaas não chega | URL não pública (localhost) | Usa ngrok OU deploy em prod |
| "insufficient_credits" no envio | Saldo zerado | Topup via `/configuracoes/creditos-whatsapp` ou SQL manual |
| Auto-reply não dispara | Tenant fora do modo horário configurado OU em cooldown OU opted-out | Checa banner de status em `/configuracoes/conversas-autoreply`; olha `conversation_autoreply_log` |
| Lembrete duplicado | Não acontece — UNIQUE `(event_id, reminder_type)` previne | — |
| Audio não toca (era o ponto inicial do Marco A-I) | URL encriptada do Meta salva direto | Fix: edge function agora decripta via `getBase64FromMediaMessage` + upload pro bucket |
| Mime `audio/ogg; codecs=opus` rejeitado pelo bucket | `allowed_mime_types` faz match exato | Fix: strip `;codecs=...` antes do upload |
| Dialog de confirmação aparece 2x | Dois `<ConfirmDialog>` montados (página + drawer global) | Fix: drawer usa `group="conversation-drawer"` pra isolar |
---
## 🔒 Segurança & Compliance
### RLS de `conversation_messages`
- **SELECT:** tenant members ativos OU saas_admin
- **INSERT direto:** bloqueado (só service_role via edge function)
- **UPDATE:** tenant members podem mudar `kanban_status`, `read_at`
- **DELETE:** bloqueado
### RLS de `whatsapp-media` bucket
- Privado
- Read: membros ativos do tenant cujo id é o primeiro segmento do path
- Write: apenas service_role
- Delete: apenas saas_admin
### RLS de `whatsapp_credits_*`
- Balance/transactions: tenant members leem
- Escrita: via RPCs `add_whatsapp_credits` / `deduct_whatsapp_credits` (SECURITY DEFINER)
- Packages: tenant members leem os ativos; saas_admin gerencia
### LGPD
- **Art. 18 §2 (direito de oposição):** opt-out implementado. Auto-reply + lembretes respeitam.
- **Dados clínicos:** canal Oficial (Twilio) recomendado em prod. Pessoal (Evolution) é uso informal, sem SLA, tenant assume risco.
- **Audit log:** toda transação de crédito fica em `whatsapp_credits_transactions` (append-only).
### Bot defense / Rate limiting
- `public_submission_attempts`, `submission_rate_limits` e `math_challenges` são tabelas do sistema de bot defense pro cadastro externo. Não relacionado ao WhatsApp, mas protege fluxo paralelo.
---
## 📚 Referência de arquivos
### Edge functions (`supabase/functions/`)
- `evolution-whatsapp-inbound/` — webhook Evolution (msgs inbound + auto-reply + opt-out + media)
- `evolution-webhook-provision/` — configura webhook na Evolution
- `twilio-whatsapp-inbound/` — webhook Twilio (inbound only, sem automações ainda)
- `twilio-whatsapp-provision/` — provisiona subconta Twilio (SaaS admin only)
- `send-whatsapp-message/` — envio unificado (Evolution ou Twilio com dedução)
- `send-session-reminders/` — worker de lembretes (chamado por cron)
- `create-whatsapp-credit-charge/` — cria PIX Asaas
- `asaas-webhook/` — recebe eventos Asaas e credita saldo
- `deactivate-notification-channel/` — soft-delete de canal (via service_role)
### Composables (`src/composables/`)
- `useConversations.js` — Kanban threads
- `useConversationNotes.js` — notas internas
- `useConversationTags.js` — tags CRUD + apply
- `useConversationOptouts.js` — opt-outs + keywords
- `useAutoReplySettings.js` — config auto-reply
- `useSessionReminders.js` — config lembretes + logs
- `useWhatsappCredits.js` — saldo + loja + extrato
### Páginas principais (`src/layout/configuracoes/`)
- `ConfiguracoesWhatsappChooserPage.vue` — landing/chooser
- `ConfiguracoesWhatsappPage.vue` — setup Evolution (Pessoal)
- `ConfiguracoesTwilioWhatsappPage.vue` — setup Twilio (Oficial, rebrandeado)
- `ConfiguracoesConversasTagsPage.vue` — CRUD tags
- `ConfiguracoesConversasOptoutsPage.vue` — lista opt-outs + keywords
- `ConfiguracoesConversasAutoreplyPage.vue` — auto-reply
- `ConfiguracoesLembretesSessaoPage.vue` — lembretes
- `ConfiguracoesCreditosWhatsappPage.vue` — saldo + loja + histórico
### Tabelas principais (database-novo/schema/)
Ver dashboard interativo: `database-novo/agenciapsi-db-dashboard.html` (regenerado via `node db.cjs dashboard`).
Domínios no dashboard:
- **CRM Conversas (WhatsApp)** — todas as tabelas de conversas, notas, tags, opt-outs, auto-reply, lembretes
- **Addons / Créditos** — créditos WhatsApp (balance, transactions, packages, purchases)
- **Comunicação / Notificações** — canais, templates, queue
---
## 📌 Dívidas técnicas conhecidas
1. **Auto-reply via Twilio** — hoje só funciona com Evolution. Precisa portar lógica pra `twilio-whatsapp-inbound`.
2. **Opt-out via Twilio** — idem (keyword detection no inbound Twilio).
3. **Admin SaaS UI de créditos** — topup manual + gestão de pacotes (hoje via SQL).
4. **Input de CPF real** no dialog de compra (hoje fallback hardcoded em sandbox).
5. **Alerta de saldo baixo** — estrutura DB pronta (`low_balance_threshold`, `low_balance_alerted_at`), falta trigger/notification.
6. **Reconnect Evolution automático** — Grupo 6.3 do roadmap (heartbeat + reconnect).
7. **pg_cron em prod** — migration tem bloco comentado; ativar via Supabase Dashboard → Database → Cron Jobs ou descomentar após setar `app.settings.service_role_key`.
---
**Última atualização:** 2026-04-21 (sessão de features CRM WhatsApp + Marco B Créditos)
+159
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` |
+183
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 |
-47
View File
@@ -1,47 +0,0 @@
🔁 CONTEXTO DO PROJETO (SaaS multi-tenant)
Stack:
- Supabase
- Multi-tenant por clinic/tenant
- Assinaturas por tenant (subscriptions.tenant_id)
- Controle de features: features, plan_features, subscription_intents, entitlementsStore, view v_tenant_entitlements
- Ativação manual: activate_subscription_from_intent()
- Merge concluído: agenda_online → online_scheduling.manage
- Entitlements e bloqueio PRO no menu funcionando
- Signup + intent funcionando; ativação cria subscription ativa; view retorna feature correta
Modelo de “Contas” decidido:
- Auth user (login) ≠ Clínica (tenant)
- Clínica = tenant; Usuário pode ser dono/admin de clínica e também profissional
- Clínica convida usuários (tenant_members). Usuário pode aceitar/recusar.
- Profissional pode trabalhar anos e depois sair: clínica mantém registros; profissional mantém histórico (audit trail), sem acesso após saída.
Regras de offboarding:
- Profissional só pode sair se NÃO houver agenda futura atribuída a ele.
- Se houver, cria “pedido de saída” e admin precisa realocar/cancelar; depois finaliza saída.
Tabelas existentes:
- tenant_members: (id uuid pk, tenant_id uuid, user_id uuid, role text, status text, created_at timestamptz)
- UNIQUE (tenant_id, user_id) atualmente
- Agenda: agenda_eventos, agenda_excecoes, agenda_configuracoes, agenda_regras_semanais
- Outros: subscriptions, subscription_intents, plan_features, features, subscription_events
O que estamos fazendo agora:
- Ajustar modelo de membership lifecycle e offboarding (exit_requests)
- Garantir integridade: histórico de vínculos + auditoria + bloqueio de saída com agenda futura
- Implementar SQL + RPC + RLS + UI (passo a passo)
✔ subscriptions
Representa o plano da clínica (tenant)
✔ tenant_members
Define quais usuários pertencem à clínica
✔ entitlements
Define o que aquela clínica pode usar
Dados que faltam confirmar:
1) Estrutura de agenda_eventos (colunas e como relaciona com profissional)
2) Valores usados em tenant_members.status (active/invited/etc)
3) Estratégia de reentrada: remover UNIQUE (tenant_id,user_id) e usar unique parcial por status ativo/convite
4) Se existe tabela public.users como espelho do auth.users
+176
View File
@@ -0,0 +1,176 @@
# Sessões 6 (continuação) → 10 — hardening em 6 áreas + scan completo do SaaS
Continuação do commit `7c20b51` (Sessões 1-6 iniciais). Esta etapa fechou
**toda revisão sênior do SaaS** + refator parcial de pacientes.
**Estado final do projeto:**
- A# auditoria abertos: **1** (A#31 Deploy real)
- V# verificações abertos: 14 (todos médios/baixos adiados com plano completo no DB)
- 🔴 Críticos: **0**
- 🟠 Altos: **0**
- Vitest: **208/208** (era 192)
- SQL integration: **33/33**
- E2E (Playwright): **5/5**
- Áreas auditadas: **15** (todas as principais do SaaS)
---
## Sessão 6 (continuação) — Documentos pendentes + Pacientes V#3
### Documentos: 100% fechado (V#50, V#51, V#52)
- **V#50** — Policy `documents: portal patient read` adicional. Paciente lê documento via portal quando `compartilhado_portal=true` AND patient pertence a auth.uid AND não expirou.
- **V#51** — `documents.content_sha256` (nullable, índice parcial). `Documents.service.uploadDocument` calcula SHA-256 hex client-side via `crypto.subtle.digest`. Helper novo `verifyDocumentIntegrity(docId)` baixa arquivo e re-hash.
- **V#52** — Migration `...13` cron retention via pg_cron: 4 jobs (document_access_logs 1 ano, math_challenges 1h, public_submission_attempts 90 dias, submission_rate_limits 30 dias).
### Pacientes V#3 (parcial — fundação)
- `src/features/patients/services/patientsRepository.js` — list/get/create/update/softDelete + groups + tags + getSessionCounts.
- `src/features/patients/composables/usePatients.js` — wrapper reativo (rows/loading/error).
- PatientsListPage.hydrateAssociationsSupabase migrada — substitui 4 queries diretas por chamadas ao repo (paralelismo preservado).
- V#9 (PatientsCadastroPage 1991 linhas) → adiado pra Sessão 10.
---
## Sessão 7 — Tenants + Calendário
### Tenants (8 V#)
- 🔴 **V#1 P0**`tenant_invites` com RLS off + 0 policies (mesmo padrão A#30 Sessão 5). Tabela tinha 0 rows. Migration: ENABLE RLS + 4 policies (SELECT tenant_admin/saas; INSERT WITH CHECK invited_by=auth.uid; UPDATE só revogação; DELETE tenant_admin/saas). Aceitar invite continua via RPC `tenant_accept_invite` SECURITY DEFINER.
- 🟠 **V#2** profiles INSERT WITH CHECK (id = auth.uid)
- 🟠 **V#3** support_sessions INSERT WITH CHECK (admin_id = auth.uid + saas_admin guard)
- 🟡 **V#4 (signup público)** verificado: RPC `ensure_personal_tenant` SECURITY DEFINER já existia (Signup.vue:232) → **ok**
- 🟡 **V#5 (accept_invite)** verificado: RPCs `tenant_accept_invite` + `tenant_invite_member_by_email` já existiam → **ok**
- 🟡 **V#6** user_settings INSERT WITH CHECK
- 🟢 V#7/V#8 baixos — adiados
### Calendário (2 V#) — 100% fechado
- 🔴 **V#1** feriados_insert + feriados_saas_insert ganharam WITH CHECK. Spam de feriado global bloqueado.
- 🟢 **V#2** feriados_delete agora permite tenant_admin (não só owner).
---
## Sessão 8 — Addons + Central SaaS
### Addons (4 V#)
- 🔴 **V#1 P0 (dinheiro)**`addon_transactions_admin_insert` ganhou WITH CHECK (EXISTS saas_admins). Edge functions com service_role bypassam RLS, pipeline preservado. **Authenticated comum não cria mais transação fake.**
- 🟠 **V#2** — 3 CHECK constraints em `addon_credits`: balance >= 0, total_consumed >= 0, total_purchased >= 0. Saldo negativo silencioso eliminado.
- 🟡 V#3 (UI extrato) — adiado.
- 🟡 V#4 — verificado: `addon_products` não tem `tenant_id` (catálogo global por design) → **ok**.
### Central SaaS (3 V#)
- 🟠 **V#1**`faq_admin_write` substituído por `faq_saas_admin_write` em `saas_faq` E `saas_faq_itens` — só saas_admin escreve. Tenant_admin lê via `faq_auth_read` (permanece).
- 🟢 V#2/V#3 médios/baixos — adiados.
---
## Sessão 9 — Serviços/Prontuários (100% fechado)
5/5 V# corrigidos:
- 🔴 **V#1** services + insurance_plans → 4 policies separadas (SELECT tenant_member; INSERT/UPDATE/DELETE owner+saas).
- 🔴 **V#2** medicos → 4 policies separadas (catálogo de médicos referenciadores compartilhado entre profissionais do tenant).
- 🟠 **V#3** commitment_services — cascade reescrito via JOIN com services (USING permite tenant_member; WITH CHECK só owner).
- 🟠 **V#4** insurance_plan_services — cascade reescrito via JOIN com insurance_plans.
- 🟡 **V#5** commitment_time_logs/determined_commitments/determined_commitment_fields ganharam WITH CHECK em INSERT.
---
## Sessão 10 — Pacientes V#9 (script extraído)
PatientsCadastroPage.vue: 1991 → 1951 linhas (qualitativo > quantitativo).
### 2 composables novos
- **`useCep.js`** — busca ViaCEP reutilizável. 6 testes (sem rede, mock fetch).
- **`usePatientSupportContacts.js`** — CRUD de contatos de suporte encapsulado (load/save/add/remove/iniciaisFor). 10 testes com builder thenable.
### patientsRepository estendido
- `getPatientRelations(patientId)` — retorna {groupIds, tagIds}
- `replacePatientGroup(patientId, groupId, {tenantId})`
- `replacePatientTags(patientId, tagIds, {tenantId, ownerId})`
### PatientsCadastroPage refatorado
- 8 funções de query → delegação 1-linha ao patientsRepository
- onCepBlur → usa composable useCep
- Contatos de suporte → composable
- Template **não** foi tocado (zero risco de regressão visual)
- Quebra de template em sub-componentes Vue → adiado pra quando houver E2E cobrindo a página
---
## 📦 Migrations consolidadas neste commit (8)
```
20260419000011_documents_portal_patient_policy.sql (V#50)
20260419000012_documents_content_hash.sql (V#51)
20260419000013_cron_retention_jobs.sql (V#52 + math_challenges + submissions + rate_limits)
20260419000014_financial_security_hardening.sql (5 V# financeiro — fechados na Sessão 6)
20260419000015_communication_security_hardening.sql (5 V# comunicação — fechados na Sessão 6)
20260419000016_tenants_calendario_hardening.sql (Tenants V#1-V#3,V#6 + Calendário V#1-V#2)
20260419000017_addons_central_saas_hardening.sql (Addons V#1-V#2 + Central SaaS V#1)
20260419000018_servicos_prontuarios_hardening.sql (Serviços V#1-V#5)
```
**Total acumulado de migrations no histórico: 18** (Sessões 1-10).
Várias dessas exigiram conexão direta como `supabase_admin` (ver memory `project_supabase_admin_gotcha.md` e `commit.md` anterior) por causa de tabelas owned por esse role.
---
## 🆕 Novos arquivos (código)
```
src/features/patients/composables/useCep.js
src/features/patients/composables/usePatientSupportContacts.js
src/features/patients/composables/usePatients.js
src/features/patients/composables/__tests__/useCep.spec.js (+6 testes)
src/features/patients/composables/__tests__/usePatientSupportContacts.spec.js (+10 testes)
src/features/patients/services/patientsRepository.js
```
---
## 🛠️ Modificações
- `src/features/patients/PatientsListPage.vue` — hydrateAssociationsSupabase usa repo
- `src/features/patients/cadastro/PatientsCadastroPage.vue` — script extraído (queries → repo, CEP → composable, contatos → composable). Template intocado.
- `src/services/Documents.service.js` — uploadDocument calcula content_sha256 + helper verifyDocumentIntegrity
---
## 📊 Áreas auditadas (estado final)
| Área | V# total | Estado |
|---|---|---|
| auth | 10 | 100% fechado/ok |
| router | 9 | 100% |
| stores | 1 | 100% |
| agenda | 11 | 100% |
| pacientes | 10 | **100% fechado** ✅ |
| seguranca | 1 | 100% |
| saas | 10 | 100% |
| documentos | 10 | **100% fechado** ✅ |
| financeiro | 11 | 5 fechados, 6 médios/baixos adiados |
| comunicacao | 10 | 5 fechados, 5 médios/baixos adiados |
| tenants | 8 | 6 fechados, 2 baixos adiados |
| calendario | 2 | **100% fechado** ✅ |
| addons | 4 | 3 resolvidos, 1 médio adiado |
| central_saas | 3 | 1 alto fechado, 2 médios adiados |
| servicos | 5 | **100% fechado** ✅ |
**Zero crítico/alto restante no sistema inteiro.**
---
## ⚠️ Pendências documentadas no DB (não esquecidas)
### A# (1 aberto)
- **A#31 Deploy real** — alto. Reformulação pendente: como ainda não há cloud Supabase nem secrets reais, próxima sessão é "Preparação completa pra deploy" (DEPLOY.md, validar migrations num container limpo, audit de edge functions, listar env vars, script `db.cjs deploy-check`).
### V# adiados (14)
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`:
- financeiro (6): parcelamento CHECK, payouts flow, recurrence DELETE policy, composables, máscara PIX, dashboard inadimplência
- comunicacao (5): notifications/schedules silos, email_templates_global filtros, retention notification_logs, dashboard health, audit dismissals/preferences
- tenants (2): owner_users policies, company_profiles + dev_user_credentials
- central_saas (2): rate limit voto, valores tipo_acesso
- addons (1): UI de extrato
### Outros
- PatientsCadastroPage template breakdown — quando houver E2E
- Pacientes V#9 segue 100% no banco (script foi extraído; template é refator visual separado)
@@ -0,0 +1,96 @@
# README — generate-dashboard.js
Script Node.js que lê o `schema.sql` do backup mais recente e gera um `dashboard.html` interativo com a visão completa do banco de dados do projeto.
---
## Como usar
Coloque o `generate-dashboard.js` na **raiz do projeto** (mesma pasta do `db.cjs`) e rode:
```bash
# Usa o backup mais recente automaticamente
node generate-dashboard.js
# Ou especifica uma data
node generate-dashboard.js 2026-03-27
```
O arquivo `dashboard.html` será gerado na raiz do projeto. Basta abrir no browser.
---
## Fluxo recomendado
Sempre que fizer alterações no banco, rode os dois comandos em sequência:
```bash
node db.cjs backup # gera o backup em database-novo/backups/YYYY-MM-DD/
node generate-dashboard.js # lê o backup mais recente e gera o dashboard.html
```
---
## O que o dashboard mostra
- **Visão geral** — cards com os 9 domínios do projeto, quantidade de tabelas e FKs por domínio
- **Tabelas** — todas as 86 tabelas com colunas, tipos, badges PK/FK
- **Foreign Keys** — cada FK aparece como link clicável que pula direto para a tabela destino
- **Views** — lista das 24 views do schema público
- **Busca** — busca em tempo real por nome de tabela ou nome de coluna
- **Sidebar** — navegação por domínio
---
## Estrutura de pastas esperada
O script espera essa estrutura para funcionar:
```
raiz-do-projeto/
├── db.cjs
├── db.config.json
├── generate-dashboard.js ← script
├── dashboard.html ← gerado aqui
└── database-novo/
└── backups/
└── 2026-03-27/
├── schema.sql ← lido pelo script
├── data.sql
└── full_dump.sql
```
---
## Tabelas novas não aparecem no domínio certo?
Quando você criar uma migration nova com uma tabela nova, ela aparecerá no dashboard na seção **"Outros"** e o script vai avisar no terminal:
```
⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):
- minha_tabela_nova
→ Edite DOMAIN_TABLES no script para mapeá-las.
```
Para corrigir, abra o `generate-dashboard.js` e adicione a tabela no domínio correto dentro do objeto `DOMAIN_TABLES` no topo do arquivo:
```js
const DOMAIN_TABLES = {
'Agenda': [
'agenda_eventos',
'agenda_configuracoes',
// ...
'minha_tabela_nova', // ← adiciona aqui
],
// ...
};
```
Depois rode `node generate-dashboard.js` novamente.
---
## Requisitos
- Node.js instalado (qualquer versão >= 14)
- Sem dependências externas — usa apenas módulos nativos (`fs`, `path`)
+119
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
+1064
View File
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
{
"container": "supabase_db_agenciapsi-primesakai",
"database": "postgres",
"user": "postgres",
"backupRetentionDays": 30,
"schema": "schema/00_full/schema.sql",
"migrationsDir": "migrations",
"seedsDir": "seeds",
"fixesDir": "fixes",
"seeds": {
"users": [
"seed_001_fixed.sql",
"seed_002.sql",
"seed_003.sql"
],
"system": [
"seed_010_plans.sql",
"seed_011_features.sql",
"seed_012_plan_features.sql",
"seed_013_subscriptions.sql",
"seed_014_global_data.sql",
"seed_015_document_templates.sql",
"seed_030_dev_phases_items.sql",
"seed_031_dev_auditoria.sql",
"seed_032_dev_competitors.sql"
],
"test_data": [
"seed_020_test_data.sql"
]
},
"fixes": [
"fix_addon_credits_fk.sql",
"fix_addon_rls_saas_admin.sql",
"fix_missing_subscriptions.sql",
"fix_notification_templates_rls_admin.sql",
"fix_seed_patient_groups.sql",
"fix_subscriptions_validate_scope.sql",
"fix_template_keys_match_populate.sql",
"fix_encoding_accents.sql"
],
"verify": {
"tables": [
{ "name": "auth.users", "min": 1 },
{ "name": "profiles", "min": 1 },
{ "name": "tenants", "min": 1 },
{ "name": "plans", "min": 7 },
{ "name": "features", "min": 20 },
{ "name": "plan_features", "min": 50 },
{ "name": "subscriptions", "min": 1 },
{ "name": "email_templates_global", "min": 10 },
{ "name": "notification_templates", "min": 5 },
{ "name": "document_templates", "min": 1 }
],
"views": [
"v_tenant_entitlements",
"v_tenant_active_subscription"
]
},
"status": {
"tables": [
"auth.users",
"profiles",
"tenants",
"tenant_members",
"plans",
"features",
"plan_features",
"subscriptions",
"patients",
"agenda_eventos",
"services",
"financial_records",
"document_templates",
"documents",
"email_templates_global",
"notification_templates"
]
},
"domains": {
"SaaS / Planos": [
"plans", "plan_features", "plan_prices", "plan_public", "plan_public_bullets",
"features", "modules", "module_features",
"subscriptions", "subscription_events",
"subscription_intents_legacy", "subscription_intents_personal", "subscription_intents_tenant",
"tenant_modules", "tenant_features", "tenant_feature_exceptions_log",
"billing_contracts", "entitlements_invalidation"
],
"Addons / Créditos": [
"addon_products", "addon_credits", "addon_transactions",
"whatsapp_credits_balance", "whatsapp_credits_transactions",
"whatsapp_credit_packages", "whatsapp_credit_purchases"
],
"Tenants / Multi-tenant": [
"tenants", "profiles", "user_settings",
"tenant_invites", "tenant_members",
"company_profiles", "support_sessions",
"saas_admins", "owner_users", "dev_user_credentials"
],
"Pacientes": [
"patients", "patient_contacts", "patient_support_contacts",
"patient_groups", "patient_group_patient",
"patient_tags", "patient_patient_tag",
"patient_discounts", "patient_intake_requests", "patient_invites",
"patient_status_history", "patient_timeline",
"contact_types", "contact_phones",
"contact_email_types", "contact_emails"
],
"Agenda / Agendamento": [
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
"agenda_online_slots", "agenda_regras_semanais",
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
"agendador_configuracoes", "agendador_solicitacoes"
],
"Financeiro": [
"financial_categories", "financial_exceptions", "financial_records",
"payment_settings", "professional_pricing",
"therapist_payouts", "therapist_payout_records",
"recurrence_rules", "recurrence_exceptions", "recurrence_rule_services"
],
"Serviços / Prontuários": [
"services", "commitment_services", "commitment_time_logs",
"determined_commitments", "determined_commitment_fields",
"insurance_plans", "insurance_plan_services",
"medicos"
],
"Documentos": [
"documents", "document_templates", "document_generated",
"document_access_logs", "document_share_links", "document_signatures"
],
"Comunicação / Notificações": [
"email_templates_global", "email_templates_tenant", "email_layout_config",
"notification_templates", "notification_channels", "notification_preferences",
"notification_logs", "notification_schedules", "notification_queue",
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
"twilio_subaccount_usage"
],
"CRM Conversas (WhatsApp)": [
"conversation_messages", "conversation_threads",
"conversation_notes",
"conversation_tags", "conversation_thread_tags",
"conversation_optouts", "conversation_optout_keywords",
"conversation_autoreply_settings", "conversation_autoreply_log",
"session_reminder_settings", "session_reminder_logs"
],
"Segurança / Rate limiting": [
"submission_rate_limits"
],
"Central SaaS (docs/FAQ)": [
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
],
"Estrutura / Calendário": [
"feriados"
]
},
"domainColors": {
"SaaS / Planos": "#4f8cff",
"Addons / Créditos": "#a78bfa",
"Tenants / Multi-tenant": "#6ee7b7",
"Pacientes": "#f472b6",
"Agenda / Agendamento": "#38bdf8",
"Financeiro": "#f87171",
"Serviços / Prontuários": "#34d399",
"Documentos": "#0ea5e9",
"Comunicação / Notificações": "#fbbf24",
"CRM Conversas (WhatsApp)": "#25d366",
"Segurança / Rate limiting": "#ef4444",
"Central SaaS (docs/FAQ)": "#c084fc",
"Estrutura / Calendário": "#fb923c"
},
"infrastructure": {
"Banco & Backend": {
"color": "#4f8cff",
"items": [
{
"name": "Supabase",
"role": "Postgres + Auth + Storage + Realtime + Edge Functions",
"env": "Local (Docker) + Cloud",
"status": "ativo",
"notes": "Stack principal. Migrations em database-novo/migrations/. Functions em supabase/functions/. CLI via npx supabase."
},
{
"name": "PostgreSQL 15",
"role": "Banco de dados relacional (via container supabase_db_agenciapsi-primesakai)",
"env": "Local (Docker)",
"status": "ativo",
"notes": "RLS habilitada em todas as tabelas públicas. Multi-tenant via tenant_id. SECURITY DEFINER em RPCs sensíveis."
},
{
"name": "Docker + Docker Compose",
"role": "Orquestração dos containers do stack Supabase local + Evolution API",
"env": "Local",
"status": "ativo",
"notes": "docker-compose.yml na raiz. Iniciado via npx supabase start."
}
]
},
"Email": {
"color": "#fbbf24",
"items": [
{
"name": "Mailpit (Supabase inbucket)",
"role": "Inbox SMTP local para capturar emails de teste",
"env": "Local (Docker)",
"status": "ativo",
"notes": "Container supabase_inbucket. Usado em dev para validar templates sem enviar email real."
},
{
"name": "SMTP produção",
"role": "Envio real de emails transacionais (faturas, convites, notificações)",
"env": "Cloud (pendente)",
"status": "pendente",
"notes": "Requer SMTP_HOST/PORT/USER/PASS/FROM nos secrets das edge functions."
}
]
},
"WhatsApp / SMS": {
"color": "#34d399",
"items": [
{
"name": "Evolution API",
"role": "WhatsApp self-hosted via Baileys (tier gratuito do SaaS — 'WhatsApp Pessoal')",
"env": "Local (Docker)",
"status": "ativo",
"notes": "Container via evolution-api/docker-compose.yml. Uso do usuário conecta via QR code no celular real. Sem SLA, Meta pode banir número. Envio sem custo. Edge functions: evolution-whatsapp-inbound, evolution-webhook-provision, send-whatsapp-message."
},
{
"name": "Twilio WhatsApp Business API",
"role": "WhatsApp oficial (tier pago rebrandeado como 'WhatsApp Oficial AgenciaPSI')",
"env": "Cloud",
"status": "ativo",
"notes": "API oficial Meta, zero risco de ban. Credenciais em notification_channels (twilio_subaccount_sid + credentials.subaccount_auth_token). Envio consome 1 crédito via RPC deduct_whatsapp_credits (atômico + rollback em falha). Provisionamento: supabase/functions/twilio-whatsapp-provision/."
}
]
},
"Pagamentos / Billing": {
"color": "#fb923c",
"items": [
{
"name": "Asaas (gateway PIX/cartão/boleto)",
"role": "Processamento de pagamentos pra compra de créditos WhatsApp (Marco B)",
"env": "Cloud — sandbox.asaas.com em dev, api.asaas.com em prod",
"status": "ativo",
"notes": "API key em ASAAS_API_KEY (env secret). URL em ASAAS_API_URL. Webhook token opcional em ASAAS_WEBHOOK_TOKEN. Edge functions: create-whatsapp-credit-charge (cria customer + PIX), asaas-webhook (recebe PAYMENT_RECEIVED/CONFIRMED e credita saldo via add_whatsapp_credits)."
},
{
"name": "ngrok (dev only — tunnel pro webhook)",
"role": "Expõe edge functions locais pra Asaas alcançar via internet",
"env": "Local (dev)",
"status": "opcional",
"notes": "Uso: ngrok http 54321 → copia URL e cadastra em Asaas → Integrações → Webhooks → /functions/v1/asaas-webhook. Necessário só pra testar fluxo completo local incluindo confirmação de pagamento."
}
]
},
"Geração de documentos": {
"color": "#38bdf8",
"items": [
{
"name": "pdfmake 0.3.7",
"role": "Geração de PDF client-side (atestados, laudos, recibos)",
"env": "Browser",
"status": "ativo",
"notes": "UMD/webpack. Requer optimizeDeps.include explícito no vite.config.mjs."
},
{
"name": "html-to-pdfmake / html2pdf.js / jsPDF",
"role": "Conversão HTML→PDF para documentos ricos",
"env": "Browser",
"status": "ativo",
"notes": "Usado em document_templates e documents gerados para pacientes."
},
{
"name": "Jodit + Quill",
"role": "Editores de texto rico para templates de documentos",
"env": "Browser",
"status": "ativo",
"notes": "Jodit em DocumentTemplateEditor; Quill em páginas legadas. Migração em andamento."
},
{
"name": "html2canvas-pro",
"role": "Captura de screenshots de DOM (preview/export)",
"env": "Browser",
"status": "ativo",
"notes": "Usado para thumbnails de templates e previews."
}
]
},
"Frontend": {
"color": "#a78bfa",
"items": [
{
"name": "Vue 3 + Composition API",
"role": "Framework principal (script setup)",
"env": "Browser",
"status": "ativo",
"notes": "~487 componentes Vue. Pinia para state management."
},
{
"name": "Vite 5",
"role": "Build tool e dev server",
"env": "Node.js",
"status": "ativo",
"notes": "vite-plugin-compression (Brotli/Gzip), unplugin-auto-import para PrimeVue e Vue. rollup-plugin-visualizer para análise de bundle."
},
{
"name": "PrimeVue 4 (tema Sakai)",
"role": "Biblioteca de componentes UI",
"env": "Browser",
"status": "ativo",
"notes": "@primeuix/themes. auto-import-resolver. DataTable, Dialog, DatePicker, Popover, Toast, ConfirmDialog headless."
},
{
"name": "Tailwind CSS v4",
"role": "Utility-first CSS",
"env": "Browser",
"status": "ativo",
"notes": "@tailwindcss/vite + tailwindcss-primeui. Surface tokens do PrimeVue (var(--surface-card), var(--text-color-secondary))."
},
{
"name": "Vue Router",
"role": "Roteamento SPA com guards por role/tenant",
"env": "Browser",
"status": "ativo",
"notes": "Grupos de rota: therapist, admin, supervisor, saas, billing, account, configuracoes, features."
},
{
"name": "FullCalendar 6",
"role": "Calendário para agenda de terapeutas",
"env": "Browser",
"status": "ativo",
"notes": "Plugins: daygrid, timegrid, interaction, list, resource, resource-timegrid."
},
{
"name": "Chart.js 3",
"role": "Gráficos para dashboards (financeiro, KPIs)",
"env": "Browser",
"status": "ativo",
"notes": "Usado em dashboards do therapist e clinic."
}
]
},
"Dev / Tooling": {
"color": "#94a3b8",
"items": [
{
"name": "Supabase CLI",
"role": "Gerencia ambiente local, migrations, edge functions",
"env": "Node.js",
"status": "ativo",
"notes": "Via npx supabase. Start/stop/status/db-push/functions-deploy."
},
{
"name": "db.cjs (este projeto)",
"role": "CLI auxiliar pra setup/backup/restore/migrate/verify via docker exec",
"env": "Node.js",
"status": "ativo",
"notes": "Complementa o supabase CLI com fluxo schema + fixes + seeds + migrations. Encoding UTF-8 preservado."
},
{
"name": "generate-dashboard.cjs",
"role": "Gera dashboard HTML estático do schema (tabelas, FKs, infra)",
"env": "Node.js",
"status": "ativo",
"notes": "Standalone, sem dependências externas. Lê config de db.config.json e schema do backup mais recente."
},
{
"name": "Vitest 4",
"role": "Runner de testes unitários",
"env": "Node.js",
"status": "ativo",
"notes": "npm test / test:watch / test:ui. Bateria inicial em src/**/__tests__."
},
{
"name": "ESLint + Prettier",
"role": "Lint + formatação automática",
"env": "Node.js",
"status": "ativo",
"notes": "@vue/eslint-config-prettier. Rodado via npm run lint."
}
]
}
}
}
+176
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.**
+191
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 |
+297
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.
+90
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 |
@@ -0,0 +1,11 @@
-- ============================================================
-- Fix: addon_credits e addon_transactions tenant_id FK
-- Corrige FK que apontava para auth.users → agora aponta para public.tenants
-- Agência PSI — 2026-03-22
-- ============================================================
ALTER TABLE public.addon_credits DROP CONSTRAINT IF EXISTS addon_credits_tenant_id_fkey;
ALTER TABLE public.addon_credits ADD CONSTRAINT addon_credits_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
ALTER TABLE public.addon_transactions DROP CONSTRAINT IF EXISTS addon_transactions_tenant_id_fkey;
ALTER TABLE public.addon_transactions ADD CONSTRAINT addon_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id);
@@ -0,0 +1,83 @@
-- ============================================================
-- Fix: RLS addon_credits e addon_transactions
-- 1. SaaS Admin: acesso total
-- 2. Tenant members: SELECT nos seus créditos/transações
-- Agência PSI — 2026-03-22
-- ============================================================
-- ── addon_products: admin pode tudo (CRUD) ────────────────────
DROP POLICY IF EXISTS "addon_products_admin_all" ON public.addon_products;
CREATE POLICY "addon_products_admin_all"
ON public.addon_products FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_credits: admin pode ver todos ───────────────────────
DROP POLICY IF EXISTS "addon_credits_admin_select" ON public.addon_credits;
CREATE POLICY "addon_credits_admin_select"
ON public.addon_credits FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_credits: admin pode inserir/atualizar ───────────────
DROP POLICY IF EXISTS "addon_credits_admin_write" ON public.addon_credits;
CREATE POLICY "addon_credits_admin_write"
ON public.addon_credits FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_transactions: admin pode ver todas ──────────────────
DROP POLICY IF EXISTS "addon_transactions_admin_select" ON public.addon_transactions;
CREATE POLICY "addon_transactions_admin_select"
ON public.addon_transactions FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ── addon_transactions: admin pode inserir ────────────────────
DROP POLICY IF EXISTS "addon_transactions_admin_insert" ON public.addon_transactions;
CREATE POLICY "addon_transactions_admin_insert"
ON public.addon_transactions FOR INSERT
TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- ══════════════════════════════════════════════════════════════
-- Corrige policies de tenant members (SELECT)
-- A policy original usava tenant_id = auth.uid(), mas o auth.uid()
-- é o user_id, não o tenant_id. Usa is_tenant_member() em vez disso.
-- ══════════════════════════════════════════════════════════════
-- addon_credits: membro do tenant vê os créditos do seu tenant
DROP POLICY IF EXISTS "addon_credits_select_own" ON public.addon_credits;
CREATE POLICY "addon_credits_select_own"
ON public.addon_credits FOR SELECT
TO authenticated
USING (
public.is_tenant_member(tenant_id)
OR owner_id = auth.uid()
);
-- addon_transactions: membro do tenant vê as transações do seu tenant
DROP POLICY IF EXISTS "addon_transactions_select_own" ON public.addon_transactions;
CREATE POLICY "addon_transactions_select_own"
ON public.addon_transactions FOR SELECT
TO authenticated
USING (
public.is_tenant_member(tenant_id)
OR owner_id = auth.uid()
);
@@ -0,0 +1,179 @@
-- =============================================================================
-- FIX: Corrige acentuação perdida (caracteres ?? no banco)
-- =============================================================================
-- Causa: Seeds aplicados originalmente sem encoding UTF-8 correto.
-- Os ?? são bytes literais 0x3F (ASCII ?) onde deveria haver UTF-8.
-- Este fix faz UPDATE direto nos valores conhecidos.
-- =============================================================================
BEGIN;
SET client_encoding TO 'UTF8';
-- ============================================================
-- 1. PROFILES — full_name
-- ============================================================
UPDATE profiles SET full_name = 'Clínica Espaço Psi' WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003' AND full_name != 'Clínica Espaço Psi';
UPDATE profiles SET full_name = 'Clínica Mente Sã' WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004' AND full_name != 'Clínica Mente Sã';
UPDATE profiles SET full_name = 'Clínica Bem Estar' WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005' AND full_name != 'Clínica Bem Estar';
UPDATE profiles SET full_name = 'Gabriela Secretária' WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011' AND full_name != 'Gabriela Secretária';
-- ============================================================
-- 2. TENANTS — name
-- ============================================================
UPDATE tenants SET name = 'Clínica Espaço Psi' WHERE id = 'bbbbbbbb-0003-0003-0003-000000000003';
UPDATE tenants SET name = 'Clínica Mente Sã' WHERE id = 'bbbbbbbb-0004-0004-0004-000000000004';
UPDATE tenants SET name = 'Clínica Bem Estar' WHERE id = 'bbbbbbbb-0005-0005-0005-000000000005';
-- ============================================================
-- 3. DETERMINED_COMMITMENTS — name
-- ============================================================
UPDATE determined_commitments SET name = 'Sessão' WHERE native_key = 'session';
UPDATE determined_commitments SET name = 'Supervisão' WHERE native_key = 'supervision';
UPDATE determined_commitments SET name = 'Análise Pessoal' WHERE native_key = 'analysis';
-- ============================================================
-- 4. PLANS — name, description
-- ============================================================
UPDATE plans SET name = 'THERAPIST PRO', description = 'Plano profissional para terapeutas' WHERE key = 'therapist_pro' AND description LIKE '%??%';
UPDATE plans SET name = 'CLINIC PRO', description = 'Plano profissional para clínicas' WHERE key = 'clinic_pro' AND description LIKE '%??%';
UPDATE plans SET name = 'THERAPIST FREE', description = 'Plano gratuito para terapeutas' WHERE key = 'therapist_free' AND description LIKE '%??%';
UPDATE plans SET name = 'CLINIC FREE', description = 'Plano gratuito para clínicas' WHERE key = 'clinic_free' AND description LIKE '%??%';
-- ============================================================
-- 5. FEATURES — name, description
-- ============================================================
UPDATE features SET name = 'Agenda - Visualizar', description = 'Visualização da agenda' WHERE key = 'agenda.view';
UPDATE features SET name = 'Agenda - Gerenciar', description = 'Gerenciamento completo da agenda' WHERE key = 'agenda.manage';
UPDATE features SET name = 'Pacientes', description = 'Módulo de pacientes' WHERE key = 'patients';
UPDATE features SET name = 'Pacientes - Visualizar', description = 'Visualização de pacientes' WHERE key = 'patients.view';
UPDATE features SET name = 'Pacientes - Gerenciar', description = 'Gerenciamento completo de pacientes' WHERE key = 'patients.manage';
UPDATE features SET name = 'Agendamento Online', description = 'Sistema de agendamento online' WHERE key = 'online_scheduling';
UPDATE features SET name = 'Agendamento Online - Gerenciar', description = 'Gerenciamento do agendamento online' WHERE key = 'online_scheduling.manage';
UPDATE features SET name = 'Agendamento Online - Público', description = 'Página pública do agendador' WHERE key = 'online_scheduling.public';
UPDATE features SET name = 'Lembretes', description = 'Sistema de lembretes automáticos' WHERE key = 'reminders';
UPDATE features SET name = 'Relatórios Básicos', description = 'Relatórios básicos' WHERE key = 'reports_basic';
UPDATE features SET name = 'Relatórios Avançados', description = 'Relatórios avançados com exportação' WHERE key = 'reports_advanced';
UPDATE features SET name = 'Secretária', description = 'Funcionalidade de secretária' WHERE key = 'secretary';
UPDATE features SET name = 'Recepção Compartilhada', description = 'Recepção compartilhada entre terapeutas' WHERE key = 'shared_reception';
UPDATE features SET name = 'Salas', description = 'Gerenciamento de salas' WHERE key = 'rooms';
UPDATE features SET name = 'Intake Público', description = 'Formulário de intake público' WHERE key = 'intake_public';
UPDATE features SET name = 'Intakes PRO', description = 'Funcionalidades avançadas de intake' WHERE key = 'intakes_pro';
UPDATE features SET name = 'Branding Personalizado', description = 'Personalização de marca' WHERE key = 'custom_branding';
UPDATE features SET name = 'Acesso API', description = 'Acesso via API' WHERE key = 'api_access';
UPDATE features SET name = 'Log de Auditoria', description = 'Log de auditoria completo' WHERE key = 'audit_log';
UPDATE features SET name = 'Lembrete SMS', description = 'Lembretes via SMS' WHERE key = 'sms_reminder';
UPDATE features SET name = 'Calendário da Clínica', description = 'Visão consolidada do calendário' WHERE key = 'clinic_calendar';
UPDATE features SET name = 'Relatórios Avançados (Clínica)', description = 'Relatórios avançados da clínica' WHERE key = 'advanced_reports';
UPDATE features SET name = 'Supervisor - Acesso', description = 'Acesso ao módulo de supervisão' WHERE key = 'supervisor.access';
UPDATE features SET name = 'Supervisor - Convidar', description = 'Convidar supervisionados' WHERE key = 'supervisor.invite';
UPDATE features SET name = 'Supervisor - Sessões', description = 'Gerenciar sessões de supervisão' WHERE key = 'supervisor.sessions';
UPDATE features SET name = 'Supervisor - Relatórios', description = 'Relatórios de supervisão' WHERE key = 'supervisor.reports';
-- ============================================================
-- 6. EMAIL_TEMPLATES_GLOBAL — subject, body_html, body_text
-- ============================================================
UPDATE email_templates_global SET
subject = 'Lembrete: sua sessão amanhã às {{session_time}}',
body_text = 'Olá {{patient_name}}, lembrete da sua sessão amanhã às {{session_time}} com {{therapist_name}}.'
WHERE key = 'session.reminder';
UPDATE email_templates_global SET
subject = 'Sessão confirmada — {{session_date}} às {{session_time}}',
body_text = 'Sua sessão com {{therapist_name}} em {{session_date}} às {{session_time}} foi confirmada.'
WHERE key = 'session.confirmation';
UPDATE email_templates_global SET
subject = 'Sessão cancelada — {{session_date}}',
body_text = 'A sessão de {{session_date}} às {{session_time}} com {{therapist_name}} foi cancelada.'
WHERE key = 'session.cancellation';
UPDATE email_templates_global SET
subject = 'Sessão reagendada — novo horário: {{session_date}} às {{session_time}}',
body_text = 'Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.'
WHERE key = 'session.rescheduled';
UPDATE email_templates_global SET
subject = 'Recebemos seu cadastro — {{patient_name}}',
body_text = 'Olá {{patient_name}}, recebemos seu formulário de cadastro. Entraremos em contato em breve.'
WHERE key = 'intake.received';
UPDATE email_templates_global SET
subject = 'Cadastro aprovado — Bem-vindo(a)!',
body_text = 'Olá {{patient_name}}, seu cadastro foi aprovado. Você já pode acessar a plataforma.'
WHERE key = 'intake.approved';
UPDATE email_templates_global SET
subject = 'Cadastro não aprovado',
body_text = 'Olá {{patient_name}}, infelizmente seu cadastro não foi aprovado no momento.'
WHERE key = 'intake.rejected';
UPDATE email_templates_global SET
subject = 'Solicitação aceita — {{session_date}} às {{session_time}}',
body_text = 'Sua solicitação de agendamento para {{session_date}} às {{session_time}} foi aceita.'
WHERE key = 'scheduler.request_accepted';
UPDATE email_templates_global SET
subject = 'Solicitação não disponível',
body_text = 'Infelizmente o horário solicitado não está disponível. Por favor, escolha outro horário.'
WHERE key = 'scheduler.request_rejected';
UPDATE email_templates_global SET
subject = 'Bem-vindo(a) à AgenciaPsi!',
body_text = 'Olá {{user_name}}, sua conta foi criada com sucesso. Acesse a plataforma para começar.'
WHERE key = 'system.welcome';
UPDATE email_templates_global SET
subject = 'Redefinição de senha — AgenciaPsi',
body_text = 'Clique no link abaixo para redefinir sua senha: {{reset_link}}'
WHERE key = 'system.password_reset';
-- ============================================================
-- 7. LOGIN_CAROUSEL_SLIDES — title, description
-- ============================================================
UPDATE login_carousel_slides SET
title = '<strong>Gestão clínica simplificada</strong>',
body = 'Gerencie agenda, pacientes e financeiro em um só lugar. Simples, rápido e seguro.'
WHERE ordem = 1;
UPDATE login_carousel_slides SET
title = '<strong>Múltiplos profissionais, uma só plataforma</strong>',
body = 'Ideal para clínicas com vários terapeutas. Cada profissional com sua agenda e seus pacientes.'
WHERE ordem = 2;
UPDATE login_carousel_slides SET
title = '<strong>Seguro, privado e sempre disponível</strong>',
body = 'Seus dados protegidos com criptografia. Acesse de qualquer lugar, a qualquer hora.'
WHERE ordem = 3;
-- ============================================================
-- 8. PATIENT_GROUPS (default groups) — name
-- ============================================================
UPDATE patient_groups SET nome = 'Crianças' WHERE nome LIKE 'Crian%' AND is_system = true;
UPDATE patient_groups SET nome = 'Adolescentes' WHERE nome LIKE 'Adolescen%' AND is_system = true;
UPDATE patient_groups SET nome = 'Idosos' WHERE nome LIKE 'Idoso%' AND is_system = true;
-- ============================================================
-- 9. AUTH.USERS — raw_user_meta_data (name field)
-- ============================================================
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Espaço Psi"') WHERE id = 'aaaaaaaa-0003-0003-0003-000000000003';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Mente Sã"') WHERE id = 'aaaaaaaa-0004-0004-0004-000000000004';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Clínica Bem Estar"') WHERE id = 'aaaaaaaa-0005-0005-0005-000000000005';
UPDATE auth.users SET raw_user_meta_data = jsonb_set(raw_user_meta_data, '{name}', '"Gabriela Secretária"') WHERE id = 'aaaaaaaa-0011-0011-0011-000000000011';
COMMIT;
-- ============================================================
DO $$
DECLARE
broken_count int;
BEGIN
SELECT count(*) INTO broken_count
FROM profiles WHERE full_name LIKE '%??%';
IF broken_count = 0 THEN
RAISE NOTICE 'fix_encoding_accents: Todos os acentos corrigidos com sucesso.';
ELSE
RAISE WARNING 'fix_encoding_accents: Ainda restam % registros com ?? em profiles.full_name', broken_count;
END IF;
END $$;
@@ -0,0 +1,220 @@
-- =============================================================================
-- FIX: Atribuir plano free a usuários/tenants sem assinatura ativa
-- =============================================================================
-- Execute no SQL Editor do Supabase (service_role)
-- Idempotente: só insere onde não existe assinatura ativa.
--
-- Regras:
-- • tenant kind = 'therapist' → therapist_free (por user_id do admin)
-- • tenant kind IN (clinic_*) → clinic_free (por tenant_id)
-- • profiles.account_type = 'patient' / portal_user → patient_free (por user_id)
-- =============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────────
-- DIAGNÓSTICO — mostra o estado atual antes de corrigir
-- ──────────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
r RECORD;
BEGIN
RAISE NOTICE '=== DIAGNÓSTICO DE ASSINATURAS ===';
RAISE NOTICE '';
-- Terapeutas sem plano
RAISE NOTICE '--- Terapeutas SEM assinatura ativa ---';
FOR r IN
SELECT
tm.user_id,
p.full_name,
t.id AS tenant_id,
t.name AS tenant_name
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
JOIN public.profiles p ON p.id = tm.user_id
WHERE t.kind = 'therapist'
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tm.user_id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%) — tenant %', r.full_name, r.user_id, r.tenant_id;
END LOOP;
-- Clínicas sem plano
RAISE NOTICE '';
RAISE NOTICE '--- Clínicas SEM assinatura ativa ---';
FOR r IN
SELECT t.id, t.name, t.kind
FROM public.tenants t
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.tenant_id = t.id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%) — kind %', r.name, r.id, r.kind;
END LOOP;
-- Pacientes sem plano
RAISE NOTICE '';
RAISE NOTICE '--- Pacientes SEM assinatura ativa ---';
FOR r IN
SELECT p.id, p.full_name
FROM public.profiles p
WHERE p.account_type = 'patient'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = p.id
AND s.status = 'active'
)
LOOP
RAISE NOTICE ' FALTANDO: % (%)', r.full_name, r.id;
END LOOP;
RAISE NOTICE '';
RAISE NOTICE '=== FIM DO DIAGNÓSTICO — aplicando correções... ===';
END;
$$;
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 1: Terapeutas sem assinatura → therapist_free
-- Escopo: user_id do tenant_admin do tenant kind='therapist'
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
tm.user_id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.tenant_members tm
JOIN public.tenants t ON t.id = tm.tenant_id
JOIN public.plans p ON p.key = 'therapist_free'
WHERE t.kind = 'therapist'
AND tm.role = 'tenant_admin'
AND tm.status = 'active'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tm.user_id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 2: Clínicas sem assinatura → clinic_free
-- Escopo: tenant_id
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
tenant_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
t.id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.tenants t
JOIN public.plans p ON p.key = 'clinic_free'
WHERE t.kind IN ('clinic_coworking', 'clinic_reception', 'clinic_full', 'clinic')
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.tenant_id = t.id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CORREÇÃO 3: Pacientes sem assinatura → patient_free
-- Escopo: user_id
-- ──────────────────────────────────────────────────────────────────────────────
INSERT INTO public.subscriptions (
user_id, plan_id, plan_key, status, interval,
current_period_start, current_period_end,
source, started_at, activated_at
)
SELECT
pr.id,
p.id,
p.key,
'active',
'month',
now(),
now() + interval '30 days',
'fix_seed',
now(),
now()
FROM public.profiles pr
JOIN public.plans p ON p.key = 'patient_free'
WHERE pr.account_type = 'patient'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = pr.id
AND s.status = 'active'
);
-- ──────────────────────────────────────────────────────────────────────────────
-- CONFIRMAÇÃO — mostra o que foi inserido (source = 'fix_seed')
-- ──────────────────────────────────────────────────────────────────────────────
DO $$
DECLARE
r RECORD;
total INT := 0;
BEGIN
RAISE NOTICE '';
RAISE NOTICE '=== ASSINATURAS CRIADAS NESTA EXECUÇÃO ===';
FOR r IN
SELECT
s.plan_key,
COALESCE(pr.full_name, t.name) AS nome,
COALESCE(s.user_id::text, s.tenant_id::text) AS owner_id
FROM public.subscriptions s
LEFT JOIN public.profiles pr ON pr.id = s.user_id
LEFT JOIN public.tenants t ON t.id = s.tenant_id
WHERE s.source = 'fix_seed'
AND s.started_at >= now() - interval '5 seconds'
ORDER BY s.plan_key, nome
LOOP
RAISE NOTICE ' ✅ % → % (%)', r.plan_key, r.nome, r.owner_id;
total := total + 1;
END LOOP;
IF total = 0 THEN
RAISE NOTICE ' (nenhuma nova assinatura criada — todos já tinham plano ativo)';
ELSE
RAISE NOTICE '';
RAISE NOTICE ' Total: % assinatura(s) criada(s).', total;
END IF;
END;
$$;
COMMIT;
@@ -0,0 +1,45 @@
-- ============================================================
-- Fix: RLS notification_templates — acesso SaaS Admin
-- Admin precisa criar/editar/excluir templates globais (tenant_id IS NULL)
-- Agência PSI — 2026-03-22
-- ============================================================
-- SaaS Admin: acesso total (SELECT + INSERT + UPDATE + DELETE)
DROP POLICY IF EXISTS "notif_templates_admin_all" ON public.notification_templates;
CREATE POLICY "notif_templates_admin_all"
ON public.notification_templates FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
)
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins WHERE user_id = auth.uid())
);
-- Tenant member: pode ler os globais + os do seu tenant
DROP POLICY IF EXISTS "notif_templates_read_global" ON public.notification_templates;
CREATE POLICY "notif_templates_read_global"
ON public.notification_templates FOR SELECT
TO authenticated
USING (
deleted_at IS NULL
AND (
(tenant_id IS NULL AND is_default = true)
OR owner_id = auth.uid()
OR public.is_tenant_member(tenant_id)
)
);
-- Tenant member: pode inserir/atualizar templates do seu tenant
DROP POLICY IF EXISTS "notif_templates_write_owner" ON public.notification_templates;
CREATE POLICY "notif_templates_write_owner"
ON public.notification_templates FOR ALL
TO authenticated
USING (
owner_id = auth.uid()
OR public.is_tenant_member(tenant_id)
)
WITH CHECK (
owner_id = auth.uid()
OR public.is_tenant_member(tenant_id)
);
@@ -0,0 +1,37 @@
-- ============================================================
-- Fix: cria função seed_default_patient_groups
-- Colunas reais: nome, cor, descricao, tenant_id (NOT NULL)
-- ============================================================
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_owner_id uuid;
BEGIN
-- busca o owner (tenant_admin) do tenant
SELECT user_id INTO v_owner_id
FROM public.tenant_members
WHERE tenant_id = p_tenant_id
AND role = 'tenant_admin'
AND status = 'active'
LIMIT 1;
IF v_owner_id IS NULL THEN
RETURN;
END IF;
INSERT INTO public.patient_groups (owner_id, nome, cor, is_system, tenant_id)
VALUES
(v_owner_id, 'Crianças', '#60a5fa', true, p_tenant_id),
(v_owner_id, 'Adolescentes', '#a78bfa', true, p_tenant_id),
(v_owner_id, 'Idosos', '#34d399', true, p_tenant_id)
ON CONFLICT (owner_id, nome) DO NOTHING;
END;
$$;
GRANT EXECUTE ON FUNCTION public.seed_default_patient_groups(uuid)
TO postgres, anon, authenticated, service_role;
@@ -0,0 +1,50 @@
-- Fix: subscriptions_validate_scope — adiciona suporte a target='patient'
CREATE OR REPLACE FUNCTION public.subscriptions_validate_scope()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_target text;
BEGIN
SELECT lower(p.target) INTO v_target
FROM public.plans p
WHERE p.id = NEW.plan_id;
IF v_target IS NULL THEN
RAISE EXCEPTION 'Plano inválido (target nulo).';
END IF;
IF v_target = 'clinic' THEN
IF NEW.tenant_id IS NULL THEN
RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
END IF;
IF NEW.user_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura clinic não pode ter user_id (XOR).';
END IF;
ELSIF v_target = 'therapist' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura therapist não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura therapist exige user_id.';
END IF;
ELSIF v_target = 'patient' THEN
IF NEW.tenant_id IS NOT NULL THEN
RAISE EXCEPTION 'Assinatura patient não deve ter tenant_id.';
END IF;
IF NEW.user_id IS NULL THEN
RAISE EXCEPTION 'Assinatura patient exige user_id.';
END IF;
ELSE
RAISE EXCEPTION 'Target de plano inválido: %', v_target;
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.subscriptions_validate_scope() OWNER TO supabase_admin;
@@ -0,0 +1,78 @@
-- ============================================================
-- Fix: Template keys devem casar com o que populate_notification_queue gera
-- Agência PSI — 2026-03-22
-- ============================================================
-- O populate gera: 'session.' || REPLACE(event_type, '_sessao', '') || '.' || channel
-- Ex: event_type='lembrete_sessao' → 'session.lembrete.whatsapp'
--
-- Os seeds usavam nomes em inglês (session.reminder.whatsapp).
-- Este fix renomeia para casar com o populate.
-- ============================================================
-- ── 1. Renomeia templates existentes ──────────────────────────
UPDATE public.notification_templates
SET key = 'session.lembrete.whatsapp'
WHERE key = 'session.reminder.whatsapp';
UPDATE public.notification_templates
SET key = 'session.lembrete_2h.whatsapp'
WHERE key = 'session.reminder_2h.whatsapp';
UPDATE public.notification_templates
SET key = 'session.confirmacao.whatsapp'
WHERE key = 'session.confirmation.whatsapp';
UPDATE public.notification_templates
SET key = 'session.cancelamento.whatsapp'
WHERE key = 'session.cancellation.whatsapp';
UPDATE public.notification_templates
SET key = 'session.reagendamento.whatsapp'
WHERE key = 'session.reschedule.whatsapp';
UPDATE public.notification_templates
SET key = 'cobranca.pendente.whatsapp'
WHERE key = 'billing.pending.whatsapp';
UPDATE public.notification_templates
SET key = 'sistema.boas_vindas.whatsapp'
WHERE key = 'system.welcome.whatsapp';
-- ── SMS templates (mesmo padrão) ──────────────────────────────
UPDATE public.notification_templates
SET key = 'session.lembrete.sms'
WHERE key = 'session.reminder.sms';
UPDATE public.notification_templates
SET key = 'session.lembrete_2h.sms'
WHERE key = 'session.reminder_2h.sms';
UPDATE public.notification_templates
SET key = 'session.confirmacao.sms'
WHERE key = 'session.confirmation.sms';
UPDATE public.notification_templates
SET key = 'session.cancelamento.sms'
WHERE key = 'session.cancellation.sms';
UPDATE public.notification_templates
SET key = 'session.reagendamento.sms'
WHERE key = 'session.reschedule.sms';
UPDATE public.notification_templates
SET key = 'cobranca.pendente.sms'
WHERE key = 'billing.pending.sms';
UPDATE public.notification_templates
SET key = 'sistema.boas_vindas.sms'
WHERE key = 'system.welcome.sms';
-- ── 2. Verifica resultado ─────────────────────────────────────
SELECT key, channel, domain, event_type, is_default
FROM notification_templates
WHERE deleted_at IS NULL
ORDER BY channel, key;
@@ -0,0 +1,163 @@
-- ============================================================
-- Fix: Remove templates com keys em inglês (WhatsApp/SMS)
-- Agência PSI — 2026-04-22
-- ============================================================
-- Ambiente de desenvolvimento sem dados reais: DELETE físico.
-- Mantém apenas as keys canônicas em português definidas
-- pelo seed_014_global_data.sql. Se alguma key PT estiver
-- faltando após rodar esta migration, rode o Step 3 de reseed.
--
-- Idempotente: rodar de novo não causa erro (DELETE simples
-- encontra 0 linhas).
-- ============================================================
BEGIN;
-- ── Step 1: Snapshot ANTES ────────────────────────────────────────────
-- Útil pra conferir o que vai ser apagado
SELECT
'BEFORE' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active,
deleted_at
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 2: DELETE físico de todas as keys em inglês ─────────────────
DELETE FROM public.notification_templates
WHERE key IN (
-- WhatsApp — variantes em inglês
'session.reminder.whatsapp',
'session.reminder_2h.whatsapp',
'session.confirmation.whatsapp',
'session.cancellation.whatsapp',
'session.reschedule.whatsapp',
'session.rescheduled.whatsapp',
'billing.pending.whatsapp',
'system.welcome.whatsapp',
-- SMS — variantes em inglês
'session.reminder.sms',
'session.reminder_2h.sms',
'session.confirmation.sms',
'session.cancellation.sms',
'session.reschedule.sms',
'session.rescheduled.sms',
'billing.pending.sms',
'system.welcome.sms'
);
-- ── Step 3: Re-seed (inserção idempotente) das keys PT canônicas ─────
-- Garante que todas as keys esperadas existem como globais ativas.
-- Usa INSERT … ON CONFLICT DO UPDATE para ser idempotente.
-- Os body_text são placeholders padrão; se quiser textos diferentes,
-- edite depois via /configuracoes/whatsapp-templates ou /saas/notification-templates.
INSERT INTO public.notification_templates
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES
-- ── WhatsApp ─────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Lembrete: sua sessão com {{therapist_name}} é amanhã às {{session_time}}. Até lá!',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} começa em 2 horas ({{session_time}}). Até já!',
'["patient_name","therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.whatsapp', 'session', 'whatsapp', 'confirmacao_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} foi confirmada para {{session_date}} às {{session_time}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.whatsapp', 'session', 'whatsapp', 'cancelamento_sessao',
'Olá {{patient_name}}. Sua sessão de {{session_date}} às {{session_time}} foi cancelada. Entre em contato para remarcar.',
'["patient_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.whatsapp', 'session', 'whatsapp', 'reagendamento',
'Olá {{patient_name}}! Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.whatsapp', 'billing', 'whatsapp', 'cobranca_pendente',
'Olá {{patient_name}}! Identificamos um pagamento pendente de {{valor}} com vencimento em {{vencimento}}. Qualquer dúvida, estou à disposição.',
'["patient_name","valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.whatsapp', 'system', 'whatsapp', 'boas_vindas_paciente',
'Olá {{patient_name}}! Bem-vindo(a) à {{clinic_name}}. Seu terapeuta {{therapist_name}} está à disposição.',
'["patient_name","clinic_name","therapist_name"]'::jsonb, true, true),
-- ── SMS ──────────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.sms', 'session', 'sms', 'lembrete_sessao',
'Lembrete: sua sessao com {{therapist_name}} e amanha as {{session_time}}.',
'["therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.sms', 'session', 'sms', 'lembrete_sessao',
'Sua sessao com {{therapist_name}} comeca em 2h ({{session_time}}).',
'["therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.sms', 'session', 'sms', 'confirmacao_sessao',
'Sua sessao foi confirmada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.sms', 'session', 'sms', 'cancelamento_sessao',
'Sua sessao de {{session_date}} as {{session_time}} foi cancelada.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.sms', 'session', 'sms', 'reagendamento',
'Sua sessao foi reagendada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.sms', 'billing', 'sms', 'cobranca_pendente',
'Pagamento pendente: {{valor}}, venc. {{vencimento}}.',
'["valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.sms', 'system', 'sms', 'boas_vindas_paciente',
'Bem-vindo a {{clinic_name}}! Seu terapeuta e {{therapist_name}}.',
'["clinic_name","therapist_name"]'::jsonb, true, true)
ON CONFLICT (tenant_id, owner_id, key, deleted_at)
DO UPDATE SET
body_text = EXCLUDED.body_text,
variables = EXCLUDED.variables,
is_default = EXCLUDED.is_default,
is_active = EXCLUDED.is_active,
domain = EXCLUDED.domain,
event_type = EXCLUDED.event_type,
updated_at = now();
-- ── Step 4: Snapshot DEPOIS ──────────────────────────────────────────
SELECT
'AFTER' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
AND deleted_at IS NULL
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 5: Verificação — esperado 0 linhas ativas em EN ─────────────
SELECT
count(*) AS remaining_english_keys
FROM public.notification_templates
WHERE deleted_at IS NULL
AND key IN (
'session.reminder.whatsapp', 'session.reminder_2h.whatsapp', 'session.confirmation.whatsapp',
'session.cancellation.whatsapp', 'session.reschedule.whatsapp', 'session.rescheduled.whatsapp',
'billing.pending.whatsapp', 'system.welcome.whatsapp',
'session.reminder.sms', 'session.reminder_2h.sms', 'session.confirmation.sms',
'session.cancellation.sms', 'session.reschedule.sms', 'session.rescheduled.sms',
'billing.pending.sms', 'system.welcome.sms'
);
COMMIT;
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env node
// =============================================================================
// AgenciaPsi — Dashboard Generator
// =============================================================================
// Uso:
// node generate-dashboard.cjs → usa backup mais recente
// node generate-dashboard.cjs 2026-04-17 → usa backup de data específica
//
// Lê de: ./backups/YYYY-MM-DD/schema.sql
// Lê de: ./db.config.json (domínios, cores e infraestrutura)
// Gera: ./agenciapsi-db-dashboard.html (na mesma pasta do script)
// =============================================================================
const fs = require('fs');
const path = require('path');
const ROOT = __dirname;
const BACKUPS_DIR = path.join(ROOT, 'backups');
const OUTPUT_FILE = path.join(ROOT, 'agenciapsi-db-dashboard.html');
const CONFIG_FILE = path.join(ROOT, 'db.config.json');
// ---------------------------------------------------------------------------
// Carrega config (domínios, cores e infraestrutura)
// ---------------------------------------------------------------------------
if (!fs.existsSync(CONFIG_FILE)) {
console.error(`✖ Config não encontrada: ${CONFIG_FILE}`);
process.exit(1);
}
const CONFIG = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
const DOMAIN_TABLES = CONFIG.domains || {};
const DOMAIN_COLORS = CONFIG.domainColors || {};
const INFRASTRUCTURE = CONFIG.infrastructure || {};
// ---------------------------------------------------------------------------
// 1. Resolve qual schema.sql usar
// ---------------------------------------------------------------------------
function resolveSchema() {
const arg = process.argv[2];
if (!fs.existsSync(BACKUPS_DIR)) {
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
console.error(` Rode primeiro: node db.cjs backup`);
process.exit(1);
}
const available = fs
.readdirSync(BACKUPS_DIR)
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
.sort()
.reverse();
if (available.length === 0) {
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
console.error(' Rode primeiro: node db.cjs backup');
process.exit(1);
}
const date = arg && /^\d{4}-\d{2}-\d{2}$/.test(arg) ? arg : available[0];
if (!available.includes(date)) {
console.error(`✖ Backup não encontrado para: ${date}`);
console.error(` Disponíveis: ${available.join(', ')}`);
process.exit(1);
}
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
if (!fs.existsSync(schemaPath)) {
console.error(`✖ schema.sql não encontrado em backups/${date}/`);
process.exit(1);
}
return { schemaPath, date, available };
}
// ---------------------------------------------------------------------------
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
// ---------------------------------------------------------------------------
function parseSchema(content) {
const tables = {};
// Tabelas public.*
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
let m;
while ((m = tableRe.exec(content)) !== null) {
const name = m[1].replace('public.', '');
const body = m[2];
const columns = [];
for (let line of body.split('\n')) {
line = line.trim().replace(/,$/, '');
if (!line || line.startsWith('--')) continue;
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
const col = line.match(
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
);
if (col) {
columns.push({
name: col[1],
type: col[2].trim().split('(')[0].trim(),
pk: col[1] === 'id'
});
}
}
tables[name] = { columns, fks: [] };
}
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
while ((m = fkRe.exec(content)) !== null) {
const [, fromTable, fromCol, toTable, toCol] = m;
if (tables[fromTable]) {
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
}
}
// Views
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
const views = [];
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
return { tables, views };
}
// ---------------------------------------------------------------------------
// 3. Monta os domínios
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
// ---------------------------------------------------------------------------
function buildDomains(tables) {
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
const others = Object.keys(tables).filter((t) => !mapped.has(t) && t !== '_db_migrations');
const domains = {};
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
const present = list.filter((t) => tables[t]);
if (present.length > 0) domains[domain] = present;
}
if (others.length > 0) {
domains['Outros'] = others;
DOMAIN_COLORS['Outros'] = '#6b7280';
}
return domains;
}
// ---------------------------------------------------------------------------
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
// ---------------------------------------------------------------------------
function generateHTML(tables, views, domains, date, available) {
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
const infraGroups = Object.keys(INFRASTRUCTURE).length;
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
const generated = new Date().toLocaleString('pt-BR');
// Slug por domínio — usado como id para scroll (ex: "SaaS / Planos" → "saas-planos")
const slugify = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const domainSlugs = {};
for (const d of Object.keys(domains)) domainSlugs[d] = slugify(d);
// Serializa dados para embutir no HTML
const jsonData = JSON.stringify({ tables, views, domains, slugs: domainSlugs });
const jsonColors = JSON.stringify(DOMAIN_COLORS);
const jsonInfra = JSON.stringify(INFRASTRUCTURE);
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgenciaPsi DB · ${date}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#6366f1;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6;--ok:#34d399;--warn:#fbbf24;--pend:#f87171;--leg:#94a3b8}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
.topbar{position:sticky;top:0;z-index:100;background:rgba(11,13,18,.94);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 28px;height:56px;display:flex;align-items:center;gap:20px}
.brand{font-weight:700;font-size:15px;letter-spacing:-.3px}.brand span{color:var(--accent)}
.gen{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.pills{display:flex;gap:10px;margin-left:auto}
.pill{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:4px 12px}
.pill strong{color:var(--text);font-size:13px}
.search{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 12px;color:var(--text);font-family:'Space Grotesk',sans-serif;font-size:13px;outline:none;width:200px;transition:border-color .2s,width .2s}
.search:focus{border-color:var(--accent);width:280px}
.search::placeholder{color:var(--text3)}
.layout{display:flex;height:calc(100vh - 56px)}
.sidebar{width:260px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
.sb-i:hover{color:var(--text);background:var(--bg3)}
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(99,102,241,.08)}
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.main{flex:1;overflow-y:auto}
.main::-webkit-scrollbar{width:5px}.main::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
.overview{padding:32px 36px;border-bottom:1px solid var(--border)}
.ov-t{font-size:22px;font-weight:700;margin-bottom:6px}
.ov-s{font-size:14px;color:var(--text2);margin-bottom:28px}
.dgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.dc{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:16px 18px;cursor:pointer;transition:all .2s;position:relative;overflow:hidden}
.dc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--c)}
.dc:hover{border-color:var(--border2);transform:translateY(-1px)}
.dc-n{font-size:14px;font-weight:600;margin-bottom:6px}
.dc-m{font-size:12px;color:var(--text2);font-family:'IBM Plex Mono',monospace}
.dc-m span{font-weight:600}
.section{padding:28px 36px}
.sec-h{display:flex;align-items:center;gap:14px;margin-bottom:20px}
.sec-t{font-size:18px;font-weight:700}
.sec-b{font-size:11px;font-family:'IBM Plex Mono',monospace;background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:3px 10px;color:var(--text2)}
.tgrid{display:flex;flex-direction:column;gap:10px}
.tc{background:var(--bg3);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .15s}
.tc:hover{border-color:var(--border2)}.tc.hl{border-color:var(--accent)}
.tc-h{display:flex;align-items:center;gap:12px;padding:12px 16px;cursor:pointer;user-select:none}
.tc-n{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600}
.tc-m{font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
.tc-f{font-size:11px;color:var(--fk);font-family:'IBM Plex Mono',monospace;margin-left:4px}
.tc-tg{margin-left:auto;color:var(--text3);font-size:11px;transition:transform .2s}
.tc-tg.open{transform:rotate(180deg)}
.tc-b{display:none;border-top:1px solid var(--border)}.tc-b.open{display:block}
.cols{padding:6px 0}
.cr{display:flex;align-items:center;gap:10px;padding:5px 16px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2)}
.cr:hover{background:rgba(255,255,255,.02)}
.bdg{font-size:9px;font-weight:700;letter-spacing:.5px;padding:1px 5px;border-radius:3px;width:26px;text-align:center;flex-shrink:0}
.bdg.pk{background:rgba(251,191,36,.15);color:var(--pk)}.bdg.fk{background:rgba(244,114,182,.15);color:var(--fk)}.bdg.x{background:transparent}
.cn{color:var(--text)}.ct{color:var(--text3);margin-left:auto;font-size:11px}
.fksec{border-top:1px solid var(--border);padding:10px 16px}
.fkt{font-size:10px;font-weight:600;letter-spacing:1px;color:var(--text3);text-transform:uppercase;margin-bottom:8px}
.fkr{display:flex;align-items:center;gap:8px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--text2);padding:3px 0}
.fka{color:var(--fk)}.fkl{color:var(--accent);cursor:pointer}.fkl:hover{text-decoration:underline}
.vsec{padding:0 36px 32px}
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
mark{background:rgba(99,102,241,.3);color:#fff;border-radius:2px}
/* Infraestrutura */
.igroup{margin-bottom:28px}
.igroup-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.igroup-t{font-size:15px;font-weight:600;letter-spacing:-.2px}
.igroup-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
.ic{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:16px 18px;transition:border-color .15s;position:relative;overflow:hidden}
.ic::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;background:var(--c)}
.ic:hover{border-color:var(--border2)}
.ic-h{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.ic-n{font-size:14px;font-weight:600;flex:1;min-width:0}
.ic-st{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:10px;flex-shrink:0;white-space:nowrap}
.ic-st.ativo{background:rgba(52,211,153,.15);color:var(--ok)}
.ic-st.pendente{background:rgba(248,113,113,.15);color:var(--pend)}
.ic-st.planejado{background:rgba(251,191,36,.15);color:var(--warn)}
.ic-st.legado{background:rgba(148,163,184,.2);color:var(--leg)}
.ic-r{font-size:12px;color:var(--text2);margin-bottom:8px;line-height:1.5}
.ic-e{font-size:10px;color:var(--text3);font-family:'IBM Plex Mono',monospace;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
.ic-nt{font-size:11px;color:var(--text3);line-height:1.55;border-top:1px solid var(--border);padding-top:8px;margin-top:8px}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">Agência<span>Psi</span> DB</div>
<span class="gen">${date} · ${generated}</span>
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
<div class="pills">
<div class="pill"><strong>${Object.keys(tables).length}</strong> tabelas</div>
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
<div class="pill"><strong>${views.length}</strong> views</div>
<div class="pill"><strong>${totalCols}</strong> colunas</div>
<div class="pill"><strong>${infraItems}</strong> infra</div>
</div>
</div>
<div class="layout">
<nav class="sidebar" id="sb"></nav>
<main class="main" id="mn"></main>
</div>
<script>
const D=${jsonData};
const C=${jsonColors};
const INFRA=${jsonInfra};
const INFRA_GROUPS=${infraGroups};
const INFRA_ITEMS=${infraItems};
const T2D={};
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
let dom=null,view='overview',q='';
function gc(d){return C[d]||'#6b7280';}
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));}
function buildSB(){
let h=\`<div class="sb-h">Visão Geral</div>
<div class="sb-i \${view==='overview'&&!dom?'active':''}" onclick="selOverview()">
<div class="sb-dot" style="background:#6366f1"></div>Todos (tabelas)
<span class="sb-c">\${Object.keys(D.tables).length}</span>
</div>
<div class="sb-i \${view==='infra'?'active':''}" onclick="selInfra()">
<div class="sb-dot" style="background:#fbbf24"></div>Infraestrutura
<span class="sb-c">\${INFRA_ITEMS}</span>
</div>
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
for(const[d,ts]of Object.entries(D.domains)){
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
<span class="sb-c">\${ts.length}</span>
</div>\`;
}
h+=\`<div class="sb-i" onclick="scrollToViews()">
<div class="sb-dot" style="background:#6ee7b7"></div>Views
<span class="sb-c">\${D.views.length}</span>
</div>\`;
document.getElementById('sb').innerHTML=h;
}
function buildMN(){
const mn=document.getElementById('mn');
let h='';
if(q){
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${escapeHtml(q)}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
h+='</div></div>';
} else if(view==='infra'){
h+=\`<div class="overview"><div class="ov-t">Infraestrutura</div>
<div class="ov-s">Serviços, bibliotecas e ferramentas que o sistema usa · \${INFRA_GROUPS} grupos · \${INFRA_ITEMS} itens</div></div>
<div class="section">\`;
for(const[grupo,info]of Object.entries(INFRA)){
const color=info.color||'#6b7280';
h+=\`<div class="igroup">
<div class="igroup-h">
<div class="igroup-c" style="background:\${color}"></div>
<div class="igroup-t" style="color:\${color}">\${escapeHtml(grupo)}</div>
<div class="sec-b">\${info.items.length} itens</div>
</div>
<div class="igrid">\${info.items.map(item=>infraCard(item,color)).join('')}</div>
</div>\`;
}
h+='</div>';
} else {
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
if(!dom){
h+=\`<div class="overview"><div class="ov-t">AgenciaPsi — Banco de Dados</div>
<div class="ov-s">Schema público · \${Object.keys(D.tables).length} tabelas · \${Object.values(D.tables).reduce((a,t)=>a+t.fks.length,0)} FKs · \${D.views.length} views</div>
<div class="dgrid">\`;
for(const[d,ts]of Object.entries(D.domains)){
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
<div class="dc-n">\${escapeHtml(d)}</div>
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
</div>\`;
}
h+='</div></div>';
}
for(const[d,ts]of Object.entries(ds)){
h+=\`<div class="section" id="dom-\${D.slugs[d]||''}"><div class="sec-h">
<div class="sec-t" style="color:\${gc(d)}">\${escapeHtml(d)}</div>
<div class="sec-b">\${ts.length} tabelas</div>
</div><div class="tgrid">\`;
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
h+='</div></div>';
}
if(!dom){
h+=\`<div class="vsec" id="dom-views"><div class="sec-h">
<div class="sec-t" style="color:#6ee7b7">Views</div>
<div class="sec-b">\${D.views.length}</div>
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
}
}
mn.innerHTML=h;
}
function infraCard(item,color){
const status=(item.status||'ativo').toLowerCase();
return \`<div class="ic" style="--c:\${color}">
<div class="ic-h">
<div class="ic-n">\${escapeHtml(item.name)}</div>
<div class="ic-st \${status}">\${escapeHtml(item.status||'ativo')}</div>
</div>
<div class="ic-r">\${escapeHtml(item.role||'')}</div>
\${item.env?\`<div class="ic-e">\${escapeHtml(item.env)}</div>\`:''}
\${item.notes?\`<div class="ic-nt">\${escapeHtml(item.notes)}</div>\`:''}
</div>\`;
}
function card(name,t,hl){
const fkCols=new Set(t.fks.map(f=>f.from_col));
const c=gc(T2D[name]);
const cols=t.columns.map(col=>{
let n=col.name;
if(hl&&n.includes(hl))n=n.replace(new RegExp(\`(\${hl})\`,'gi'),'<mark>$1</mark>');
const b=col.pk?'pk':fkCols.has(col.name)?'fk':'x';
const l=col.pk?'PK':fkCols.has(col.name)?'FK':'';
return \`<div class="cr"><span class="bdg \${b}">\${l}</span><span class="cn">\${n}</span><span class="ct">\${col.type}</span></div>\`;
}).join('');
const fks=t.fks.length?\`<div class="fksec"><div class="fkt">Foreign Keys</div>\${
t.fks.map(f=>\`<div class="fkr"><span>\${f.from_col}</span><span class="fka">→</span><span class="fkl" onclick="jump('\${f.to_table}')">\${f.to_table}.\${f.to_col}</span></div>\`).join('')
}</div>\`:'';
return \`<div class="tc \${hl&&name.includes(hl)?'hl':''}" id="tc-\${name}">
<div class="tc-h" onclick="tog('\${name}')">
<div style="width:8px;height:8px;border-radius:50%;background:\${c};flex-shrink:0"></div>
<div class="tc-n">\${name}</div>
<span class="tc-m">\${t.columns.length} cols</span>
\${t.fks.length?\`<span class="tc-f">\${t.fks.length} FK</span>\`:''}
<span class="tc-tg" id="tg-\${name}"></span>
</div>
<div class="tc-b" id="bd-\${name}"><div class="cols">\${cols}</div>\${fks}</div>
</div>\`;
}
function tog(n){
document.getElementById('bd-'+n)?.classList.toggle('open');
document.getElementById('tg-'+n)?.classList.toggle('open');
}
function sel(d){
dom=d;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function scrollToDomain(d){
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
const needRebuild=view!=='overview'||dom!==null||q;
if(needRebuild){
dom=null;view='overview';q='';
document.getElementById('si').value='';
buildSB();buildMN();
}
setTimeout(()=>{
const el=document.getElementById('dom-'+(D.slugs[d]||''));
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
}, needRebuild?80:0);
}
function scrollToViews(){
const needRebuild=view!=='overview'||dom!==null||q;
if(needRebuild){
dom=null;view='overview';q='';
document.getElementById('si').value='';
buildSB();buildMN();
}
setTimeout(()=>{
const el=document.getElementById('dom-views');
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
}, needRebuild?80:0);
}
function selOverview(){
dom=null;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function selInfra(){
dom=null;view='infra';q='';document.getElementById('si').value='';
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
}
function jump(name){
dom=T2D[name]||null;view='overview';q='';document.getElementById('si').value='';
buildSB();buildMN();
setTimeout(()=>{
const el=document.getElementById('tc-'+name);
if(!el)return;
el.scrollIntoView({behavior:'smooth',block:'center'});
const bd=document.getElementById('bd-'+name);
const tg=document.getElementById('tg-'+name);
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
el.style.borderColor='#6366f1';
setTimeout(()=>el.style.borderColor='',2000);
},80);
}
let st;
function search(v){
clearTimeout(st);q=v.trim();
st=setTimeout(()=>{dom=null;view='overview';buildSB();buildMN();},200);
}
buildSB();buildMN();
</script>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// 5. Execução
// ---------------------------------------------------------------------------
console.log('\n═══ AgenciaPsi — Dashboard Generator ═══\n');
const { schemaPath, date, available } = resolveSchema();
console.log(` → Schema: ${schemaPath}`);
if (available.length > 1) console.log(` → Outros backups: ${available.slice(1).join(', ')}`);
const content = fs.readFileSync(schemaPath, 'utf8');
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
const { tables, views } = parseSchema(content);
const domains = buildDomains(tables);
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
console.log(`${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
// Avisa sobre tabelas novas não mapeadas
if (domains['Outros']) {
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
domains['Outros'].forEach((t) => console.log(` - ${t}`));
console.log(` → Edite "domains" em db.config.json para mapeá-las.\n`);
}
// Infra stats
const infraGroups = Object.keys(INFRASTRUCTURE).length;
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
console.log(` → Infraestrutura: ${infraGroups} grupos, ${infraItems} itens`);
const html = generateHTML(tables, views, domains, date, available);
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
console.log(` Abra no browser: file://${OUTPUT_FILE.replace(/\\/g, '/')}\n`);
@@ -0,0 +1,132 @@
-- =============================================================================
-- AgenciaPsi — Migration 001: Twilio WhatsApp Subaccounts
-- =============================================================================
-- Adiciona suporte a subcontas Twilio com número WhatsApp dedicado por tenant.
-- Cada clínica/terapeuta recebe sua própria subconta Twilio com número exclusivo.
-- =============================================================================
-- ── 1. Campos de subconta Twilio em notification_channels ──────────────────
ALTER TABLE public.notification_channels
ADD COLUMN IF NOT EXISTS twilio_subaccount_sid text,
ADD COLUMN IF NOT EXISTS twilio_phone_number text,
ADD COLUMN IF NOT EXISTS twilio_phone_sid text,
ADD COLUMN IF NOT EXISTS webhook_url text,
ADD COLUMN IF NOT EXISTS cost_per_message_usd numeric(8,6) DEFAULT 0,
ADD COLUMN IF NOT EXISTS price_per_message_brl numeric(8,4) DEFAULT 0,
ADD COLUMN IF NOT EXISTS provisioned_at timestamp with time zone;
COMMENT ON COLUMN public.notification_channels.twilio_subaccount_sid IS 'SID da subconta Twilio criada para este tenant';
COMMENT ON COLUMN public.notification_channels.twilio_phone_number IS 'Número WhatsApp provisionado (E.164, ex: +5511999990000)';
COMMENT ON COLUMN public.notification_channels.twilio_phone_sid IS 'SID do número de telefone na subconta Twilio';
COMMENT ON COLUMN public.notification_channels.webhook_url IS 'URL do webhook configurada na Twilio para receber callbacks de status';
COMMENT ON COLUMN public.notification_channels.cost_per_message_usd IS 'Custo real Twilio por mensagem WhatsApp (USD)';
COMMENT ON COLUMN public.notification_channels.price_per_message_brl IS 'Valor cobrado do tenant por mensagem (BRL, inclui margem SaaS)';
COMMENT ON COLUMN public.notification_channels.provisioned_at IS 'Timestamp do provisionamento da subconta';
-- Índice para busca rápida por subconta
CREATE INDEX IF NOT EXISTS idx_notification_channels_twilio_subaccount_sid
ON public.notification_channels (twilio_subaccount_sid)
WHERE twilio_subaccount_sid IS NOT NULL;
-- ── 2. Tabela de consumo por subconta ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.twilio_subaccount_usage (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
channel_id uuid NOT NULL,
twilio_subaccount_sid text NOT NULL,
period_start date NOT NULL,
period_end date NOT NULL,
messages_sent integer DEFAULT 0 NOT NULL,
messages_delivered integer DEFAULT 0 NOT NULL,
messages_failed integer DEFAULT 0 NOT NULL,
cost_usd numeric(12,6) DEFAULT 0 NOT NULL,
cost_brl numeric(12,4) DEFAULT 0 NOT NULL,
revenue_brl numeric(12,4) DEFAULT 0 NOT NULL,
margin_brl numeric(12,4) GENERATED ALWAYS AS (revenue_brl - cost_brl) STORED,
usd_brl_rate numeric(8,4) DEFAULT 0,
synced_at timestamp with time zone DEFAULT now(),
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT twilio_subaccount_usage_pkey PRIMARY KEY (id),
CONSTRAINT twilio_subaccount_usage_channel_fk
FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE,
CONSTRAINT twilio_subaccount_usage_period_check
CHECK (period_end >= period_start)
);
COMMENT ON TABLE public.twilio_subaccount_usage IS
'Consumo mensal de mensagens WhatsApp por subconta Twilio. Sincronizado via Edge Function.';
CREATE INDEX IF NOT EXISTS idx_twilio_usage_tenant_period
ON public.twilio_subaccount_usage (tenant_id, period_start DESC);
CREATE INDEX IF NOT EXISTS idx_twilio_usage_channel
ON public.twilio_subaccount_usage (channel_id, period_start DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_twilio_usage_unique_period
ON public.twilio_subaccount_usage (channel_id, period_start, period_end);
ALTER TABLE public.twilio_subaccount_usage OWNER TO supabase_admin;
-- ── 3. RLS: twilio_subaccount_usage ───────────────────────────────────────
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
-- Tenant vê apenas seu próprio consumo
CREATE POLICY "tenant_select_own_usage"
ON public.twilio_subaccount_usage
FOR SELECT
USING (
tenant_id IN (
SELECT tenant_id FROM public.tenant_members
WHERE user_id = auth.uid()
)
);
-- Apenas service_role pode inserir/atualizar (via Edge Function)
CREATE POLICY "service_role_manage_usage"
ON public.twilio_subaccount_usage
FOR ALL
USING (auth.role() = 'service_role');
-- ── 4. RLS: notification_channels — acesso ao twilio_subaccount_sid ───────
-- As políticas existentes já cobrem SELECT/UPDATE. Nenhuma alteração necessária.
-- ── 5. View: resumo de subcontas para o painel SaaS admin ─────────────────
CREATE OR REPLACE VIEW public.v_twilio_whatsapp_overview AS
SELECT
nc.id AS channel_id,
nc.tenant_id,
nc.owner_id,
nc.is_active,
nc.connection_status,
nc.display_name,
nc.twilio_subaccount_sid,
nc.twilio_phone_number,
nc.twilio_phone_sid,
nc.cost_per_message_usd,
nc.price_per_message_brl,
nc.provisioned_at,
nc.created_at,
nc.updated_at,
-- Uso do mês atual
COALESCE(u.messages_sent, 0) AS current_month_sent,
COALESCE(u.messages_delivered, 0) AS current_month_delivered,
COALESCE(u.messages_failed, 0) AS current_month_failed,
COALESCE(u.cost_usd, 0) AS current_month_cost_usd,
COALESCE(u.cost_brl, 0) AS current_month_cost_brl,
COALESCE(u.revenue_brl, 0) AS current_month_revenue_brl,
COALESCE(u.margin_brl, 0) AS current_month_margin_brl
FROM public.notification_channels nc
LEFT JOIN public.twilio_subaccount_usage u
ON u.channel_id = nc.id
AND u.period_start = date_trunc('month', CURRENT_DATE)::date
WHERE nc.channel = 'whatsapp'
AND nc.provider = 'twilio'
AND nc.deleted_at IS NULL;
COMMENT ON VIEW public.v_twilio_whatsapp_overview IS
'Visão consolidada de subcontas Twilio WhatsApp com uso do mês corrente.';
@@ -0,0 +1,57 @@
-- ============================================================
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
-- ============================================================
-- Tabela: tenants (Step 2 — Negócio)
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
-- ============================================================
-- ----------------------------------------------------------
-- tenants: dados do negócio
-- ----------------------------------------------------------
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS business_type text,
ADD COLUMN IF NOT EXISTS logo_url text,
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS phone text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS site_url text,
ADD COLUMN IF NOT EXISTS social_instagram text;
-- Valores aceitos: consultorio | clinica | instituto | grupo
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_business_type_check
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
'consultorio'::text,
'clinica'::text,
'instituto'::text,
'grupo'::text
]));
-- ----------------------------------------------------------
-- agenda_configuracoes: modo de atendimento
-- ----------------------------------------------------------
ALTER TABLE public.agenda_configuracoes
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
ALTER TABLE public.agenda_configuracoes
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
'particular'::text,
'convenio'::text,
'ambos'::text
]));
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';
@@ -0,0 +1,33 @@
-- ============================================================
-- Migration 003 — Tenants: campos de endereço detalhado
-- ============================================================
-- Substitui o campo address (texto livre) por campos estruturados
-- preenchidos via consulta de CEP (ViaCEP)
-- ============================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cep text,
ADD COLUMN IF NOT EXISTS logradouro text,
ADD COLUMN IF NOT EXISTS numero text,
ADD COLUMN IF NOT EXISTS complemento text,
ADD COLUMN IF NOT EXISTS bairro text,
ADD COLUMN IF NOT EXISTS cidade text,
ADD COLUMN IF NOT EXISTS estado text;
-- Migra dados existentes do campo address para logradouro
UPDATE public.tenants
SET logradouro = address
WHERE address IS NOT NULL
AND logradouro IS NULL;
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';
@@ -0,0 +1,147 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `medicos`
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026 · São Carlos/SP — Brasil
--
-- Propósito:
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
-- suporte clínico do terapeuta.
--
-- Usado em:
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
--
-- Relacionamentos:
-- medicos.owner_id → auth.users(id)
-- medicos.tenant_id → tenants(id)
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
--
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.medicos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Identidade profissional
nome text NOT NULL,
crm text, -- Ex: "123456/SP"
especialidade text, -- Ex: "Psiquiatria"
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
telefone_profissional text, -- Consultório / clínica
telefone_pessoal text, -- WhatsApp / pessoal
email text,
-- Local de atuação
clinica text, -- Nome da clínica/hospital
cidade text,
estado text DEFAULT 'SP',
-- Notas internas do terapeuta
observacoes text,
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT medicos_pkey PRIMARY KEY (id),
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
);
-- --------------------------------------------------------------------------
-- 2. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS medicos_owner_idx
ON public.medicos USING btree (owner_id);
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
ON public.medicos USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS medicos_nome_idx
ON public.medicos USING btree (nome);
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
ON public.medicos USING btree (especialidade);
-- Busca textual por nome e especialidade
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
ON public.medicos USING gin (nome gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger de updated_at
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_medicos_updated_at
BEFORE UPDATE ON public.medicos
FOR EACH ROW
EXECUTE FUNCTION public.set_medicos_updated_at();
-- --------------------------------------------------------------------------
-- 4. Row Level Security
-- --------------------------------------------------------------------------
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
-- Owner tem acesso total aos seus próprios médicos
CREATE POLICY "medicos: owner full access"
ON public.medicos
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 5. Comentários de documentação
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
-- --------------------------------------------------------------------------
-- 6. Coluna FK opcional em patients
-- (Conecta "Encaminhado por" ao cadastro de médico)
-- Execute apenas se quiser a FK estruturada; caso contrário,
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
-- --------------------------------------------------------------------------
-- ALTER TABLE public.patients
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
-- ON public.patients USING btree (medico_encaminhador_id);
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,119 @@
-- ==========================================================================
-- Agência PSI — Migração: novos campos em `patients`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
--
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
-- aceitar os valores usados no novo formulário de cadastro.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Colunas novas
-- --------------------------------------------------------------------------
-- Identidade
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS pronomes text,
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS etnia text;
-- Contato
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS canal_preferido text,
ADD COLUMN IF NOT EXISTS horario_contato text;
-- Clínico / convênio
-- convenio: nome de exibição (badge azul no header)
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS convenio text,
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
-- Origem
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
ADD COLUMN IF NOT EXISTS motivo_saida text;
-- --------------------------------------------------------------------------
-- 2. Ajuste do CHECK constraint de `status`
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
-- Valores novos: + Em espera
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- --------------------------------------------------------------------------
-- 3. Ajuste do CHECK constraint de `patient_scope`
-- Valores originais: clinic | therapist (valores técnicos internos)
-- Valores novos: + Clínica | Particular | Online | Híbrido
-- Estratégia: remover o constraint restritivo e deixar livre (text),
-- pois o controle já é feito no frontend via Select com opções fixas.
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
-- Também remove a constraint de consistência que dependia do scope antigo
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- --------------------------------------------------------------------------
-- 4. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
ON public.patients USING btree (convenio_id);
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
ON public.patients USING btree (pronomes);
CREATE INDEX IF NOT EXISTS patients_etnia_idx
ON public.patients USING btree (etnia);
-- --------------------------------------------------------------------------
-- 5. Comentários
-- --------------------------------------------------------------------------
COMMENT ON COLUMN public.patients.pronomes
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
COMMENT ON COLUMN public.patients.nome_social
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
COMMENT ON COLUMN public.patients.etnia
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
COMMENT ON COLUMN public.patients.canal_preferido
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
COMMENT ON COLUMN public.patients.horario_contato
IS 'Horário preferido para contato. Ex: 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
-- ==========================================================================
@@ -0,0 +1,70 @@
-- ==========================================================================
-- Agência PSI — Migração: remove check constraints dos novos campos
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
-- pela migration anterior (ou que já existiam no schema ao vivo).
-- O frontend já controla os valores via Select com opções fixas,
-- então os constraints são desnecessários e serão removidos.
-- ==========================================================================
-- canal_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
-- horario_contato
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
-- pronomes
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
-- nome_social
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
-- etnia
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_etnia_check;
-- convenio
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_convenio_check;
-- metodo_pagamento_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
-- motivo_saida
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
-- status (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- patient_scope (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,56 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `patient_support_contacts`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Contatos da rede de suporte do paciente.
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
-- is_primario = true → badge vermelho "emergência" no perfil.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
nome text,
relacao text, -- Ex: mãe, psiquiatra, cônjuge
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
telefone text,
email text,
is_primario boolean DEFAULT false NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
);
-- Índices
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
-- Trigger updated_at
CREATE TRIGGER trg_psc_updated_at
BEFORE UPDATE ON public.patient_support_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "psc: owner full access"
ON public.patient_support_contacts
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- Comentários
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================
@@ -0,0 +1,454 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Modulo completo de documentos do paciente.
-- Tabelas: documents, document_access_logs, document_signatures,
-- document_share_links.
--
-- Relacionamentos:
-- documents.patient_id → patients(id)
-- documents.owner_id → auth.users(id)
-- documents.tenant_id → tenants(id)
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
-- document_access_logs.documento_id → documents(id)
-- document_signatures.documento_id → documents(id)
-- document_share_links.documento_id → documents(id)
--
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal: documents
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.documents (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Vinculo com paciente
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
-- Arquivo no Storage
bucket_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'documents',
nome_original text NOT NULL,
mime_type text,
tamanho_bytes bigint,
-- Classificacao
tipo_documento text NOT NULL DEFAULT 'outro',
-- laudo | receita | exame | termo_assinado | relatorio_externo
-- identidade | convenio | declaracao | atestado | recibo | outro
categoria text,
descricao text,
tags text[] DEFAULT '{}',
-- Vinculo opcional com sessao/nota
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
session_note_id uuid,
-- Visibilidade & controle de acesso
visibilidade text NOT NULL DEFAULT 'privado',
-- privado | compartilhado_supervisor | compartilhado_portal
compartilhado_portal boolean DEFAULT false NOT NULL,
compartilhado_supervisor boolean DEFAULT false NOT NULL,
compartilhado_em timestamptz,
expira_compartilhamento timestamptz,
-- Upload pelo paciente (portal)
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
status_revisao text DEFAULT 'aprovado',
-- pendente | aprovado | rejeitado
revisado_por uuid,
revisado_em timestamptz,
-- Quem fez upload
uploaded_by uuid NOT NULL,
uploaded_at timestamptz DEFAULT now() NOT NULL,
-- Soft delete com retencao (LGPD / CFP)
deleted_at timestamptz,
deleted_by uuid,
retencao_ate timestamptz,
-- Controle
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT documents_pkey PRIMARY KEY (id),
-- Validacoes
CONSTRAINT documents_tipo_check CHECK (
tipo_documento = ANY (ARRAY[
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
])
),
CONSTRAINT documents_visibilidade_check CHECK (
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
),
CONSTRAINT documents_status_revisao_check CHECK (
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — documents
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS docs_patient_idx
ON public.documents USING btree (patient_id);
CREATE INDEX IF NOT EXISTS docs_owner_idx
ON public.documents USING btree (owner_id);
CREATE INDEX IF NOT EXISTS docs_tenant_idx
ON public.documents USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS docs_tipo_idx
ON public.documents USING btree (patient_id, tipo_documento);
CREATE INDEX IF NOT EXISTS docs_tags_idx
ON public.documents USING gin (tags);
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
ON public.documents USING btree (patient_id, uploaded_at DESC);
-- Excluir soft-deleted da listagem padrao
CREATE INDEX IF NOT EXISTS docs_active_idx
ON public.documents USING btree (patient_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
-- Busca textual no nome do arquivo
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
ON public.documents USING gin (nome_original gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at — documents
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_documents_updated_at
BEFORE UPDATE ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
NEW.patient_id,
NEW.tenant_id,
'documento_adicionado',
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
'blue',
'documento',
NEW.id,
NEW.uploaded_by,
NEW.uploaded_at
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_documents_timeline_insert
AFTER INSERT ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.fn_documents_timeline_insert();
-- --------------------------------------------------------------------------
-- 5. RLS — documents
-- --------------------------------------------------------------------------
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "documents: owner full access"
ON public.documents
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 6. Comentarios — documents
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
-- ==========================================================================
-- 7. Tabela: document_access_logs (imutavel — auditoria)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_access_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Acao realizada
acao text NOT NULL,
-- visualizou | baixou | imprimiu | compartilhou | assinou
user_id uuid,
ip inet,
user_agent text,
acessado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
CONSTRAINT dal_acao_check CHECK (
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS dal_documento_idx
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_tenant_idx
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_user_idx
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
-- RLS — somente INSERT (imutavel) + SELECT
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dal: tenant members can insert"
ON public.document_access_logs
FOR INSERT
WITH CHECK (true);
CREATE POLICY "dal: tenant members can select"
ON public.document_access_logs
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
-- ==========================================================================
-- 8. Tabela: document_signatures (assinatura eletronica)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_signatures (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Signatario
signatario_tipo text NOT NULL,
-- paciente | responsavel_legal | terapeuta
signatario_id uuid,
signatario_nome text,
signatario_email text,
-- Ordem e status
ordem smallint DEFAULT 1 NOT NULL,
status text NOT NULL DEFAULT 'pendente',
-- pendente | enviado | assinado | recusado | expirado
-- Dados da assinatura (preenchidos ao assinar)
ip inet,
user_agent text,
assinado_em timestamptz,
hash_documento text,
-- Controle
criado_em timestamptz DEFAULT now(),
atualizado_em timestamptz DEFAULT now(),
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
CONSTRAINT ds_signatario_tipo_check CHECK (
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
),
CONSTRAINT ds_status_check CHECK (
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS ds_documento_idx
ON public.document_signatures USING btree (documento_id, ordem);
CREATE INDEX IF NOT EXISTS ds_tenant_idx
ON public.document_signatures USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS ds_status_idx
ON public.document_signatures USING btree (documento_id, status);
-- Trigger updated_at
CREATE TRIGGER trg_ds_updated_at
BEFORE UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- Trigger: ao assinar, registrar na patient_timeline
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_patient_id uuid;
v_tenant_id uuid;
v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
SELECT d.patient_id, d.tenant_id, d.nome_original
INTO v_patient_id, v_tenant_id, v_doc_nome
FROM public.documents d
WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
v_patient_id,
v_tenant_id,
'documento_assinado',
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
'green',
'documento',
NEW.documento_id,
NEW.signatario_id,
NEW.assinado_em
);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_ds_timeline
AFTER UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.fn_document_signature_timeline();
-- RLS
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ds: tenant members access"
ON public.document_signatures
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
))
WITH CHECK (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
-- ==========================================================================
-- 9. Tabela: document_share_links (links temporarios)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_share_links (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Token unico para o link
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
-- Limites
expira_em timestamptz NOT NULL,
usos_max smallint DEFAULT 5 NOT NULL,
usos smallint DEFAULT 0 NOT NULL,
-- Quem criou
criado_por uuid NOT NULL,
criado_em timestamptz DEFAULT now(),
-- Controle
ativo boolean DEFAULT true NOT NULL,
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
CONSTRAINT dsl_token_unique UNIQUE (token)
);
-- Indices
CREATE INDEX IF NOT EXISTS dsl_documento_idx
ON public.document_share_links USING btree (documento_id);
CREATE INDEX IF NOT EXISTS dsl_token_idx
ON public.document_share_links USING btree (token)
WHERE ativo = true;
CREATE INDEX IF NOT EXISTS dsl_expira_idx
ON public.document_share_links USING btree (expira_em)
WHERE ativo = true;
-- RLS
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dsl: creator full access"
ON public.document_share_links
USING (criado_por = auth.uid())
WITH CHECK (criado_por = auth.uid());
-- Politica publica de leitura por token (para acesso externo sem login)
CREATE POLICY "dsl: public read by token"
ON public.document_share_links
FOR SELECT
USING (ativo = true AND expira_em > now() AND usos < usos_max);
-- Comentarios
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
-- ==========================================================================
-- FIM DA MIGRACAO 005
-- ==========================================================================
@@ -0,0 +1,260 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
-- e registro de cada documento gerado (instancia PDF).
--
-- Tabelas: document_templates, document_generated.
--
-- Relacionamentos:
-- document_templates.tenant_id → tenants(id)
-- document_templates.owner_id → auth.users(id)
-- document_generated.template_id → document_templates(id)
-- document_generated.patient_id → patients(id)
-- document_generated.tenant_id → tenants(id)
--
-- Templates globais: is_global = true, tenant_id = NULL.
-- Templates do tenant: is_global = false, tenant_id preenchido.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela: document_templates
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.document_templates (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto
tenant_id uuid,
owner_id uuid,
-- Identificacao
nome_template text NOT NULL,
tipo text NOT NULL DEFAULT 'outro',
-- declaracao_comparecimento | atestado_psicologico
-- relatorio_acompanhamento | recibo_pagamento
-- termo_consentimento | encaminhamento | outro
descricao text,
-- Corpo do template
corpo_html text NOT NULL DEFAULT '',
cabecalho_html text,
rodape_html text,
-- Variaveis que o template utiliza
variaveis text[] DEFAULT '{}',
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
-- Personalizacao visual
logo_url text,
-- Escopo
is_global boolean DEFAULT false NOT NULL,
-- true = template padrao do sistema (visivel para todos)
-- false = template criado pelo tenant/terapeuta
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
CONSTRAINT dt_tipo_check CHECK (
tipo = ANY (ARRAY[
'declaracao_comparecimento', 'atestado_psicologico',
'relatorio_acompanhamento', 'recibo_pagamento',
'termo_consentimento', 'encaminhamento',
'contrato_servicos', 'tcle', 'autorizacao_menor',
'laudo_psicologico', 'parecer_psicologico',
'termo_sigilo', 'declaracao_inicio_tratamento',
'termo_alta', 'tcle_online', 'outro'
])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — document_templates
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dt_tenant_idx
ON public.document_templates USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS dt_owner_idx
ON public.document_templates USING btree (owner_id);
CREATE INDEX IF NOT EXISTS dt_global_idx
ON public.document_templates USING btree (is_global)
WHERE is_global = true;
CREATE INDEX IF NOT EXISTS dt_tipo_idx
ON public.document_templates USING btree (tipo);
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
ON public.document_templates USING gin (nome_template gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_dt_updated_at
BEFORE UPDATE ON public.document_templates
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. RLS — document_templates
-- --------------------------------------------------------------------------
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
-- Templates globais: todos podem ler
CREATE POLICY "dt: global templates readable by all"
ON public.document_templates
FOR SELECT
USING (is_global = true);
-- Templates do tenant: membros do tenant podem ler
CREATE POLICY "dt: tenant members can select"
ON public.document_templates
FOR SELECT
USING (
is_global = false
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Owner pode inserir/atualizar/deletar seus templates
CREATE POLICY "dt: owner can insert"
ON public.document_templates
FOR INSERT
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can update"
ON public.document_templates
FOR UPDATE
USING (owner_id = auth.uid() AND is_global = false)
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can delete"
ON public.document_templates
FOR DELETE
USING (owner_id = auth.uid() AND is_global = false);
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
CREATE POLICY "dt: saas admin can insert global"
ON public.document_templates
FOR INSERT
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can update global"
ON public.document_templates
FOR UPDATE
USING (is_global = true AND public.is_saas_admin())
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can delete global"
ON public.document_templates
FOR DELETE
USING (is_global = true AND public.is_saas_admin());
-- --------------------------------------------------------------------------
-- 5. Comentarios — document_templates
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
-- ==========================================================================
-- 6. Tabela: document_generated (cada PDF gerado)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_generated (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Origem
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
-- PDF gerado
pdf_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'generated-docs',
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
-- Quem gerou
gerado_por uuid NOT NULL,
gerado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
);
-- --------------------------------------------------------------------------
-- 7. Indices — document_generated
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dg_template_idx
ON public.document_generated USING btree (template_id);
CREATE INDEX IF NOT EXISTS dg_patient_idx
ON public.document_generated USING btree (patient_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_tenant_idx
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
-- --------------------------------------------------------------------------
-- 8. RLS — document_generated
-- --------------------------------------------------------------------------
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dg: generator full access"
ON public.document_generated
USING (gerado_por = auth.uid())
WITH CHECK (gerado_por = auth.uid());
-- Membros do tenant podem visualizar
CREATE POLICY "dg: tenant members can select"
ON public.document_generated
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- --------------------------------------------------------------------------
-- 9. Comentarios — document_generated
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
-- ==========================================================================
-- FIM DA MIGRACAO 006
-- ==========================================================================
@@ -0,0 +1,93 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Buckets para Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Cria os buckets no Supabase Storage para documentos de pacientes
-- e PDFs gerados pelo sistema.
-- ==========================================================================
-- Bucket: documents (uploads de terapeuta/paciente)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'documents',
'documents',
false,
52428800, -- 50 MB
ARRAY[
'application/pdf',
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
)
ON CONFLICT (id) DO NOTHING;
-- Bucket: generated-docs (PDFs gerados pelo sistema)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'generated-docs',
'generated-docs',
false,
20971520, -- 20 MB
ARRAY['application/pdf']
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: documents
-- --------------------------------------------------------------------------
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
CREATE POLICY "documents: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'documents');
-- Download: usuario autenticado pode ler arquivos do seu tenant
CREATE POLICY "documents: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'documents');
-- Delete: usuario autenticado pode deletar seus arquivos
CREATE POLICY "documents: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'documents');
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: generated-docs
-- --------------------------------------------------------------------------
CREATE POLICY "generated-docs: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'generated-docs');
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260417000001_dev_tables
-- Área de Desenvolvimento (dev_*) — roadmap, auditoria, concorrentes, logs
-- -----------------------------------------------------------------------------
-- Tabelas usadas pela página /saas/desenvolvimento. Todas restritas a
-- saas_admins via RLS (helper public.is_saas_admin()).
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Helper trigger: updated_at
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.dev_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
-- =============================================================================
-- 1. dev_roadmap_phases — Fases (1, 2, 3...)
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_roadmap_phases (
id BIGSERIAL PRIMARY KEY,
numero INTEGER NOT NULL UNIQUE,
nome VARCHAR(160) NOT NULL,
objetivo TEXT,
timeline_sugerida VARCHAR(160),
criterio_saida TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'planejada'
CHECK (status IN ('planejada','em_andamento','concluida','arquivada')),
data_inicio DATE,
data_fim DATE,
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_status ON public.dev_roadmap_phases(status);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases(ordem);
DROP TRIGGER IF EXISTS trg_dev_roadmap_phases_updated_at ON public.dev_roadmap_phases;
CREATE TRIGGER trg_dev_roadmap_phases_updated_at
BEFORE UPDATE ON public.dev_roadmap_phases
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 2. dev_roadmap_items — Itens das fases
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_roadmap_items (
id BIGSERIAL PRIMARY KEY,
phase_id BIGINT NOT NULL REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE,
numero INTEGER,
bloco VARCHAR(160),
feature TEXT NOT NULL,
descricao TEXT,
esforco VARCHAR(4)
CHECK (esforco IS NULL OR esforco IN ('S','M','L','XL')),
prioridade VARCHAR(20)
CHECK (prioridade IS NULL OR prioridade IN ('bloqueador','alta','media','diferencial')),
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
CHECK (status IN ('pendente','em_andamento','concluido','cancelado','bloqueado')),
notas TEXT,
assignee VARCHAR(120),
data_inicio DATE,
data_conclusao DATE,
ordem INTEGER NOT NULL DEFAULT 0,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_phase ON public.dev_roadmap_items(phase_id);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_status ON public.dev_roadmap_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_prior ON public.dev_roadmap_items(prioridade);
CREATE INDEX IF NOT EXISTS idx_dev_roadmap_items_ordem ON public.dev_roadmap_items(phase_id, ordem);
DROP TRIGGER IF EXISTS trg_dev_roadmap_items_updated_at ON public.dev_roadmap_items;
CREATE TRIGGER trg_dev_roadmap_items_updated_at
BEFORE UPDATE ON public.dev_roadmap_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 3. dev_auditoria_items — Bugs / débitos técnicos / decisões
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_auditoria_items (
id BIGSERIAL PRIMARY KEY,
categoria VARCHAR(120),
titulo TEXT NOT NULL,
descricao_problema TEXT,
solucao TEXT,
severidade VARCHAR(20)
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
status VARCHAR(20) NOT NULL DEFAULT 'aberto'
CHECK (status IN ('aberto','em_analise','resolvido','wontfix','duplicado')),
resolvido_em DATE,
sessao_resolucao VARCHAR(160),
arquivo_afetado TEXT,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_status ON public.dev_auditoria_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_severidade ON public.dev_auditoria_items(severidade);
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_categoria ON public.dev_auditoria_items(categoria);
DROP TRIGGER IF EXISTS trg_dev_auditoria_items_updated_at ON public.dev_auditoria_items;
CREATE TRIGGER trg_dev_auditoria_items_updated_at
BEFORE UPDATE ON public.dev_auditoria_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 4. dev_competitors — Concorrentes
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_competitors (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(80) NOT NULL UNIQUE,
nome VARCHAR(160) NOT NULL,
pais VARCHAR(40),
foco VARCHAR(160),
pricing TEXT,
posicionamento TEXT,
url TEXT,
ultima_pesquisa DATE,
notas TEXT,
ativo BOOLEAN NOT NULL DEFAULT true,
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_competitors_ativo ON public.dev_competitors(ativo);
CREATE INDEX IF NOT EXISTS idx_dev_competitors_pais ON public.dev_competitors(pais);
DROP TRIGGER IF EXISTS trg_dev_competitors_updated_at ON public.dev_competitors;
CREATE TRIGGER trg_dev_competitors_updated_at
BEFORE UPDATE ON public.dev_competitors
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 5. dev_competitor_features — features de cada concorrente
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_competitor_features (
id BIGSERIAL PRIMARY KEY,
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
categoria VARCHAR(120),
nome TEXT NOT NULL,
descricao TEXT,
fonte VARCHAR(20) NOT NULL DEFAULT 'publico'
CHECK (fonte IN ('fetched','observacao','publico','hipotese')),
fonte_url TEXT,
data_fonte DATE,
destaque BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_comp ON public.dev_competitor_features(competitor_id);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_cat ON public.dev_competitor_features(categoria);
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_destaque ON public.dev_competitor_features(destaque);
DROP TRIGGER IF EXISTS trg_dev_competitor_features_updated_at ON public.dev_competitor_features;
CREATE TRIGGER trg_dev_competitor_features_updated_at
BEFORE UPDATE ON public.dev_competitor_features
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 6. dev_comparison_matrix — AgenciaPsi × features-de-concorrente
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_comparison_matrix (
id BIGSERIAL PRIMARY KEY,
dominio VARCHAR(120),
feature TEXT NOT NULL,
nosso_status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
CHECK (nosso_status IN ('tem','parcial','gap','na','a_definir')),
nossa_nota TEXT,
importancia VARCHAR(20)
CHECK (importancia IS NULL OR importancia IN ('alta','media','baixa')),
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix(dominio);
CREATE INDEX IF NOT EXISTS idx_dev_comparison_matrix_status ON public.dev_comparison_matrix(nosso_status);
DROP TRIGGER IF EXISTS trg_dev_comparison_matrix_updated_at ON public.dev_comparison_matrix;
CREATE TRIGGER trg_dev_comparison_matrix_updated_at
BEFORE UPDATE ON public.dev_comparison_matrix
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- dev_comparison_competitor_status — opcional: status por concorrente por feature
-- (se quisermos marcar que competitor X tem feature Y). Tabela ponte N-N.
CREATE TABLE IF NOT EXISTS public.dev_comparison_competitor_status (
id BIGSERIAL PRIMARY KEY,
comparison_id BIGINT NOT NULL REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE,
competitor_id BIGINT NOT NULL REFERENCES public.dev_competitors(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'a_definir'
CHECK (status IN ('tem','parcial','gap','na','a_definir')),
nota TEXT,
fonte VARCHAR(20)
CHECK (fonte IS NULL OR fonte IN ('fetched','observacao','publico','hipotese')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (comparison_id, competitor_id)
);
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comp ON public.dev_comparison_competitor_status(competitor_id);
CREATE INDEX IF NOT EXISTS idx_dev_ccs_comparison ON public.dev_comparison_competitor_status(comparison_id);
DROP TRIGGER IF EXISTS trg_dev_ccs_updated_at ON public.dev_comparison_competitor_status;
CREATE TRIGGER trg_dev_ccs_updated_at
BEFORE UPDATE ON public.dev_comparison_competitor_status
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
-- =============================================================================
-- 7. dev_generation_log — histórico de execuções (backup, dashboard, export...)
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_generation_log (
id BIGSERIAL PRIMARY KEY,
tipo VARCHAR(40) NOT NULL,
comando TEXT,
sucesso BOOLEAN NOT NULL DEFAULT false,
stdout TEXT,
stderr TEXT,
duration_ms INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
trigger_user_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_tipo ON public.dev_generation_log(tipo);
CREATE INDEX IF NOT EXISTS idx_dev_generation_log_created ON public.dev_generation_log(created_at DESC);
-- =============================================================================
-- RLS — tudo restrito a saas_admins (helper existente: public.is_saas_admin())
-- =============================================================================
DO $$
DECLARE
t TEXT;
dev_tables TEXT[] := ARRAY[
'dev_roadmap_phases',
'dev_roadmap_items',
'dev_auditoria_items',
'dev_competitors',
'dev_competitor_features',
'dev_comparison_matrix',
'dev_comparison_competitor_status',
'dev_generation_log'
];
BEGIN
FOREACH t IN ARRAY dev_tables
LOOP
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', t);
-- Drop policy se existir (idempotente)
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I;', t || '_saas_admin_all', t);
-- Cria policy que permite tudo pra saas_admin
EXECUTE format(
'CREATE POLICY %I ON public.%I FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());',
t || '_saas_admin_all',
t
);
END LOOP;
END $$;
-- =============================================================================
-- Comentários
-- =============================================================================
COMMENT ON TABLE public.dev_roadmap_phases IS 'Fases do roadmap (MVP, Paridade, Diferenciação). Visível só pra saas_admins.';
COMMENT ON TABLE public.dev_roadmap_items IS 'Itens de cada fase do roadmap.';
COMMENT ON TABLE public.dev_auditoria_items IS 'Bugs, dívidas técnicas e decisões arquiteturais.';
COMMENT ON TABLE public.dev_competitors IS 'Concorrentes analisados no benchmark.';
COMMENT ON TABLE public.dev_competitor_features IS 'Features catalogadas de cada concorrente.';
COMMENT ON TABLE public.dev_comparison_matrix IS 'Matriz de comparação AgenciaPsi × features esperadas do mercado.';
COMMENT ON TABLE public.dev_comparison_competitor_status IS 'Qual concorrente tem qual feature (ponte N-N com matrix).';
COMMENT ON TABLE public.dev_generation_log IS 'Histórico de execuções (backup, dashboard, export, seed, etc).';
@@ -0,0 +1,48 @@
-- =============================================================================
-- Migration: 20260417000002_dev_tables_ordem
-- Adiciona coluna `ordem` em dev_auditoria_items e dev_competitor_features
-- (pra suportar reordenação por drag-and-drop na UI).
-- =============================================================================
-- dev_auditoria_items
ALTER TABLE public.dev_auditoria_items
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dev_auditoria_items_ordem ON public.dev_auditoria_items(ordem);
-- Popular ordem existente (status + id pra evitar colisão)
UPDATE public.dev_auditoria_items SET ordem = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (
ORDER BY
CASE status
WHEN 'aberto' THEN 1
WHEN 'em_analise' THEN 2
WHEN 'resolvido' THEN 3
WHEN 'wontfix' THEN 4
WHEN 'duplicado' THEN 5
ELSE 6
END,
id
) AS rn
FROM public.dev_auditoria_items
) sub
WHERE public.dev_auditoria_items.id = sub.id;
-- dev_competitor_features
ALTER TABLE public.dev_competitor_features
ADD COLUMN IF NOT EXISTS ordem INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dev_competitor_features_ordem
ON public.dev_competitor_features(competitor_id, ordem);
-- Popular ordem existente (por competitor + categoria + id)
UPDATE public.dev_competitor_features SET ordem = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY competitor_id
ORDER BY COALESCE(categoria, 'zzz'), id
) AS rn
FROM public.dev_competitor_features
) sub
WHERE public.dev_competitor_features.id = sub.id;
@@ -0,0 +1,51 @@
-- =============================================================================
-- Migration: 20260418000001_dev_verificacoes
-- Nova aba "Verificações" em /saas/desenvolvimento
-- -----------------------------------------------------------------------------
-- Diferente de dev_auditoria_items (bugs conhecidos), esta tabela registra o
-- PROCESSO de revisão sênior sessão-a-sessão: o que já foi olhado, o que falta
-- olhar, o que foi encontrado em cada área do sistema.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_verificacoes_items (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(80) NOT NULL,
categoria VARCHAR(120),
titulo TEXT NOT NULL,
descricao TEXT,
resultado TEXT,
acao_sugerida TEXT,
severidade VARCHAR(20)
CHECK (severidade IS NULL OR severidade IN ('critico','alto','medio','baixo')),
status VARCHAR(20) NOT NULL DEFAULT 'pendente'
CHECK (status IN ('pendente','verificando','ok','problema','corrigido','wontfix')),
verificado_em DATE,
sessao_verificacao VARCHAR(160),
arquivo_afetado TEXT,
auditoria_item_id BIGINT REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL,
tags TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_area ON public.dev_verificacoes_items(area);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_status ON public.dev_verificacoes_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_severidade ON public.dev_verificacoes_items(severidade);
CREATE INDEX IF NOT EXISTS idx_dev_verificacoes_ordem ON public.dev_verificacoes_items(area, ordem);
DROP TRIGGER IF EXISTS trg_dev_verificacoes_updated_at ON public.dev_verificacoes_items;
CREATE TRIGGER trg_dev_verificacoes_updated_at
BEFORE UPDATE ON public.dev_verificacoes_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items;
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.dev_verificacoes_items IS 'Revisão sênior por área/sessão — o que foi verificado e o que foi encontrado.';
COMMENT ON COLUMN public.dev_verificacoes_items.area IS 'Domínio revisado: auth, router, agenda, financeiro, pacientes, comunicacao, etc.';
COMMENT ON COLUMN public.dev_verificacoes_items.auditoria_item_id IS 'Link opcional: se a verificação virou um bug em dev_auditoria_items.';
@@ -0,0 +1,403 @@
-- =============================================================================
-- Migration: 20260418000002_patient_intake_security_hardening
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
-- -----------------------------------------------------------------------------
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
-- avatars + storage policies.
--
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
-- whitelist de valores, validação de token completa (active/expires/max_uses).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. create_patient_intake_request_v2 — versão hardened
-- -----------------------------------------------------------------------------
-- Mudanças vs versão anterior:
-- • A#16: valida active, expires_at, max_uses; incrementa uses no final
-- • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
-- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
-- • A#27: length checks em TODOS os campos texto
-- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
-- lower em emails, whitelist para genero/estado_civil
-- • Consent obrigatório (raise se false)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
p_token text,
p_payload jsonb
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_owner_id uuid;
v_tenant_id uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
v_email text;
v_email_alt text;
v_nome text;
v_consent boolean;
v_genero text;
v_estado_civil text;
-- Whitelists para campos tipados
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Carrega invite e valida TUDO (A#16)
-- ───────────────────────────────────────────────────────────────────────
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
FROM public.patient_invites
WHERE token = p_token
LIMIT 1;
IF v_owner_id IS NULL THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Resolver tenant_id (A#19)
-- Se o invite não tem tenant_id, tenta achar a membership active do owner.
-- ───────────────────────────────────────────────────────────────────────
IF v_tenant_id IS NULL THEN
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_owner_id
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização + validações de campos (A#27)
-- ───────────────────────────────────────────────────────────────────────
-- Nome obrigatório (max 200)
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
RAISE EXCEPTION 'Nome é obrigatório';
END IF;
IF length(v_nome) > 200 THEN
RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
END IF;
-- Email principal obrigatório + lower + max 120
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
RAISE EXCEPTION 'E-mail é obrigatório';
END IF;
IF length(v_email) > 120 THEN
RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail inválido';
END IF;
-- Email alternativo opcional mas validado se presente
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
IF v_email_alt IS NOT NULL THEN
IF length(v_email_alt) > 120 THEN
RAISE EXCEPTION 'E-mail alternativo muito longo';
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
RAISE EXCEPTION 'E-mail alternativo inválido';
END IF;
END IF;
-- Consent obrigatório
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
RAISE EXCEPTION 'Consentimento é obrigatório';
END IF;
-- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
v_birth := CASE
WHEN v_birth_raw IS NULL THEN NULL
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
ELSE NULL
END;
-- Sanidade: nascimento não pode ser no futuro nem antes de 1900
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
-- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
v_genero := nullif(trim(p_payload->>'genero'), '');
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
v_genero := NULL;
END IF;
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
v_estado_civil := NULL;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- INSERT com sanitização inline
-- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
-- do terapeuta, não deve vir do paciente.
-- ───────────────────────────────────────────────────────────────────────
INSERT INTO public.patient_intake_requests (
owner_id,
tenant_id,
token,
status,
consent,
nome_completo,
email_principal,
email_alternativo,
telefone,
telefone_alternativo,
avatar_url,
data_nascimento,
cpf,
rg,
genero,
estado_civil,
profissao,
escolaridade,
nacionalidade,
naturalidade,
cep,
pais,
cidade,
estado,
endereco,
numero,
complemento,
bairro,
observacoes,
encaminhado_por,
onde_nos_conheceu
)
VALUES (
v_owner_id,
v_tenant_id,
p_token,
'new',
v_consent,
v_nome,
v_email,
v_email_alt,
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'rg'), ''), 20),
v_genero,
v_estado_civil,
left(nullif(trim(p_payload->>'profissao'), ''), 120),
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'pais'), ''), 60),
left(nullif(trim(p_payload->>'cidade'), ''), 120),
left(nullif(trim(p_payload->>'estado'), ''), 2),
left(nullif(trim(p_payload->>'endereco'), ''), 200),
left(nullif(trim(p_payload->>'numero'), ''), 20),
left(nullif(trim(p_payload->>'complemento'), ''), 120),
left(nullif(trim(p_payload->>'bairro'), ''), 120),
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
)
RETURNING id INTO v_intake_id;
-- Incrementa contador de uso (A#16)
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
RETURN v_intake_id;
END;
$function$;
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
-- -----------------------------------------------------------------------------
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
-- Novo: gera gen_random_uuid() server-side e retorna.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_new_token text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Token gerado no servidor (criptograficamente seguro via pgcrypto)
v_new_token := replace(gen_random_uuid()::text, '-', '');
-- Resolve tenant_id do usuário (active)
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
-- Desativa tokens ativos anteriores
UPDATE public.patient_invites
SET active = false
WHERE owner_id = v_uid
AND active = true;
-- Insere novo
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_new_token, true);
RETURN v_new_token;
END;
$function$;
COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';
GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
-- -----------------------------------------------------------------------------
-- Substitui o client-side newToken() + direct insert em patient_invites.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_uid uuid;
v_tenant_id uuid;
v_token text;
v_existing text;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
END IF;
-- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
SELECT token
INTO v_existing
FROM public.patient_invites
WHERE owner_id = v_uid
AND active = true
ORDER BY created_at DESC
LIMIT 1;
IF v_existing IS NOT NULL THEN
RETURN v_existing;
END IF;
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_uid
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
v_token := replace(gen_random_uuid()::text, '-', '');
INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
VALUES (v_uid, v_tenant_id, v_token, true);
RETURN v_token;
END;
$function$;
COMMENT ON FUNCTION public.issue_patient_invite() IS
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';
GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
-- -----------------------------------------------------------------------------
UPDATE storage.buckets
SET file_size_limit = 5242880, -- 5 MB
allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
WHERE id = 'avatars';
-- ─────────────────────────────────────────────────────────────────────────
-- 5. Storage policies — remover upload anon irrestrito (A#15)
-- -----------------------------------------------------------------------------
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
-- arquivo. Removemos essas policies. Upload público passa a exigir token
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;
-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
-- o terapeuta vê no painel de cadastros recebidos).
CREATE POLICY "intake_read_owner_only"
ON storage.objects FOR SELECT
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = 'intakes'
);
COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
@@ -0,0 +1,280 @@
-- =============================================================================
-- Migration: 20260418000003_patient_invite_attempts_log
-- Resolve A#24: log de tentativas de submit no cadastro público externo.
-- -----------------------------------------------------------------------------
-- Observação sobre IP: em RPC Postgres chamada via PostgREST o IP real do
-- cliente não chega aqui (só o do connection pooler). Por isso o registro
-- guarda o user_agent enviado pelo cliente (quando disponível) + metadados
-- resolvidos (owner, tenant). Rate-limit real por IP deve ser feito em edge
-- function no futuro (A#20).
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.patient_invite_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
token text NOT NULL,
ok boolean NOT NULL,
error_code text,
error_msg text,
client_info text, -- user_agent enviado pelo cliente (cap 500 no INSERT)
owner_id uuid, -- resolvido do token quando possível
tenant_id uuid,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_created ON public.patient_invite_attempts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_token ON public.patient_invite_attempts(token);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_owner ON public.patient_invite_attempts(owner_id);
CREATE INDEX IF NOT EXISTS idx_patient_invite_attempts_ok ON public.patient_invite_attempts(ok) WHERE ok = false;
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
-- Owner vê suas próprias tentativas (qualquer flood/erro que envolveu seus links)
DROP POLICY IF EXISTS patient_invite_attempts_owner_read ON public.patient_invite_attempts;
CREATE POLICY patient_invite_attempts_owner_read
ON public.patient_invite_attempts FOR SELECT
TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
COMMENT ON TABLE public.patient_invite_attempts IS
'Log de tentativas (ok e falhas) de submit do form público de cadastro externo. Base para monitoramento de flood/tentativas maliciosas. Sem IP direto — proteção LGPD.';
COMMENT ON COLUMN public.patient_invite_attempts.client_info IS
'User-agent enviado pelo cliente (opcional). Limitado a 500 chars no insert. Não contém PII.';
-- =============================================================================
-- create_patient_intake_request_v2 — versão instrumentada
-- -----------------------------------------------------------------------------
-- Mesma função do hardening anterior, agora com log em patient_invite_attempts.
-- O log é feito num bloco EXCEPTION que NUNCA propaga falha de log pro fluxo
-- principal (log falhar jamais deve impedir o cadastro de ser aceito).
-- =============================================================================
CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2(
p_token text,
p_payload jsonb,
p_client_info text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_owner_id uuid;
v_tenant_id uuid;
v_active boolean;
v_expires timestamptz;
v_max_uses int;
v_uses int;
v_intake_id uuid;
v_birth_raw text;
v_birth date;
v_email text;
v_email_alt text;
v_nome text;
v_consent boolean;
v_genero text;
v_estado_civil text;
v_err_msg text;
v_err_code text;
v_clean_info text;
c_generos text[] := ARRAY['male','female','non_binary','other','na'];
c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na'];
-- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
-- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
BEGIN
-- Sanitiza client_info recebido (cap + trim)
v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');
-- ───────────────────────────────────────────────────────────────────────
-- Resolve invite + valida TUDO (A#16)
-- ───────────────────────────────────────────────────────────────────────
SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
FROM public.patient_invites
WHERE token = p_token
LIMIT 1;
IF v_owner_id IS NULL THEN
v_err_code := 'TOKEN_INVALID';
v_err_msg := 'Token inválido';
-- Log + raise (owner_id NULL porque token não bateu)
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_active IS NOT TRUE THEN
v_err_code := 'TOKEN_DISABLED';
v_err_msg := 'Link desativado';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_expires IS NOT NULL AND now() > v_expires THEN
v_err_code := 'TOKEN_EXPIRED';
v_err_msg := 'Link expirado';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
v_err_code := 'TOKEN_MAX_USES';
v_err_msg := 'Limite de uso atingido';
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
END IF;
-- Resolve tenant_id se invite não tiver (A#19)
IF v_tenant_id IS NULL THEN
SELECT tenant_id
INTO v_tenant_id
FROM public.tenant_members
WHERE user_id = v_owner_id
AND status = 'active'
ORDER BY created_at ASC
LIMIT 1;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização + validações de campos (A#27)
-- ───────────────────────────────────────────────────────────────────────
v_nome := nullif(trim(p_payload->>'nome_completo'), '');
IF v_nome IS NULL THEN
v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF length(v_nome) > 200 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
IF v_email IS NULL THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF length(v_email) > 120 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
IF v_email_alt IS NOT NULL THEN
IF length(v_email_alt) > 120 THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
END IF;
v_consent := coalesce((p_payload->>'consent')::boolean, false);
IF v_consent IS NOT TRUE THEN
v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
RAISE EXCEPTION '%', v_err_msg;
END IF;
v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
v_birth := CASE
WHEN v_birth_raw IS NULL THEN NULL
WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
ELSE NULL
END;
IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
v_birth := NULL;
END IF;
v_genero := nullif(trim(p_payload->>'genero'), '');
IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
v_genero := NULL;
END IF;
v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
v_estado_civil := NULL;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- INSERT
-- ───────────────────────────────────────────────────────────────────────
INSERT INTO public.patient_intake_requests (
owner_id, tenant_id, token, status, consent,
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
avatar_url,
data_nascimento, cpf, rg, genero, estado_civil,
profissao, escolaridade, nacionalidade, naturalidade,
cep, pais, cidade, estado, endereco, numero, complemento, bairro,
observacoes, encaminhado_por, onde_nos_conheceu
)
VALUES (
v_owner_id, v_tenant_id, p_token, 'new', v_consent,
v_nome, v_email, v_email_alt,
nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
v_birth,
nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'rg'), ''), 20),
v_genero, v_estado_civil,
left(nullif(trim(p_payload->>'profissao'), ''), 120),
left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
left(nullif(trim(p_payload->>'pais'), ''), 60),
left(nullif(trim(p_payload->>'cidade'), ''), 120),
left(nullif(trim(p_payload->>'estado'), ''), 2),
left(nullif(trim(p_payload->>'endereco'), ''), 200),
left(nullif(trim(p_payload->>'numero'), ''), 20),
left(nullif(trim(p_payload->>'complemento'), ''), 120),
left(nullif(trim(p_payload->>'bairro'), ''), 120),
left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
)
RETURNING id INTO v_intake_id;
UPDATE public.patient_invites
SET uses = uses + 1
WHERE token = p_token;
-- Log de sucesso (best-effort, não propaga erro)
BEGIN
INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
EXCEPTION WHEN OTHERS THEN NULL; END;
RETURN v_intake_id;
END;
$function$;
COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) IS
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas; exige consent=true; registra cada tentativa em patient_invite_attempts (A#24).';
@@ -0,0 +1,149 @@
-- =============================================================================
-- Migration: 20260418000004_dev_tests
-- Nova aba "Testes" em /saas/desenvolvimento — catálogo de suítes de teste.
-- -----------------------------------------------------------------------------
-- Espelha a estrutura de dev_verificacoes_items. Uma linha = uma suíte de
-- teste (arquivo .spec.js ou grupo de testes). Serve para responder "quais
-- áreas estão cobertas por teste?" sem rodar npm test.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dev_test_items (
id BIGSERIAL PRIMARY KEY,
area VARCHAR(80) NOT NULL,
categoria VARCHAR(120), -- unit, integration, e2e, manual
titulo TEXT NOT NULL,
arquivo TEXT,
descricao TEXT,
total_tests INTEGER DEFAULT 0,
passing INTEGER DEFAULT 0,
failing INTEGER DEFAULT 0,
skipped INTEGER DEFAULT 0,
cobertura_pct NUMERIC(5,2), -- cobertura estimada daquela área
status VARCHAR(20) NOT NULL DEFAULT 'ok'
CHECK (status IN ('ok','falhando','pendente','obsoleto','a_escrever')),
last_run_at TIMESTAMPTZ,
sessao_criacao VARCHAR(160),
notas TEXT,
tags TEXT[] DEFAULT '{}',
ordem INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_area ON public.dev_test_items(area);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_status ON public.dev_test_items(status);
CREATE INDEX IF NOT EXISTS idx_dev_test_items_ordem ON public.dev_test_items(area, ordem);
DROP TRIGGER IF EXISTS trg_dev_test_items_updated_at ON public.dev_test_items;
CREATE TRIGGER trg_dev_test_items_updated_at
BEFORE UPDATE ON public.dev_test_items
FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dev_test_items_saas_admin_all ON public.dev_test_items;
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.dev_test_items IS
'Catálogo de suítes de teste por área. Responde "o que está testado?" sem precisar rodar npm test.';
-- =============================================================================
-- Seed inicial — testes existentes em 2026-04-18
-- =============================================================================
INSERT INTO public.dev_test_items
(area, categoria, titulo, arquivo, descricao, total_tests, passing, failing, skipped, cobertura_pct, status, last_run_at, sessao_criacao, notas, tags, ordem)
VALUES
('agenda', 'unit',
'useRecurrence — geração de ocorrências',
'src/features/agenda/composables/__tests__/useRecurrence.spec.js',
$$Cobre: generateDates (weekly, biweekly, custom_weekdays, monthly, yearly), expandRules com exceções (cancel_session, patient_missed, reschedule_session, holiday_block), mergeWithStoredSessions, max_occurrences, range boundaries, remarcação inbound.$$,
23, 23, 0, 0, NULL,
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
'Suite sólida. Cobre os branches críticos da expansão de recorrência. Testes sobreviveram à adição do cap de range (V#20) e ao filtro de tenant_id nas CRUDs (V#12).',
ARRAY['unit','agenda','recurrence','critical'], 1),
('agenda', 'unit',
'agendaMappers — transformação pra FullCalendar',
'src/features/agenda/services/__tests__/agendaMappers.spec.js',
$$Cobre: mapAgendaEventosToCalendarEvents (shape, campos extras), status cor + ícone (agendado, realizado, faltou, cancelado, remarcado), aliases de FK (patients, determined_commitments), tipo fallback, ocorrência virtual (is_occurrence), resource events (clinic mosaic).$$,
40, 40, 0, 0, NULL,
'ok', '2026-04-18 08:47:00+00', 'Sessão 2 — agenda',
'Quatro testes estavam falhando antes do V#21 (status "remarcado" vs "remarcar" + cores faltou/cancelado invertidas). Agora 100%.',
ARRAY['unit','agenda','mappers'], 2),
('auth', 'a_escrever',
'guards.js — branches do router beforeEach',
'src/router/__tests__/guards.spec.js (não existe)',
$$Deveria cobrir: rotas públicas liberadas, redirect pra /auth/login sem session, área /account sem tenant, saas_admin em /saas, tenant lockdown, trocaTenantScope, matchesRoles com aliases, cache de globalRole, cache de saasAdmin.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'guards.js tem ~650 linhas e só roda via navegação real. Sem teste unitário → mudanças no guard são de alto risco. Prioridade média para criar (mock do router + pinia).',
ARRAY['unit','auth','router','guard','missing'], 3),
('auth', 'a_escrever',
'session.js — hydrate e race conditions',
'src/app/__tests__/session.spec.js (não existe)',
$$Deveria cobrir: initSession com/sem session, refreshSession que não dispara se refreshing, SIGNED_IN redundante ignorado, SIGNED_OUT zera state, TOKEN_REFRESHED não derruba cache, hydrate preserva user em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Módulo tem histórico de race conditions (comentado no próprio arquivo). Teste unitário daria garantia contra regressão.',
ARRAY['unit','auth','session','race','missing'], 4),
('stores', 'a_escrever',
'tenantStore — singleflight + persist',
'src/stores/__tests__/tenantStore.spec.js (não existe)',
$$Deveria cobrir: loadSessionAndTenant com Promise compartilhada (V#3), ensureLoaded sem setInterval, tenant salvo se pertence ao user, normalizeTenantRole, reset, persistência em localStorage.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'V#3 trocou polling por Promise singleflight — a correção não tem teste que proteja contra regressão.',
ARRAY['unit','store','tenant','missing'], 5),
('utils', 'a_escrever',
'roleNormalizer — saídas esperadas',
'src/utils/__tests__/roleNormalizer.spec.js (não existe)',
$$Fácil de testar função pura, sem IO. Cobre: tenant_admin+therapisttherapist, tenant_admin+clinicclinic_admin, tenant_admin+supervisorsupervisor, tenant_admin sem kindclinic_admin, clinic_adminclinic_admin, pass-through.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 1 — auth/router',
'Criado em V#4. É função pura — fácil de cobrir em 10min. Baixa prioridade técnica mas alto valor simbólico (garantir que os 2 consumidores — guards.js e tenantStore.js — concordam).',
ARRAY['unit','utils','trivial'], 6),
('pacientes', 'a_escrever',
'Cadastros externos — fluxo do paciente',
'src/features/patients/__tests__/external-intake.spec.js (não existe)',
$$Deveria cobrir: validação client-side (token regex, email, consent), truncation em todos os campos, payload final, não envio de notas_internas, comportamento com token inválido.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Página pública é ponto crítico de segurança. Teste de regressão importante após A#17/A#18/A#21 — garantir que nenhum dos valores "perigosos" voltem a ser enviados.',
ARRAY['unit','pacientes','external','security-regression'], 7),
('database', 'manual',
'RPCs de intake — validação de inputs maliciosos',
'database-novo/tests/test_patient_intake_security.sql (sugerido)',
$$Deveria cobrir: token inválido raise, token desativado raise (A#16), token expirado raise, max_uses raise, uses incrementa após sucesso, consent=false raise, payload com notas_internas é ignorado (A#17), tenant_id é preenchido (A#19), nome > 200 chars raise, email inválido raise, genero fora whitelist vira NULL, data_nascimento futura vira NULL.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Testes SQL diretos via psql. Importantes porque as validações estão dentro do RPC SECURITY DEFINER. Executar antes de cada deploy.',
ARRAY['manual','sql','security','rpc'], 8),
('agenda', 'a_escrever',
'useAgendaEvents — wrapper do repository',
'src/features/agenda/composables/__tests__/useAgendaEvents.spec.js (não existe)',
$$Deveria cobrir: loadMyRange chama listMyAgendaEvents, estado loading/error transições, sem ownerId retorna cedo, rollback em erro.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 2 — agenda',
'Após refactor V#14 o composable virou fino. Teste garante que continue fino.',
ARRAY['unit','agenda','composable','missing'], 9),
('e2e', 'a_escrever',
'Fluxo completo: terapeuta cria link → paciente preenche → terapeuta vê',
'(não existe)',
$$Deveria cobrir o happy path integrado: login terapeuta, gera link via issue_patient_invite, abre /cadastro/paciente em aba anônima, preenche, submit, terapeuta em /therapist/patients/recebidos.$$,
0, 0, 0, 0, NULL,
'a_escrever', NULL, 'Sessão 4 — Security Hardening',
'Não há E2E hoje. Playwright ou Cypress valem? Decidir provider. Alta prioridade pra confiança em deploy.',
ARRAY['e2e','critical','missing','decisão-pendente'], 10);
SELECT id, area, categoria, status, total_tests, passing FROM public.dev_test_items ORDER BY ordem;
@@ -0,0 +1,167 @@
-- =============================================================================
-- Migration: 20260418000005_saas_rls_emergency_fix
-- Corrige A#30 (P0) — 7 tabelas SaaS estavam com RLS desabilitado + grants
-- totais pra anon/authenticated/service_role. Qualquer usuário anônimo
-- podia alterar/deletar dados críticos (tenant_features, plan_prices,
-- subscription_intents_personal/tenant, plan_public, ...).
--
-- Estratégia:
-- 1. Habilitar RLS em todas as 7 tabelas
-- 2. REVOKE ALL de anon (nunca deveria ter tido)
-- 3. REVOKE ALL de authenticated (controle passa a ser via policy)
-- 4. Policies explícitas por caso de uso
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. REVOKE grants inseguros
-- -----------------------------------------------------------------------------
REVOKE ALL ON public.tenant_features FROM anon, authenticated;
REVOKE ALL ON public.plan_prices FROM anon, authenticated;
REVOKE ALL ON public.plan_public FROM anon, authenticated;
REVOKE ALL ON public.plan_public_bullets FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_personal FROM anon, authenticated;
REVOKE ALL ON public.subscription_intents_tenant FROM anon, authenticated;
REVOKE ALL ON public.tenant_feature_exceptions_log FROM anon, authenticated;
-- Concede o mínimo necessário (controlado por RLS abaixo)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_features TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plan_prices TO authenticated;
GRANT SELECT ON public.plan_public TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public TO authenticated;
GRANT SELECT ON public.plan_public_bullets TO anon, authenticated;
GRANT INSERT, UPDATE, DELETE ON public.plan_public_bullets TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_personal TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.subscription_intents_tenant TO authenticated;
GRANT SELECT ON public.tenant_feature_exceptions_log TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. HABILITAR RLS em todas
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. POLICIES — tenant_features
-- -----------------------------------------------------------------------------
-- SELECT: membros do tenant leem as features do próprio tenant. Saas admin lê tudo.
DROP POLICY IF EXISTS tenant_features_select ON public.tenant_features;
CREATE POLICY tenant_features_select ON public.tenant_features
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
-- WRITE: apenas tenant_admin do próprio tenant OU saas_admin.
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
CREATE POLICY tenant_features_write ON public.tenant_features
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 4. POLICIES — plan_prices (SaaS admin only pra escrita; authenticated lê)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_prices_read ON public.plan_prices;
CREATE POLICY plan_prices_read ON public.plan_prices
FOR SELECT TO authenticated
USING (true); -- preços são públicos pra usuários logados
DROP POLICY IF EXISTS plan_prices_write ON public.plan_prices;
CREATE POLICY plan_prices_write ON public.plan_prices
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 5. POLICIES — plan_public + plan_public_bullets (anon pode ler — landing page)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS plan_public_read_anon ON public.plan_public;
CREATE POLICY plan_public_read_anon ON public.plan_public
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_write ON public.plan_public;
CREATE POLICY plan_public_write ON public.plan_public
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS plan_public_bullets_read_anon ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets
FOR SELECT TO anon, authenticated
USING (true);
DROP POLICY IF EXISTS plan_public_bullets_write ON public.plan_public_bullets;
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- 6. POLICIES — subscription_intents_personal + _tenant
-- -----------------------------------------------------------------------------
-- Dono vê o próprio intent; saas admin vê tudo; owner cria/atualiza seus próprios.
DROP POLICY IF EXISTS subscription_intents_personal_owner ON public.subscription_intents_personal;
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal
FOR ALL TO authenticated
USING (user_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (user_id = auth.uid() OR public.is_saas_admin());
DROP POLICY IF EXISTS subscription_intents_tenant_member ON public.subscription_intents_tenant;
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- 7. POLICY — tenant_feature_exceptions_log (somente leitura)
-- -----------------------------------------------------------------------------
-- Log de auditoria. Inserts vêm de triggers/funções server-side (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log;
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (SELECT tm.tenant_id FROM public.tenant_members tm WHERE tm.user_id = auth.uid() AND tm.status = 'active')
);
COMMENT ON TABLE public.tenant_features IS
'Controle de features por tenant. RLS: member do tenant lê; tenant_admin ou saas_admin escreve. Antes da migration 20260418000005 estava com RLS off + GRANT ALL pra anon (A#30).';
@@ -0,0 +1,214 @@
-- =============================================================================
-- Migration: 20260419000001_tenant_features_b2_governance
-- Resolve V#34 (isEnabled opt-out por padrão) + V#41 (dupla fonte entitlements
-- vs tenant_features) — Opção B2 (plano + override com exceção comercial).
--
-- Mudanças:
-- 1. Trigger tenant_features_guard_with_plan ganha bypass via session flag
-- (current_setting('app.allow_feature_exception')) — só RPC pode setar.
-- 2. Nova RPC set_tenant_feature_exception(tenant_id, feature_key, enabled, reason)
-- SECURITY DEFINER, com regras assimétricas:
-- - p_enabled=false → tenant_admin OU saas_admin (preferência)
-- - p_enabled=true AND plano permite → tenant_admin OU saas_admin
-- - p_enabled=true AND plano NÃO permite → SOMENTE saas_admin + reason obrigatório
-- Toda mudança grava em tenant_feature_exceptions_log.
-- 3. Policy tenant_features_write restringida a saas_admin (writes diretos).
-- Tenant_admin agora muda só via RPC.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. Trigger: bypass controlado por session flag
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.tenant_features_guard_with_plan()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_allowed boolean;
v_bypass text;
BEGIN
-- Só valida quando está habilitando
IF new.enabled IS DISTINCT FROM true THEN
RETURN new;
END IF;
-- Bypass autorizado: setado pela RPC set_tenant_feature_exception
-- após validar que o caller é saas_admin com reason.
v_bypass := current_setting('app.allow_feature_exception', true);
IF v_bypass = 'true' THEN
RETURN new;
END IF;
-- Permitido pelo plano do tenant?
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements_full v
WHERE v.tenant_id = new.tenant_id
AND v.feature_key = new.feature_key
AND v.allowed = true
) INTO v_allowed;
IF NOT v_allowed THEN
RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
new.feature_key, new.tenant_id
USING ERRCODE = 'P0001';
END IF;
RETURN new;
END;
$$;
-- ─────────────────────────────────────────────────────────────────────────
-- 2. RPC set_tenant_feature_exception
-- (substitui versão anterior que retornava void; retorna jsonb agora)
-- -----------------------------------------------------------------------------
DROP FUNCTION IF EXISTS public.set_tenant_feature_exception(uuid, text, boolean, text);
CREATE OR REPLACE FUNCTION public.set_tenant_feature_exception(
p_tenant_id uuid,
p_feature_key text,
p_enabled boolean,
p_reason text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_is_saas boolean := public.is_saas_admin();
v_is_tenant_adm boolean;
v_plan_allows boolean;
v_feature_key text;
v_reason text;
v_is_exception boolean;
BEGIN
-- ───────────────────────────────────────────────────────────────────────
-- Sanitização (padrão V#31)
-- ───────────────────────────────────────────────────────────────────────
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF p_tenant_id IS NULL THEN
RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
END IF;
IF p_enabled IS NULL THEN
RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
END IF;
v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
IF v_feature_key IS NULL THEN
RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
END IF;
IF length(v_feature_key) > 80 THEN
RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
END IF;
IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
END IF;
v_reason := nullif(btrim(coalesce(p_reason, '')), '');
IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
v_reason := substring(v_reason FROM 1 FOR 500);
END IF;
IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Plano permite essa feature?
-- ───────────────────────────────────────────────────────────────────────
SELECT EXISTS (
SELECT 1
FROM public.v_tenant_entitlements vte
WHERE vte.tenant_id = p_tenant_id
AND vte.feature_key = v_feature_key
) INTO v_plan_allows;
v_is_exception := (p_enabled = true AND NOT v_plan_allows);
-- ───────────────────────────────────────────────────────────────────────
-- Caller é tenant_admin desse tenant?
-- ───────────────────────────────────────────────────────────────────────
v_is_tenant_adm := EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = p_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
);
-- ───────────────────────────────────────────────────────────────────────
-- Autorização (assimétrica — V#34 Opção B2)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
-- Override positivo fora do plano = exceção comercial
IF NOT v_is_saas THEN
RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
END IF;
IF v_reason IS NULL THEN
RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
END IF;
ELSE
-- Demais casos: tenant_admin OR saas_admin
IF NOT (v_is_saas OR v_is_tenant_adm) THEN
RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
END IF;
END IF;
-- ───────────────────────────────────────────────────────────────────────
-- Persistência: bypass controlado do trigger guard quando é exceção
-- (escopo de transação via SET LOCAL — só esta RPC vê)
-- ───────────────────────────────────────────────────────────────────────
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'true', true);
END IF;
INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
VALUES (p_tenant_id, v_feature_key, p_enabled, now())
ON CONFLICT (tenant_id, feature_key)
DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();
-- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
IF v_is_exception THEN
PERFORM set_config('app.allow_feature_exception', 'false', true);
END IF;
INSERT INTO public.tenant_feature_exceptions_log
(tenant_id, feature_key, enabled, reason, created_by)
VALUES
(p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);
RETURN jsonb_build_object(
'tenant_id', p_tenant_id,
'feature_key', v_feature_key,
'enabled', p_enabled,
'plan_allows', v_plan_allows,
'is_exception', v_is_exception,
'reason', v_reason
);
END;
$function$;
REVOKE ALL ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.set_tenant_feature_exception(uuid, text, boolean, text) TO authenticated;
-- ─────────────────────────────────────────────────────────────────────────
-- 3. Policy: writes diretos só via saas_admin
-- (tenant_admin agora muda só via RPC set_tenant_feature_exception)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS tenant_features_write ON public.tenant_features;
DROP POLICY IF EXISTS tenant_features_write_saas_only ON public.tenant_features;
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
@@ -0,0 +1,21 @@
-- =============================================================================
-- Migration: 20260419000002_features_is_active
-- V#40 — features hard-deleted: adiciona is_active para soft-delete.
--
-- Estratégia conservadora:
-- - features.is_active boolean DEFAULT true NOT NULL
-- - SaasFeaturesPage substitui DELETE por UPDATE is_active=false
-- - Views que expõem features para o app (v_tenant_entitlements etc) NÃO são
-- alteradas: features depreciadas ainda servem tenants legados via plan_features
-- enquanto não houver migração explícita
-- - Permite reativar feature acidentalmente deprecada
-- =============================================================================
ALTER TABLE public.features
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true;
CREATE INDEX IF NOT EXISTS idx_features_is_active
ON public.features (is_active) WHERE is_active = false;
COMMENT ON COLUMN public.features.is_active IS
'V#40: false = feature depreciada, escondida no catálogo SaaS mas continua válida em planos/tenants existentes.';
@@ -0,0 +1,69 @@
-- =============================================================================
-- Migration: 20260419000003_delete_plan_safe
-- V#36 — DELETE de plans sem checagem de assinaturas ativas pode quebrar tenants.
--
-- Cria RPC delete_plan_safe(plan_id) que:
-- - Valida saas_admin
-- - Conta subscriptions ativas (status='active') no plano
-- - Se houver, RAISE EXCEPTION descritivo com a contagem
-- - Se OK, desativa prices ativos e deleta o plano (atomic)
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_plan_safe(
p_plan_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_active_count int;
v_plan_key text;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
END IF;
IF p_plan_id IS NULL THEN
RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
END IF;
SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
IF v_plan_key IS NULL THEN
RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
END IF;
SELECT COUNT(*) INTO v_active_count
FROM public.subscriptions
WHERE plan_id = p_plan_id
AND status = 'active';
IF v_active_count > 0 THEN
RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
v_plan_key, v_active_count
USING ERRCODE = 'P0001';
END IF;
-- desativa preços ativos antes de deletar
UPDATE public.plan_prices
SET is_active = false,
active_to = now()
WHERE plan_id = p_plan_id
AND is_active = true;
DELETE FROM public.plans WHERE id = p_plan_id;
RETURN jsonb_build_object(
'deleted', true,
'plan_key', v_plan_key
);
END;
$function$;
REVOKE ALL ON FUNCTION public.delete_plan_safe(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.delete_plan_safe(uuid) TO authenticated;
@@ -0,0 +1,46 @@
-- =============================================================================
-- Migration: 20260419000004_consolidate_policies
-- V#35 — Consolida policies duplicadas em plans, features, plan_features e
-- subscriptions. Remove legado redundante e documenta as que ficam.
--
-- Análise (auditada via pg_policies):
-- • plans/features/plan_features: cada uma tem "read * (auth)" duplicado
-- com "*_read_authenticated" (mesmo USING true). Removidos os legados.
-- • subscriptions:
-- - "subscriptions read own" (USING user_id = auth.uid()) é SUBSET de
-- "subscriptions_read_own" (USING user_id = auth.uid() OR is_saas_admin())
-- - "subscriptions_select_own_personal" (user_id = auth.uid() AND tenant_id IS NULL)
-- é SUBSET de "subscriptions_read_own"
-- - "subscriptions_no_direct_update" (USING false) é no-op em OR com
-- "subscriptions_update_only_saas_admin"
-- Removidas as 3 redundâncias.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Drops dos legados redundantes
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "read plans (auth)" ON public.plans;
DROP POLICY IF EXISTS "read features (auth)" ON public.features;
DROP POLICY IF EXISTS "read plan_features (auth)" ON public.plan_features;
DROP POLICY IF EXISTS "subscriptions read own" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_select_own_personal" ON public.subscriptions;
DROP POLICY IF EXISTS "subscriptions_no_direct_update" ON public.subscriptions;
-- ─────────────────────────────────────────────────────────────────────────
-- COMMENT ON POLICY — documenta escopo das que ficaram
-- -----------------------------------------------------------------------------
COMMENT ON POLICY plans_read_authenticated ON public.plans IS 'Qualquer usuário autenticado lê o catálogo de planos (vitrine, upgrade UI).';
COMMENT ON POLICY plans_write_saas_admin ON public.plans IS 'Somente saas_admin escreve. DELETE deve ser via RPC delete_plan_safe (V#36).';
COMMENT ON POLICY features_read_authenticated ON public.features IS 'Qualquer logado lê o catálogo de features.';
COMMENT ON POLICY features_write_saas_admin ON public.features IS 'Somente saas_admin escreve. DELETE = soft delete via is_active=false (V#40).';
COMMENT ON POLICY plan_features_read_authenticated ON public.plan_features IS 'Qualquer logado lê o vínculo plano↔feature (necessário para entitlements).';
COMMENT ON POLICY plan_features_write_saas_admin ON public.plan_features IS 'Somente saas_admin escreve.';
COMMENT ON POLICY subscriptions_read_own ON public.subscriptions IS 'Dono da assinatura (user_id) ou saas_admin. Cobre o caso pessoal.';
COMMENT ON POLICY subscriptions_select_for_tenant_members ON public.subscriptions IS 'Membros ativos do tenant leem assinaturas do tenant.';
COMMENT ON POLICY "subscriptions: read if linked owner_users" ON public.subscriptions IS 'Caso especial: usuários ligados ao owner via owner_users (terapeutas de uma clínica que precisam ver a assinatura do owner).';
COMMENT ON POLICY subscriptions_insert_own_personal ON public.subscriptions IS 'Usuário cria a própria assinatura pessoal (intent → conversion).';
COMMENT ON POLICY subscriptions_update_only_saas_admin ON public.subscriptions IS 'UPDATE direto somente via saas_admin. Mudanças de tenant devem passar por RPC dedicada.';
@@ -0,0 +1,29 @@
-- =============================================================================
-- Migration: 20260419000005_restrict_intake_rpc
-- A#20 — Restringe create_patient_intake_request_v2 a service_role.
--
-- Antes: anon (e PUBLIC) podia chamar direto. Bot bypassava qualquer
-- proteção do front (Turnstile etc).
-- Agora: edge function `submit-patient-intake` valida CAPTCHA e chama
-- a RPC com service_role. Anon não chama mais a RPC direto.
-- =============================================================================
-- Revoga PUBLIC (DEFAULT) e anon
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) FROM PUBLIC, anon;
REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) FROM PUBLIC, anon;
-- Mantém grants explícitos pra authenticated (uso interno futuro) e service_role (edge function)
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) TO authenticated, service_role;
GRANT EXECUTE ON FUNCTION public.create_patient_intake_request_v2(text, jsonb, text) TO authenticated, service_role;
-- Mesma proteção para RPC v1 legada (caso ainda exista)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'create_patient_intake_request'
) THEN
EXECUTE 'REVOKE EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) FROM PUBLIC, anon';
EXECUTE 'GRANT EXECUTE ON FUNCTION public.create_patient_intake_request(text, text, text, text, text, boolean) TO authenticated, service_role';
END IF;
END$$;
@@ -0,0 +1,136 @@
-- =============================================================================
-- Migration: 20260419000006_layered_bot_defense
-- A#20 (rev2) — Defesa em camadas self-hosted (substitui Turnstile).
--
-- Camadas:
-- 1. Honeypot field (no front) → invisível, sempre ativo
-- 2. Rate limit por IP no edge → submission_rate_limits
-- 3. Math captcha CONDICIONAL → só se IP teve N falhas recentes
-- 4. Logging em public_submission_attempts (genérico, não só intake)
-- 5. Modo paranoid global → saas_security_config.captcha_required
--
-- Substitui chamadas Turnstile na edge function submit-patient-intake.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- 1. saas_security_config (singleton)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.saas_security_config (
id boolean PRIMARY KEY DEFAULT true,
honeypot_enabled boolean NOT NULL DEFAULT true,
rate_limit_enabled boolean NOT NULL DEFAULT true,
rate_limit_window_min integer NOT NULL DEFAULT 10,
rate_limit_max_attempts integer NOT NULL DEFAULT 5,
captcha_after_failures integer NOT NULL DEFAULT 3,
captcha_required_globally boolean NOT NULL DEFAULT false,
block_duration_min integer NOT NULL DEFAULT 30,
captcha_required_window_min integer NOT NULL DEFAULT 60,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK (id = true)
);
INSERT INTO public.saas_security_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_security_config FROM anon, authenticated;
GRANT SELECT, UPDATE ON public.saas_security_config TO authenticated;
DROP POLICY IF EXISTS saas_security_config_read ON public.saas_security_config;
CREATE POLICY saas_security_config_read ON public.saas_security_config
FOR SELECT TO authenticated
USING (true); -- qualquer logado pode ler config global (não tem segredo)
DROP POLICY IF EXISTS saas_security_config_write ON public.saas_security_config;
CREATE POLICY saas_security_config_write ON public.saas_security_config
FOR UPDATE TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
COMMENT ON TABLE public.saas_security_config IS 'Singleton: configuração global de defesa contra bots em endpoints públicos.';
-- ─────────────────────────────────────────────────────────────────────────
-- 2. public_submission_attempts (log genérico)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.public_submission_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text, -- 'honeypot' | 'rate_limit' | 'captcha' | 'rpc' | null
user_agent text,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_psa_endpoint_created ON public.public_submission_attempts (endpoint, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_psa_ip_hash_created ON public.public_submission_attempts (ip_hash, created_at DESC) WHERE ip_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_psa_failed ON public.public_submission_attempts (created_at DESC) WHERE success = false;
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.public_submission_attempts FROM anon, authenticated;
GRANT SELECT ON public.public_submission_attempts TO authenticated;
DROP POLICY IF EXISTS psa_read_saas_admin ON public.public_submission_attempts;
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.public_submission_attempts IS 'Log de tentativas em endpoints públicos (intake, signup, agendador). Escrita apenas via RPC SECURITY DEFINER.';
-- ─────────────────────────────────────────────────────────────────────────
-- 3. submission_rate_limits (estado vigente por IP+endpoint)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer NOT NULL DEFAULT 0,
fail_count integer NOT NULL DEFAULT 0,
window_start timestamptz NOT NULL DEFAULT now(),
blocked_until timestamptz,
requires_captcha_until timestamptz,
last_attempt_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (ip_hash, endpoint)
);
CREATE INDEX IF NOT EXISTS idx_srl_blocked_until ON public.submission_rate_limits (blocked_until) WHERE blocked_until IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_srl_endpoint ON public.submission_rate_limits (endpoint, last_attempt_at DESC);
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.submission_rate_limits FROM anon, authenticated;
GRANT SELECT ON public.submission_rate_limits TO authenticated;
DROP POLICY IF EXISTS srl_read_saas_admin ON public.submission_rate_limits;
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits
FOR SELECT TO authenticated
USING (public.is_saas_admin());
COMMENT ON TABLE public.submission_rate_limits IS 'Estado de rate limit por IP+endpoint. Escrita apenas via RPC. SaaS admin lê pra dashboard.';
-- ─────────────────────────────────────────────────────────────────────────
-- 4. math_challenges (TTL 5min, limpa via cron)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.math_challenges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
question text NOT NULL,
answer integer NOT NULL,
used boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT (now() + interval '5 minutes')
);
CREATE INDEX IF NOT EXISTS idx_mc_expires ON public.math_challenges (expires_at);
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.math_challenges FROM anon, authenticated;
-- nenhum grant: tabela acessada apenas via RPC SECURITY DEFINER
COMMENT ON TABLE public.math_challenges IS 'Challenges de math captcha. TTL 5min. Escrita/leitura apenas via RPC.';
@@ -0,0 +1,299 @@
-- =============================================================================
-- Migration: 20260419000007_bot_defense_rpcs
-- A#20 (rev2) — RPCs da defesa em camadas:
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
-- • record_submission_attempt — log + atualiza contadores e bloqueios
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
-- • verify_math_challenge — valida {id, answer}, marca used
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- check_rate_limit
-- Lê config + estado atual, decide o que retornar.
-- Se fora da janela atual, "rolha" os contadores (reset).
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.check_rate_limit(
p_ip_hash text,
p_endpoint text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
rl submission_rate_limits%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
v_in_window boolean;
v_requires_captcha boolean := false;
v_blocked_until timestamptz;
v_retry_after_seconds integer := 0;
BEGIN
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND THEN
-- Sem config: fail-open (libera). Logado.
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
END IF;
-- Modo paranoid global: sempre captcha
IF cfg.captcha_required_globally THEN
v_requires_captcha := true;
END IF;
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
IF NOT cfg.rate_limit_enabled THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
);
END IF;
-- Sem ip_hash: libera (não dá pra rastrear)
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', 'no_ip'
);
END IF;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
-- Bloqueio temporário ativo?
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'blocked'
);
END IF;
-- Captcha condicional ativo?
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
v_requires_captcha := true;
END IF;
-- Janela atual ainda válida?
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
v_in_window := FOUND AND rl.window_start >= v_window_start;
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
-- Excedeu — bloqueia
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
UPDATE submission_rate_limits
SET blocked_until = v_blocked_until,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'rate_limit_exceeded'
);
END IF;
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
);
END;
$function$;
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- record_submission_attempt
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
-- requires_captcha_until = now + captcha_required_window_min.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
p_endpoint text,
p_ip_hash text,
p_success boolean,
p_blocked_by text DEFAULT NULL,
p_error_code text DEFAULT NULL,
p_error_msg text DEFAULT NULL,
p_user_agent text DEFAULT NULL,
p_metadata jsonb DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
rl submission_rate_limits%ROWTYPE;
BEGIN
-- Log sempre (mesmo sem ip)
INSERT INTO public_submission_attempts
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
VALUES
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
left(coalesce(p_error_code, ''), 80),
left(coalesce(p_error_msg, ''), 500),
left(coalesce(p_user_agent, ''), 500),
p_metadata);
-- Sem ip ou rate limit desligado: não atualiza contador
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF NOT FOUND THEN
INSERT INTO submission_rate_limits
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
VALUES
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
ELSE
IF rl.window_start < v_window_start THEN
-- Reset janela
UPDATE submission_rate_limits
SET attempt_count = 1,
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
window_start = v_now,
last_attempt_at = v_now,
blocked_until = NULL
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
ELSE
UPDATE submission_rate_limits
SET attempt_count = attempt_count + 1,
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
-- Se atingiu threshold de captcha condicional, marca
IF NOT p_success THEN
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF rl.fail_count >= cfg.captcha_after_failures
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
UPDATE submission_rate_limits
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
END IF;
END IF;
END;
$function$;
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- generate_math_challenge
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
-- Operações: + - * (resultado sempre positivo)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_a integer;
v_b integer;
v_op text;
v_ans integer;
v_q text;
v_id uuid;
BEGIN
v_a := 1 + floor(random() * 9)::int;
v_b := 1 + floor(random() * 9)::int;
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
-- garantir resultado positivo na subtração
IF v_op = '-' AND v_b > v_a THEN
v_a := v_a + v_b;
END IF;
v_ans := CASE v_op
WHEN '+' THEN v_a + v_b
WHEN '-' THEN v_a - v_b
WHEN '*' THEN v_a * v_b
END;
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
INSERT INTO math_challenges (question, answer)
VALUES (v_q, v_ans)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$function$;
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- verify_math_challenge
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
p_id uuid,
p_answer integer
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
mc math_challenges%ROWTYPE;
BEGIN
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
RETURN false;
END IF;
UPDATE math_challenges SET used = true WHERE id = p_id;
RETURN mc.answer = p_answer;
END;
$function$;
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- cleanup_expired_math_challenges (chamável via cron)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
RETURNS integer
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH d AS (
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
)
SELECT COUNT(*)::int FROM d;
$function$;
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
@@ -0,0 +1,155 @@
-- =============================================================================
-- Migration: 20260419000008_saas_twilio_config
-- Permite saas_admin editar config Twilio operacional pelo painel, sem redeploy.
--
-- DECISÃO DE SEGURANÇA:
-- • TWILIO_AUTH_TOKEN (secret) NÃO entra na tabela. Continua em env var
-- da Edge Function. Painel apenas exibe se está configurado (best-effort).
-- • TWILIO_ACCOUNT_SID (público no Twilio dashboard, identificador) → DB
-- • TWILIO_WHATSAPP_WEBHOOK (URL) → DB
-- • USD_BRL_RATE / MARGIN_MULTIPLIER (operacional) → DB
--
-- Edge function: lê primeiro do banco; cai pra env vars como fallback se row
-- ainda não foi configurada (back-compat com deploys antigos).
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.saas_twilio_config (
id boolean PRIMARY KEY DEFAULT true,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) NOT NULL DEFAULT 5.5,
margin_multiplier numeric(10,4) NOT NULL DEFAULT 1.4,
notes text,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid,
CONSTRAINT saas_twilio_config_singleton CHECK (id = true),
CONSTRAINT saas_twilio_config_rate_chk CHECK (usd_brl_rate > 0 AND usd_brl_rate < 100),
CONSTRAINT saas_twilio_config_mult_chk CHECK (margin_multiplier >= 1 AND margin_multiplier <= 10),
CONSTRAINT saas_twilio_config_sid_chk CHECK (account_sid IS NULL OR account_sid ~ '^AC[a-zA-Z0-9]{32}$'),
CONSTRAINT saas_twilio_config_url_chk CHECK (whatsapp_webhook_url IS NULL OR whatsapp_webhook_url ~ '^https?://')
);
INSERT INTO public.saas_twilio_config (id) VALUES (true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.saas_twilio_config FROM anon, authenticated;
GRANT SELECT ON public.saas_twilio_config TO authenticated;
DROP POLICY IF EXISTS saas_twilio_config_read ON public.saas_twilio_config;
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config
FOR SELECT TO authenticated
USING (public.is_saas_admin()); -- só admin vê config (mesmo sem secret, é dado operacional)
COMMENT ON TABLE public.saas_twilio_config IS
'Config operacional Twilio editável via painel. AUTH_TOKEN continua em env var por segurança.';
-- ─────────────────────────────────────────────────────────────────────────
-- RPC get_twilio_config — retorna config atual (saas_admin OU service_role)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_twilio_config()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_twilio_config%ROWTYPE;
BEGIN
-- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
-- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
END IF;
SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
IF NOT FOUND THEN
RETURN jsonb_build_object(
'account_sid', NULL,
'whatsapp_webhook_url', NULL,
'usd_brl_rate', 5.5,
'margin_multiplier', 1.4
);
END IF;
RETURN jsonb_build_object(
'account_sid', cfg.account_sid,
'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
'usd_brl_rate', cfg.usd_brl_rate,
'margin_multiplier', cfg.margin_multiplier,
'notes', cfg.notes,
'updated_at', cfg.updated_at,
'updated_by', cfg.updated_by
);
END;
$function$;
REVOKE ALL ON FUNCTION public.get_twilio_config() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.get_twilio_config() TO authenticated, service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- RPC update_twilio_config — só saas_admin
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.update_twilio_config(
p_account_sid text DEFAULT NULL,
p_whatsapp_webhook_url text DEFAULT NULL,
p_usd_brl_rate numeric DEFAULT NULL,
p_margin_multiplier numeric DEFAULT NULL,
p_notes text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_account_sid text;
v_webhook_url text;
v_notes text;
BEGIN
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
END IF;
IF NOT public.is_saas_admin() THEN
RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
END IF;
-- Sanitização
v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
v_notes := nullif(btrim(coalesce(p_notes, '')), '');
IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
END IF;
IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
END IF;
IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
END IF;
IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
END IF;
IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
v_notes := substring(v_notes FROM 1 FOR 1000);
END IF;
UPDATE saas_twilio_config
SET account_sid = COALESCE(v_account_sid, account_sid),
whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
usd_brl_rate = COALESCE(p_usd_brl_rate, usd_brl_rate),
margin_multiplier = COALESCE(p_margin_multiplier, margin_multiplier),
notes = COALESCE(v_notes, notes),
updated_at = now(),
updated_by = v_caller
WHERE id = true;
RETURN public.get_twilio_config();
END;
$function$;
REVOKE ALL ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.update_twilio_config(text, text, numeric, numeric, text) TO authenticated;
@@ -0,0 +1,34 @@
-- =============================================================================
-- Migration: 20260419000009_patient_session_counts_rpc
-- V#8 — Substitui o .limit(1000) arbitrário em PatientsListPage por RPC
-- agregada que retorna contagens por paciente (sempre atualizada, sem teto).
--
-- Tenant scoping é feito via WHERE tenant_id IN (memberships do caller),
-- consistente com a policy SELECT de agenda_eventos.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_session_counts(
p_patient_ids uuid[]
)
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
ae.patient_id,
COUNT(*)::int AS session_count,
MAX(ae.inicio_em) AS last_session_at
FROM public.agenda_eventos ae
WHERE ae.patient_id = ANY(p_patient_ids)
AND ae.tenant_id IN (
SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
)
GROUP BY ae.patient_id;
$function$;
REVOKE ALL ON FUNCTION public.get_patient_session_counts(uuid[]) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_session_counts(uuid[]) TO authenticated;
@@ -0,0 +1,304 @@
-- =============================================================================
-- Migration: 20260419000010_documents_security_hardening
-- Sessão 6 — revisão sênior de Documentos. Resolve V#43-V#49 (5 críticos/altos
-- + 2 médios). V#50-V#52 (portal-paciente, hash, retention) ficam pendentes
-- pra próxima sessão (precisam de design/decisão).
--
-- Path convention dos buckets: "{tenant_id}/{patient_id}/{timestamp}-{file}"
-- (storage.foldername(name))[1] = tenant_id
-- =============================================================================
-- Tabelas de documents são owned por supabase_admin
SET LOCAL ROLE supabase_admin;
-- ─────────────────────────────────────────────────────────────────────────
-- V#43 + V#44: storage.objects para buckets "documents" e "generated-docs"
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "documents: tenant member delete" ON storage.objects;
CREATE POLICY "documents: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "documents: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'documents'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "documents: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'documents'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "generated-docs: authenticated read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: authenticated delete" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member read" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member upload" ON storage.objects;
DROP POLICY IF EXISTS "generated-docs: tenant member delete" ON storage.objects;
CREATE POLICY "generated-docs: tenant member read" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'generated-docs'
AND (storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects
FOR DELETE TO authenticated
USING (
bucket_id = 'generated-docs'
AND (
public.is_saas_admin()
OR
(storage.foldername(name))[1]::uuid IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#45: documents — policies separadas por cmd
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "documents: owner full access" ON public.documents;
DROP POLICY IF EXISTS "documents: select" ON public.documents;
DROP POLICY IF EXISTS "documents: insert" ON public.documents;
DROP POLICY IF EXISTS "documents: update" ON public.documents;
DROP POLICY IF EXISTS "documents: delete" ON public.documents;
-- SELECT: owner OR tenant_member ativo OR saas_admin
CREATE POLICY "documents: select" ON public.documents
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: owner_id deve ser o caller, tenant_id deve ser tenant ativo do caller
CREATE POLICY "documents: insert" ON public.documents
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- UPDATE: só owner
CREATE POLICY "documents: update" ON public.documents
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
-- DELETE: só owner ou saas_admin
CREATE POLICY "documents: delete" ON public.documents
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#46: document_share_links — RPC validate_share_token + remover SELECT direto
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
sl document_share_links%ROWTYPE;
v_doc documents%ROWTYPE;
v_token text;
BEGIN
v_token := nullif(btrim(coalesce(p_token, '')), '');
IF v_token IS NULL THEN
RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
END IF;
SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
END IF;
IF sl.ativo IS NOT TRUE THEN
RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
END IF;
IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
END IF;
IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
END IF;
-- Incrementa uso atomicamente
UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;
-- Loga acesso (best-effort)
BEGIN
INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
FROM documents d WHERE d.id = sl.document_id;
EXCEPTION WHEN OTHERS THEN
-- não derruba a request se log falhar (schema pode variar)
NULL;
END;
SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;
RETURN jsonb_build_object(
'document_id', sl.document_id,
'bucket', v_doc.storage_bucket,
'bucket_path', v_doc.bucket_path,
'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type,
'tamanho_bytes', v_doc.tamanho_bytes
);
END;
$function$;
REVOKE ALL ON FUNCTION public.validate_share_token(text) FROM PUBLIC, authenticated;
GRANT EXECUTE ON FUNCTION public.validate_share_token(text) TO anon, authenticated, service_role;
-- Restringe SELECT direto da tabela: só criador (saas_admin via outra policy se necessário)
DROP POLICY IF EXISTS "dsl: public read by token" ON public.document_share_links;
DROP POLICY IF EXISTS "dsl: creator full access" ON public.document_share_links;
CREATE POLICY "dsl: creator full access" ON public.document_share_links
FOR ALL TO authenticated
USING (criado_por = auth.uid() OR public.is_saas_admin())
WITH CHECK (criado_por = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- V#47: document_signatures — separar SELECT/INSERT (tenant_member) vs UPDATE/DELETE (signatário)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "ds: tenant members access" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: select" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: insert" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: update" ON public.document_signatures;
DROP POLICY IF EXISTS "ds: delete" ON public.document_signatures;
CREATE POLICY "ds: select" ON public.document_signatures
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- INSERT: tenant_member pode criar; signatario_id (se preenchido) deve ser o caller
-- (paciente externo é signatario_tipo='paciente' com signatario_id NULL — a row
-- nasce sem assinatura e signatario_id é preenchido na aceitação via outro fluxo)
CREATE POLICY "ds: insert" ON public.document_signatures
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
AND (signatario_id IS NULL OR signatario_id = auth.uid())
);
-- UPDATE: só o signatário designado ou saas_admin (impede secretária forjar status='assinado')
CREATE POLICY "ds: update" ON public.document_signatures
FOR UPDATE TO authenticated
USING (signatario_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (signatario_id = auth.uid() OR public.is_saas_admin());
-- DELETE: signatário, saas_admin ou tenant_admin/owner
CREATE POLICY "ds: delete" ON public.document_signatures
FOR DELETE TO authenticated
USING (
signatario_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#48: document_access_logs — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dal: tenant members can insert" ON public.document_access_logs;
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#49: document_templates — INSERT com WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "dt: owner can insert" ON public.document_templates;
DROP POLICY IF EXISTS "dt: saas admin can insert global" ON public.document_templates;
CREATE POLICY "dt: owner can insert" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (
is_global = false
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates
FOR INSERT TO authenticated
WITH CHECK (is_global = true AND public.is_saas_admin());
@@ -0,0 +1,24 @@
-- =============================================================================
-- Migration: 20260419000011_documents_portal_patient_policy
-- V#50 — paciente vê documento via portal quando compartilhado_portal=true.
--
-- Adiciona policy SELECT ADICIONAL em documents (combina via OR com a policy
-- existente "documents: select"). Paciente conseguem ler documentos próprios
-- quando o terapeuta compartilhou via portal.
-- =============================================================================
DROP POLICY IF EXISTS "documents: portal patient read" ON public.documents;
CREATE POLICY "documents: portal patient read" ON public.documents
FOR SELECT TO authenticated
USING (
compartilhado_portal = true
AND patient_id IN (
SELECT p.id FROM public.patients p
WHERE p.user_id = auth.uid()
)
AND (expira_compartilhamento IS NULL OR expira_compartilhamento > now())
);
COMMENT ON POLICY "documents: portal patient read" ON public.documents IS
'V#50: paciente lê documento quando compartilhado_portal=true E patient_id pertence ao auth.uid + não expirou.';
@@ -0,0 +1,18 @@
-- =============================================================================
-- Migration: 20260419000012_documents_content_hash
-- V#51 — hash SHA-256 do conteúdo pra detecção de tampering.
--
-- Coluna nullable (documentos antigos não têm). Calculado client-side via
-- crypto.subtle.digest('SHA-256') antes do upload pro storage.
-- Integridade pode ser verificada baixando o arquivo e recalculando o hash.
-- =============================================================================
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS content_sha256 text;
CREATE INDEX IF NOT EXISTS idx_documents_content_sha256
ON public.documents (content_sha256)
WHERE content_sha256 IS NOT NULL;
COMMENT ON COLUMN public.documents.content_sha256 IS
'V#51: SHA-256 hex (64 chars) do conteúdo no momento do upload. Permite verificar integridade. NULL pra documentos legados pré-V#51.';
@@ -0,0 +1,65 @@
-- =============================================================================
-- Migration: 20260419000013_cron_retention_jobs
-- V#52 — retention automática de logs/challenges via pg_cron.
--
-- Jobs:
-- • document_access_logs_cleanup — diário, retém 1 ano (CFP típico)
-- • math_challenges_cleanup — horário, remove expirados há >1h
-- • public_submission_attempts_cleanup — diário, retém 90 dias
-- =============================================================================
-- Garante extensão (idempotente em ambientes que não têm)
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- ─────────────────────────────────────────────────────────────────────────
-- document_access_logs: retém 1 ano (suficiente pra auditoria CFP)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('document_access_logs_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'document_access_logs_cleanup');
SELECT cron.schedule(
'document_access_logs_cleanup',
'0 3 * * *', -- todo dia às 03:00
$$DELETE FROM public.document_access_logs WHERE created_at < now() - interval '1 year'$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- math_challenges: remove expirados (> 1h após expiração)
-- (RPC cleanup_expired_math_challenges já existe desde 20260419000007)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('math_challenges_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'math_challenges_cleanup');
SELECT cron.schedule(
'math_challenges_cleanup',
'0 * * * *', -- toda hora
$$SELECT public.cleanup_expired_math_challenges()$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- public_submission_attempts: retém 90 dias (analytics + alertas)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('public_submission_attempts_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'public_submission_attempts_cleanup');
SELECT cron.schedule(
'public_submission_attempts_cleanup',
'15 3 * * *', -- todo dia 03:15 (após o de docs)
$$DELETE FROM public.public_submission_attempts WHERE created_at < now() - interval '90 days'$$
);
-- ─────────────────────────────────────────────────────────────────────────
-- submission_rate_limits: limpa entradas antigas (>30 dias sem atividade)
-- (estados expirados não fazem mal, mas tabela cresce sem limite)
-- -----------------------------------------------------------------------------
SELECT cron.unschedule('submission_rate_limits_cleanup')
WHERE EXISTS (SELECT 1 FROM cron.job WHERE jobname = 'submission_rate_limits_cleanup');
SELECT cron.schedule(
'submission_rate_limits_cleanup',
'30 3 * * *', -- todo dia 03:30
$$DELETE FROM public.submission_rate_limits
WHERE last_attempt_at < now() - interval '30 days'
AND (blocked_until IS NULL OR blocked_until < now())
AND (requires_captcha_until IS NULL OR requires_captcha_until < now())$$
);
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260419000014_financial_security_hardening
-- Sessão 6 — revisão Financeiro. Resolve V#1-V#5 (2 críticos + 3 altos).
-- V#6-V#11 adiados (médios/baixos com plano).
--
-- Auditoria prévia confirmou:
-- • 0 financial_records com tenant_id NULL
-- • 0 records com clinic_fee_amount > amount
-- → seguro aplicar NOT NULL e CHECK constraints.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1: billing_contracts policy granular
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "billing_contracts: owner full access" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: select" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: insert" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: update" ON public.billing_contracts;
DROP POLICY IF EXISTS "billing_contracts: delete" ON public.billing_contracts;
CREATE POLICY "billing_contracts: select" ON public.billing_contracts
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "billing_contracts: update" ON public.billing_contracts
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#2: financial_records.tenant_id NOT NULL + trigger backfill
-- (auditoria: 0 órfãos, seguro aplicar)
-- -----------------------------------------------------------------------------
ALTER TABLE public.financial_records ALTER COLUMN tenant_id SET NOT NULL;
-- Trigger defensivo: se tentar inserir sem tenant_id, busca via owner_id->tenant_members
CREATE OR REPLACE FUNCTION public.financial_records_inject_tenant()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
SELECT tm.tenant_id INTO NEW.tenant_id
FROM public.tenant_members tm
WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
ORDER BY tm.created_at DESC
LIMIT 1;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_financial_records_inject_tenant ON public.financial_records;
CREATE TRIGGER trg_financial_records_inject_tenant
BEFORE INSERT ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
-- ─────────────────────────────────────────────────────────────────────────
-- V#5: financial_records CHECK contra net_amount negativo
-- -----------------------------------------------------------------------------
ALTER TABLE public.financial_records
DROP CONSTRAINT IF EXISTS financial_records_fee_lte_amount_chk;
ALTER TABLE public.financial_records
ADD CONSTRAINT financial_records_fee_lte_amount_chk
CHECK (clinic_fee_amount IS NULL OR (clinic_fee_amount >= 0 AND clinic_fee_amount <= amount));
-- ─────────────────────────────────────────────────────────────────────────
-- V#3: payment_settings — adicionar SELECT pra tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "payment_settings: tenant_admin read" ON public.payment_settings;
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings
FOR SELECT TO authenticated
USING (
tenant_id IS NOT NULL
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- (a policy ALL "owner full access" continua — owner mexe nos próprios)
-- ─────────────────────────────────────────────────────────────────────────
-- V#4: professional_pricing — adicionar SELECT pra tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "professional_pricing: tenant_admin read" ON public.professional_pricing;
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing
FOR SELECT TO authenticated
USING (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
@@ -0,0 +1,127 @@
-- =============================================================================
-- Migration: 20260419000015_communication_security_hardening
-- Sessão 6 — revisão Comunicação. Resolve V#1-V#5 (2 críticos + 3 altos).
-- V#6-V#10 adiados (médios/baixos com plano completo no DB).
--
-- 🔴 V#1+V#2 são bugs P0: policies usavam (tenant_id = auth.uid()) — comparação
-- de UUID de tenant com UUID de user. Tabelas inacessíveis na prática.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1: email_layout_config — fix BUG do tenant_id = auth.uid()
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "tenant owns email layout config" ON public.email_layout_config;
DROP POLICY IF EXISTS "email_layout_config: tenant_admin all" ON public.email_layout_config;
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#2: email_templates_tenant — MESMO bug
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "tenant manages own overrides" ON public.email_templates_tenant;
DROP POLICY IF EXISTS "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant;
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant
FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#3: notification_logs — SELECT pra tenant_member
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notif_logs_tenant_member" ON public.notification_logs;
CREATE POLICY "notif_logs_tenant_member" ON public.notification_logs
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#4: notification_queue — SELECT pra tenant_member
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notif_queue_tenant_member" ON public.notification_queue;
CREATE POLICY "notif_queue_tenant_member" ON public.notification_queue
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#5: notification_channels — SELECT pra tenant_member; INSERT tenant_admin; UPDATE/DELETE owner
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "notification_channels_owner" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_select" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
DROP POLICY IF EXISTS "notif_channels_modify" ON public.notification_channels;
CREATE POLICY "notif_channels_select" ON public.notification_channels
FOR SELECT TO authenticated
USING (
deleted_at IS NULL
AND (
public.is_saas_admin()
OR owner_id = auth.uid()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
CREATE POLICY "notif_channels_insert" ON public.notification_channels
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "notif_channels_modify" ON public.notification_channels
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
CREATE POLICY "notif_channels_delete" ON public.notification_channels
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
@@ -0,0 +1,157 @@
-- =============================================================================
-- Migration: 20260419000016_tenants_calendario_hardening
-- Sessão 7 — Tenants + Calendário scan (corrige críticos + altos + WITH CHECKs).
--
-- Resolve:
-- • Tenants V#1 (P0) — tenant_invites RLS off + 0 policies
-- • Tenants V#2 — profiles_insert_own sem WITH CHECK
-- • Tenants V#3 — support_sessions_saas_insert sem WITH CHECK
-- • Tenants V#6 — user_settings_insert_own sem WITH CHECK
-- • Calendário V#1 — feriados_insert + feriados_saas_insert sem WITH CHECK
--
-- Auditoria prévia: tenant_invites tem 0 rows (seguro habilitar RLS sem
-- migração de dados).
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#1 (P0): tenant_invites
-- -----------------------------------------------------------------------------
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.tenant_invites FROM anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.tenant_invites TO authenticated;
-- SELECT: tenant_admin/admin/owner do tenant + saas_admin
DROP POLICY IF EXISTS tenant_invites_select ON public.tenant_invites;
CREATE POLICY tenant_invites_select ON public.tenant_invites
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- INSERT: só tenant_admin do tenant_id, e invited_by deve ser o caller
DROP POLICY IF EXISTS tenant_invites_insert ON public.tenant_invites;
CREATE POLICY tenant_invites_insert ON public.tenant_invites
FOR INSERT TO authenticated
WITH CHECK (
invited_by = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- UPDATE: só revogação por tenant_admin do tenant. Aceitar é via RPC tenant_accept_invite (SECURITY DEFINER).
DROP POLICY IF EXISTS tenant_invites_update ON public.tenant_invites;
CREATE POLICY tenant_invites_update ON public.tenant_invites
FOR UPDATE TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
)
WITH CHECK (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
-- DELETE: tenant_admin OR saas_admin
DROP POLICY IF EXISTS tenant_invites_delete ON public.tenant_invites;
CREATE POLICY tenant_invites_delete ON public.tenant_invites
FOR DELETE TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
)
);
COMMENT ON TABLE public.tenant_invites IS
'Convites pra entrar em tenant. Aceitar deve ser via RPC tenant_accept_invite (SECURITY DEFINER). Criar/revogar via UI por tenant_admin.';
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#2: profiles INSERT WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS profiles_insert_own ON public.profiles;
CREATE POLICY profiles_insert_own ON public.profiles
FOR INSERT TO authenticated
WITH CHECK (id = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#3: support_sessions INSERT WITH CHECK
-- (admin_id deve ser o caller E o caller deve ser saas_admin)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS support_sessions_saas_insert ON public.support_sessions;
CREATE POLICY support_sessions_saas_insert ON public.support_sessions
FOR INSERT TO authenticated
WITH CHECK (
admin_id = auth.uid()
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Tenants V#6: user_settings INSERT WITH CHECK
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS user_settings_insert_own ON public.user_settings;
CREATE POLICY user_settings_insert_own ON public.user_settings
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
-- ─────────────────────────────────────────────────────────────────────────
-- Calendário V#1: feriados INSERT WITH CHECK (tenant + global)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS feriados_insert ON public.feriados;
CREATE POLICY feriados_insert ON public.feriados
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS feriados_saas_insert ON public.feriados;
CREATE POLICY feriados_saas_insert ON public.feriados
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IS NULL
AND EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Calendário V#2: feriados DELETE — adicionar tenant_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS feriados_delete ON public.feriados;
CREATE POLICY feriados_delete ON public.feriados
FOR DELETE TO authenticated
USING (
owner_id = auth.uid()
OR (tenant_id IS NOT NULL AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND tm.role IN ('tenant_admin','admin','owner')
))
);
@@ -0,0 +1,65 @@
-- =============================================================================
-- Migration: 20260419000017_addons_central_saas_hardening
-- Sessão 8 — Addons + Central SaaS scan.
--
-- Resolve:
-- • Addons V#1 (CRÍTICO — dinheiro real): addon_transactions sem WITH CHECK
-- • Addons V#2: addon_credits sem CHECK contra saldo negativo
-- • Central SaaS V#1: saas_faq write permite tenant_admin/clinic_admin
--
-- Auditoria prévia: 0 addon_credits com balance < 0 (seguro CHECK).
-- Edge functions consomem créditos via service_role (bypass RLS) — nova
-- restrição não quebra pipeline.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- Addons V#1: addon_transactions INSERT WITH CHECK (saas_admin only)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS addon_transactions_admin_insert ON public.addon_transactions;
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (SELECT 1 FROM public.saas_admins sa WHERE sa.user_id = auth.uid())
);
-- ─────────────────────────────────────────────────────────────────────────
-- Addons V#2: addon_credits CHECK contra saldo negativo
-- -----------------------------------------------------------------------------
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_balance_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_balance_nonneg_chk
CHECK (balance >= 0);
-- Aproveita: total_consumed também não deve ser negativo
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_consumed_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_consumed_nonneg_chk
CHECK (total_consumed >= 0);
ALTER TABLE public.addon_credits
DROP CONSTRAINT IF EXISTS addon_credits_purchased_nonneg_chk;
ALTER TABLE public.addon_credits
ADD CONSTRAINT addon_credits_purchased_nonneg_chk
CHECK (total_purchased >= 0);
-- ─────────────────────────────────────────────────────────────────────────
-- Central SaaS V#1: saas_faq + saas_faq_itens write SÓ saas_admin
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS faq_admin_write ON public.saas_faq;
CREATE POLICY faq_saas_admin_write ON public.saas_faq
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
DROP POLICY IF EXISTS faq_itens_admin_write ON public.saas_faq_itens;
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens
FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- (Policies de leitura — faq_auth_read, faq_public_read, faq_itens_auth_read — permanecem)
@@ -0,0 +1,223 @@
-- =============================================================================
-- Migration: 20260419000018_servicos_prontuarios_hardening
-- Sessão 9 — Serviços/Prontuários scan.
--
-- Resolve:
-- • Serviços V#1+V#2 (CRÍTICOS): silos por owner em services/medicos/insurance_plans
-- • Serviços V#3+V#4 (ALTOS): cascade silos em commitment_services/insurance_plan_services
-- • Serviços V#5: WITH CHECK ausente em commitment_time_logs/determined_*
--
-- Padrão validado em 5 áreas anteriores (Documentos/Financeiro/Comunicação/etc):
-- SELECT tenant_member, INSERT/UPDATE/DELETE owner+saas, com WITH CHECK explícito.
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- V#1 services — split em 4 policies
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "services: owner full access" ON public.services;
DROP POLICY IF EXISTS "services: select" ON public.services;
DROP POLICY IF EXISTS "services: insert" ON public.services;
DROP POLICY IF EXISTS "services: update" ON public.services;
DROP POLICY IF EXISTS "services: delete" ON public.services;
CREATE POLICY "services: select" ON public.services
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "services: insert" ON public.services
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "services: update" ON public.services
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
CREATE POLICY "services: delete" ON public.services
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#2 medicos — mesmo padrão
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "medicos: owner full access" ON public.medicos;
DROP POLICY IF EXISTS "medicos: select" ON public.medicos;
DROP POLICY IF EXISTS "medicos: insert" ON public.medicos;
DROP POLICY IF EXISTS "medicos: update" ON public.medicos;
DROP POLICY IF EXISTS "medicos: delete" ON public.medicos;
CREATE POLICY "medicos: select" ON public.medicos
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "medicos: insert" ON public.medicos
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "medicos: update" ON public.medicos
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
CREATE POLICY "medicos: delete" ON public.medicos
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#1 (parte 2) insurance_plans — mesmo padrão
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: select" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: insert" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: update" ON public.insurance_plans;
DROP POLICY IF EXISTS "insurance_plans: delete" ON public.insurance_plans;
CREATE POLICY "insurance_plans: select" ON public.insurance_plans
FOR SELECT TO authenticated
USING (
owner_id = auth.uid()
OR public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans
FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "insurance_plans: update" ON public.insurance_plans
FOR UPDATE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin())
WITH CHECK (owner_id = auth.uid() OR public.is_saas_admin());
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans
FOR DELETE TO authenticated
USING (owner_id = auth.uid() OR public.is_saas_admin());
-- ─────────────────────────────────────────────────────────────────────────
-- V#3 commitment_services — cascade via JOIN com services.tenant_id
-- (tabela N:N sem tenant_id próprio; herda do services pai)
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "commitment_services: owner full access" ON public.commitment_services;
DROP POLICY IF EXISTS "commitment_services: tenant_member" ON public.commitment_services;
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.services s
WHERE s.id = commitment_services.service_id
AND (
s.owner_id = auth.uid()
OR public.is_saas_admin()
OR s.tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.services s
WHERE s.id = commitment_services.service_id
AND (s.owner_id = auth.uid() OR public.is_saas_admin())
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#4 insurance_plan_services — cascade via JOIN com insurance_plans
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
DROP POLICY IF EXISTS "insurance_plan_services: tenant_member" ON public.insurance_plan_services;
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.insurance_plans ip
WHERE ip.id = insurance_plan_services.insurance_plan_id
AND (
ip.owner_id = auth.uid()
OR public.is_saas_admin()
OR ip.tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.insurance_plans ip
WHERE ip.id = insurance_plan_services.insurance_plan_id
AND (ip.owner_id = auth.uid() OR public.is_saas_admin())
)
);
-- ─────────────────────────────────────────────────────────────────────────
-- V#5 — adicionar WITH CHECK em INSERT das 3 tabelas que não tinham
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS ctl_insert_for_active_member ON public.commitment_time_logs;
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS dcf_insert_for_active_member ON public.determined_commitment_fields;
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS dc_insert_for_active_member ON public.determined_commitments;
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments
FOR INSERT TO authenticated
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
@@ -0,0 +1,107 @@
-- =============================================================================
-- Migration: 20260420000001_patient_intake_invite_info_rpc
-- A#31 — RPC read-only de lookup público do terapeuta/clínica a partir do
-- token do patient_invite. Consumida pela edge function get-intake-invite-info
-- para alimentar o "hero header" da página /cadastro/paciente.
--
-- Segurança:
-- • SECURITY DEFINER (ignora RLS de profiles/company_profiles)
-- • Valida token: existe, ativo, não-expirado, dentro do max_uses
-- • Retorna APENAS campos explicitamente seguros (não-sensíveis)
-- • Execute revogado de PUBLIC/anon; grantado só para service_role (edge)
-- e authenticated (usos internos futuros)
--
-- Payload devolvido:
-- { ok: true, info: { therapist: {...}, clinic: {...}|null } }
-- { error: 'invalid-token' } — token inválido/expirado/esgotado
-- { error: 'missing-token' } — input vazio
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_intake_invite_info(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_token_clean text;
v_invite RECORD;
v_result jsonb;
BEGIN
v_token_clean := nullif(trim(coalesce(p_token, '')), '');
IF v_token_clean IS NULL THEN
RETURN jsonb_build_object('error', 'missing-token');
END IF;
SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
INTO v_invite
FROM public.patient_invites pi
WHERE pi.token = v_token_clean
LIMIT 1;
IF v_invite.owner_id IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.active IS DISTINCT FROM true THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
SELECT jsonb_build_object(
'therapist', jsonb_build_object(
'display_name', coalesce(
nullif(trim(p.full_name), ''),
nullif(trim(p.nickname), ''),
'Profissional'
),
'avatar_url', nullif(trim(coalesce(p.avatar_url, '')), ''),
'work_description', nullif(trim(coalesce(p.work_description, '')), ''),
'bio', nullif(trim(coalesce(p.bio, '')), ''),
'phone', nullif(trim(coalesce(p.phone, '')), ''),
'site_url', nullif(trim(coalesce(p.site_url, '')), ''),
'instagram', nullif(trim(coalesce(p.social_instagram, '')), '')
),
'clinic', CASE
WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
'name', nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
'logo_url', nullif(trim(coalesce(cp.logo_url, '')), ''),
'email', nullif(trim(coalesce(cp.email, '')), ''),
'phone', nullif(trim(coalesce(cp.telefone, '')), ''),
'site', nullif(trim(coalesce(cp.site, '')), ''),
'city', nullif(trim(coalesce(cp.cidade, '')), ''),
'state', nullif(trim(coalesce(cp.estado, '')), ''),
'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
'street', nullif(trim(coalesce(cp.logradouro, '')), ''),
'number', nullif(trim(coalesce(cp.numero, '')), ''),
'social', coalesce(cp.redes_sociais, '[]'::jsonb)
)
ELSE NULL
END
)
INTO v_result
FROM public.profiles p
LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
WHERE p.id = v_invite.owner_id
LIMIT 1;
IF v_result IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
RETURN jsonb_build_object('ok', true, 'info', v_result);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) TO authenticated, service_role;
COMMENT ON FUNCTION public.get_patient_intake_invite_info(text) IS
'A#31 — Lookup público read-only (via edge function) dos dados de apresentação do terapeuta/clínica dono do link de cadastro externo. Só retorna campos não-sensíveis.';
@@ -0,0 +1,199 @@
-- =============================================================================
-- Migration: 20260420000002_audit_logs_lgpd
-- Sessao 11 - Fase 2a (Opcao C).
--
-- Resolve: LGPD Art. 37 - registro das operacoes de tratamento.
-- Projeto ja tinha logs pontuais (document_access_logs, patient_status_history,
-- notification_logs, addon_transactions) mas nao registrava:
-- - Edicao de dados do paciente (nome/CPF/endereco)
-- - CRUD de sessoes na agenda
-- - CRUD de registros financeiros
-- - CRUD de documentos (metadata)
-- - Mudancas de permissao / members do tenant
--
-- Cria tabela audit_logs imutavel + funcao trigger generica + triggers nas
-- tabelas criticas. RLS: tenant member le; ninguem INSERT/UPDATE/DELETE direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela audit_logs
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
action TEXT NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
ON public.audit_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
ON public.audit_logs (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
ON public.audit_logs (user_id, created_at DESC) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_fields
ON public.audit_logs USING GIN (changed_fields);
COMMENT ON TABLE public.audit_logs IS
'Registro imutavel de operacoes de tratamento (LGPD Art. 37). INSERT apenas via trigger SECURITY DEFINER.';
-- ---------------------------------------------------------------------------
-- Funcao trigger generica
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_tenant_id UUID;
v_entity_id TEXT;
v_old JSONB;
v_new JSONB;
v_changed TEXT[];
v_heavy_fields TEXT[] := ARRAY[
'content', 'content_html', 'content_json', 'raw_data',
'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
];
v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
BEGIN
IF TG_OP = 'DELETE' THEN
v_tenant_id := OLD.tenant_id;
v_entity_id := OLD.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := NULL;
v_new := to_jsonb(NEW) - v_heavy_fields;
ELSE -- UPDATE
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := to_jsonb(NEW) - v_heavy_fields;
-- calcular campos realmente alterados
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
-- se nada mudou, ignora
IF v_changed IS NULL THEN
RETURN NEW;
END IF;
-- se mudou apenas campos de ruido (ex: updated_at), ignora
IF v_changed <@ v_noise_fields THEN
RETURN NEW;
END IF;
END IF;
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields
) VALUES (
v_tenant_id,
auth.uid(),
TG_TABLE_NAME,
v_entity_id,
lower(TG_OP),
v_old,
v_new,
v_changed
);
RETURN COALESCE(NEW, OLD);
END;
$$;
COMMENT ON FUNCTION public.log_audit_change() IS
'Trigger generica de audit. Filtra campos pesados (content, signature_data) e ruido (updated_at).';
-- ---------------------------------------------------------------------------
-- Triggers nas tabelas criticas
-- ---------------------------------------------------------------------------
-- patients
DROP TRIGGER IF EXISTS trg_audit_patients ON public.patients;
CREATE TRIGGER trg_audit_patients
AFTER INSERT OR UPDATE OR DELETE ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- agenda_eventos
DROP TRIGGER IF EXISTS trg_audit_agenda_eventos ON public.agenda_eventos;
CREATE TRIGGER trg_audit_agenda_eventos
AFTER INSERT OR UPDATE OR DELETE ON public.agenda_eventos
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- financial_records
DROP TRIGGER IF EXISTS trg_audit_financial_records ON public.financial_records;
CREATE TRIGGER trg_audit_financial_records
AFTER INSERT OR UPDATE OR DELETE ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- documents
DROP TRIGGER IF EXISTS trg_audit_documents ON public.documents;
CREATE TRIGGER trg_audit_documents
AFTER INSERT OR UPDATE OR DELETE ON public.documents
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- tenant_members (mudanca de permissao)
DROP TRIGGER IF EXISTS trg_audit_tenant_members ON public.tenant_members;
CREATE TRIGGER trg_audit_tenant_members
AFTER INSERT OR UPDATE OR DELETE ON public.tenant_members
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; saas_admin le tudo; ninguem escreve direto
-- ---------------------------------------------------------------------------
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "audit_logs: select tenant" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: saas_admin all" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct insert" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct update" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct delete" ON public.audit_logs;
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Explicitamente NEGA insert/update/delete via API
-- (SECURITY DEFINER na funcao trigger bypassa RLS; app nao consegue escrever direto)
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs
FOR UPDATE TO authenticated
USING (false) WITH CHECK (false);
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs
FOR DELETE TO authenticated
USING (false);
-- ---------------------------------------------------------------------------
-- Marca hardening na auditoria
-- ---------------------------------------------------------------------------
COMMENT ON COLUMN public.audit_logs.old_values IS 'Estado anterior (jsonb); NULL em INSERT; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.new_values IS 'Estado posterior (jsonb); NULL em DELETE; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.changed_fields IS 'Lista de campos alterados em UPDATE (NULL em INSERT/DELETE)';
@@ -0,0 +1,148 @@
-- =============================================================================
-- Migration: 20260420000003_audit_logs_unified_view
-- Sessao 11 - Fase 2a (Opcao C).
--
-- View audit_log_unified que junta:
-- - audit_logs (nova, trigger generico em patients/agenda/etc)
-- - document_access_logs (visualizacao/download/impressao de documento)
-- - patient_status_history (mudancas de status de paciente)
-- - notification_logs (envio de SMS/email/WhatsApp)
-- - addon_transactions (compras/consumos de recursos extras)
--
-- RLS: aplica-se das tabelas base. View usa security_invoker para herdar.
-- =============================================================================
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
CREATE VIEW public.audit_log_unified
WITH (security_invoker = true)
AS
-- 1) audit_logs (trigger generico)
SELECT
'audit:' || al.id::text AS uid,
al.tenant_id AS tenant_id,
al.user_id AS user_id,
al.entity_type AS entity_type,
al.entity_id AS entity_id,
al.action AS action,
CASE al.action
WHEN 'insert' THEN 'Criou ' || al.entity_type
WHEN 'update' THEN 'Alterou ' || al.entity_type
|| COALESCE(' (' || array_to_string(al.changed_fields, ', ') || ')', '')
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
END AS description,
al.created_at AS occurred_at,
'audit_logs' AS source,
jsonb_build_object(
'old_values', al.old_values,
'new_values', al.new_values,
'changed_fields', al.changed_fields
) AS details
FROM public.audit_logs al
UNION ALL
-- 2) document_access_logs
SELECT
'doc_access:' || dal.id::text,
dal.tenant_id,
dal.user_id,
'document' AS entity_type,
dal.documento_id::text AS entity_id,
dal.acao AS action,
CASE dal.acao
WHEN 'visualizou' THEN 'Visualizou documento'
WHEN 'baixou' THEN 'Baixou documento'
WHEN 'imprimiu' THEN 'Imprimiu documento'
WHEN 'compartilhou' THEN 'Compartilhou documento'
WHEN 'assinou' THEN 'Assinou documento'
ELSE dal.acao
END AS description,
dal.acessado_em AS occurred_at,
'document_access_logs' AS source,
jsonb_build_object(
'ip', dal.ip::text,
'user_agent', dal.user_agent
) AS details
FROM public.document_access_logs dal
UNION ALL
-- 3) patient_status_history
SELECT
'psh:' || psh.id::text,
psh.tenant_id,
psh.alterado_por,
'patient_status' AS entity_type,
psh.patient_id::text AS entity_id,
'status_change' AS action,
'Status do paciente: '
|| COALESCE(psh.status_anterior, '') || '' || psh.status_novo
|| COALESCE(' (' || psh.motivo || ')', '') AS description,
psh.alterado_em AS occurred_at,
'patient_status_history' AS source,
jsonb_build_object(
'status_anterior', psh.status_anterior,
'status_novo', psh.status_novo,
'motivo', psh.motivo,
'encaminhado_para', psh.encaminhado_para,
'data_saida', psh.data_saida
) AS details
FROM public.patient_status_history psh
UNION ALL
-- 4) notification_logs
SELECT
'notif:' || nl.id::text,
nl.tenant_id,
nl.owner_id AS user_id,
'notification' AS entity_type,
nl.patient_id::text AS entity_id,
nl.status AS action,
'Notificação ' || nl.channel || ' '
|| nl.status
|| COALESCE(' para ' || nl.recipient_address, '') AS description,
nl.created_at AS occurred_at,
'notification_logs' AS source,
jsonb_build_object(
'channel', nl.channel,
'template_key', nl.template_key,
'status', nl.status,
'provider', nl.provider,
'failure_reason', nl.failure_reason
) AS details
FROM public.notification_logs nl
UNION ALL
-- 5) addon_transactions
SELECT
'addon:' || at.id::text,
at.tenant_id,
at.admin_user_id AS user_id,
'addon_transaction' AS entity_type,
at.id::text AS entity_id,
at.type AS action,
CASE at.type
WHEN 'purchase' THEN 'Compra de ' || at.amount || ' créditos de ' || at.addon_type
WHEN 'consumption' THEN 'Consumo de ' || abs(at.amount) || ' crédito(s) ' || at.addon_type
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
WHEN 'refund' THEN 'Reembolso de ' || abs(at.amount) || ' créditos ' || at.addon_type
ELSE at.type || ' ' || at.addon_type
END AS description,
at.created_at AS occurred_at,
'addon_transactions' AS source,
jsonb_build_object(
'addon_type', at.addon_type,
'amount', at.amount,
'balance_after', at.balance_after,
'price_cents', at.price_cents,
'payment_reference', at.payment_reference
) AS details
FROM public.addon_transactions at;
COMMENT ON VIEW public.audit_log_unified IS
'Timeline unificada de eventos auditaveis (LGPD). Herda RLS das tabelas base via security_invoker.';
GRANT SELECT ON public.audit_log_unified TO authenticated;
@@ -0,0 +1,225 @@
-- =============================================================================
-- Migration: 20260420000004_lgpd_export_patient_rpc
-- Sessao 11 - Fase 2b (Opcao C).
--
-- Implementa LGPD Art. 18 - direito de portabilidade do titular.
-- RPC export_patient_data(p_patient_id uuid) retorna jsonb com todos os dados
-- relacionados ao paciente. Registra o evento em audit_logs para rastreabilidade.
--
-- Seguranca:
-- - SECURITY DEFINER permite agregar tabelas diversas bypassando RLS
-- - Verificacao explicita: caller deve ser tenant_member ativo da clinica do paciente
-- - Proibido acesso cross-tenant
-- =============================================================================
CREATE OR REPLACE FUNCTION public.export_patient_data(p_patient_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_patient RECORD;
v_tenant_id UUID;
v_caller UUID;
v_is_member BOOLEAN;
v_result JSONB;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
END IF;
-- carrega paciente
SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
END IF;
v_tenant_id := v_patient.tenant_id;
-- verifica se caller e membro ativo do tenant do paciente
SELECT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
) OR public.is_saas_admin() INTO v_is_member;
IF NOT v_is_member THEN
RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
END IF;
-- monta o payload
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object(
'generated_at', now(),
'generated_by', v_caller,
'tenant_id', v_tenant_id,
'patient_id', p_patient_id,
'lgpd_basis', 'Art. 18, II - portabilidade dos dados do titular',
'controller', 'AgenciaPSI - Clinica responsavel',
'format_version', '1.0'
),
'paciente', to_jsonb(v_patient),
'contatos', COALESCE((
SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at)
FROM public.patient_contacts pc
WHERE pc.patient_id = p_patient_id
), '[]'::jsonb),
'contatos_apoio', COALESCE((
SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at)
FROM public.patient_support_contacts psc
WHERE psc.patient_id = p_patient_id
), '[]'::jsonb),
'historico_status', COALESCE((
SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em)
FROM public.patient_status_history psh
WHERE psh.patient_id = p_patient_id
), '[]'::jsonb),
'timeline', COALESCE((
SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em)
FROM public.patient_timeline pt
WHERE pt.patient_id = p_patient_id
), '[]'::jsonb),
'descontos', COALESCE((
SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at)
FROM public.patient_discounts pd
WHERE pd.patient_id = p_patient_id
), '[]'::jsonb),
'eventos_agenda', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', ae.id,
'tipo', ae.tipo,
'inicio_em', ae.inicio_em,
'fim_em', ae.fim_em,
'status', ae.status,
'observacoes', ae.observacoes,
'created_at', ae.created_at
) ORDER BY ae.inicio_em
)
FROM public.agenda_eventos ae
WHERE ae.patient_id = p_patient_id
), '[]'::jsonb),
'registros_financeiros', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', fr.id,
'amount', fr.amount,
'discount_amount', fr.discount_amount,
'final_amount', fr.final_amount,
'status', fr.status,
'due_date', fr.due_date,
'paid_at', fr.paid_at,
'payment_method', fr.payment_method,
'notes', fr.notes,
'created_at', fr.created_at
) ORDER BY fr.created_at
)
FROM public.financial_records fr
WHERE fr.patient_id = p_patient_id
), '[]'::jsonb),
'documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', d.id,
'nome_original', d.nome_original,
'tipo_documento', d.tipo_documento,
'categoria', d.categoria,
'descricao', d.descricao,
'mime_type', d.mime_type,
'tamanho_bytes', d.tamanho_bytes,
'status_revisao', d.status_revisao,
'visibilidade', d.visibilidade,
'uploaded_at', d.uploaded_at,
'created_at', d.created_at
) ORDER BY d.created_at
)
FROM public.documents d
WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
), '[]'::jsonb),
'notificacoes_enviadas', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', nl.id,
'channel', nl.channel,
'template_key', nl.template_key,
'recipient_address', nl.recipient_address,
'status', nl.status,
'sent_at', nl.sent_at,
'delivered_at', nl.delivered_at,
'read_at', nl.read_at,
'failure_reason', nl.failure_reason,
'created_at', nl.created_at
) ORDER BY nl.created_at
)
FROM public.notification_logs nl
WHERE nl.patient_id = p_patient_id
), '[]'::jsonb),
'audit_trail', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', al.id,
'action', al.action,
'entity_type', al.entity_type,
'changed_fields', al.changed_fields,
'user_id', al.user_id,
'created_at', al.created_at
) ORDER BY al.created_at
)
FROM public.audit_logs al
WHERE al.tenant_id = v_tenant_id
AND al.entity_type = 'patients'
AND al.entity_id = p_patient_id::text
), '[]'::jsonb),
'acessos_a_documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', dal.id,
'documento_id', dal.documento_id,
'acao', dal.acao,
'user_id', dal.user_id,
'acessado_em', dal.acessado_em
) ORDER BY dal.acessado_em
)
FROM public.document_access_logs dal
WHERE dal.documento_id IN (
SELECT id FROM public.documents WHERE patient_id = p_patient_id
)
), '[]'::jsonb),
'grupos', COALESCE((
SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
FROM public.patient_group_patient pgp
WHERE pgp.patient_id = p_patient_id
), '[]'::jsonb),
'tags', COALESCE((
SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
FROM public.patient_patient_tag ppt
WHERE ppt.patient_id = p_patient_id
), '[]'::jsonb)
);
-- registra o export como evento auditavel
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields, metadata
) VALUES (
v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
NULL, NULL, ARRAY['__lgpd_export__'],
jsonb_build_object(
'action_kind', 'lgpd_export',
'lgpd_basis', 'Art. 18, II',
'patient_name', v_patient.nome_completo
)
);
RETURN v_result;
END;
$$;
COMMENT ON FUNCTION public.export_patient_data(UUID) IS
'LGPD Art. 18, II - exporta todos os dados do paciente em jsonb portavel. Registra evento em audit_logs.';
REVOKE ALL ON FUNCTION public.export_patient_data(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.export_patient_data(UUID) TO authenticated;
@@ -0,0 +1,263 @@
-- =============================================================================
-- Migration: 20260420000005_conversation_messages
-- Sessao 11 - Fase 5a (CRM de WhatsApp / inbox).
--
-- Cria infraestrutura para receber mensagens inbound de WhatsApp (Twilio e
-- Evolution API) e exibir num Kanban de conversas.
--
-- - conversation_messages — todas as mensagens (in/out) com link opcional
-- ao paciente via telefone matching
-- - function match_patient_by_phone(tenant_id, phone) — encontra paciente
-- - view conversation_threads — agregado por paciente/numero pra UI Kanban
--
-- RLS: tenant members leem; service_role (edge function) escreve via SECURITY
-- DEFINER match_and_insert. App nao escreve direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela de mensagens
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_messages (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
channel TEXT NOT NULL CHECK (channel IN ('whatsapp', 'sms', 'email')),
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
from_number TEXT,
to_number TEXT,
body TEXT,
media_url TEXT,
media_mime TEXT,
provider TEXT NOT NULL CHECK (provider IN ('twilio', 'evolution', 'manual')),
provider_message_id TEXT,
provider_raw JSONB,
-- estado Kanban
kanban_status TEXT NOT NULL DEFAULT 'awaiting_us'
CHECK (kanban_status IN ('urgent', 'awaiting_us', 'awaiting_patient', 'resolved')),
priority INT NOT NULL DEFAULT 0,
read_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_msg_tenant_created
ON public.conversation_messages (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_patient
ON public.conversation_messages (patient_id, created_at DESC) WHERE patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_msg_from_number
ON public.conversation_messages (tenant_id, from_number);
CREATE INDEX IF NOT EXISTS idx_conv_msg_kanban
ON public.conversation_messages (tenant_id, kanban_status, priority DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_provider_msg_id
ON public.conversation_messages (provider_message_id) WHERE provider_message_id IS NOT NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_messages_updated_at ON public.conversation_messages;
CREATE TRIGGER trg_conv_messages_updated_at
BEFORE UPDATE ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_messages IS
'Mensagens in/out de WhatsApp/SMS/email. Timeline de conversas do tenant com pacientes.';
-- ---------------------------------------------------------------------------
-- Funcao: normaliza telefone BR (remove tudo que nao seja digito, tira DDI 55)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.normalize_phone_br(p_phone TEXT)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_digits TEXT;
BEGIN
IF p_phone IS NULL THEN RETURN NULL; END IF;
-- remove tudo que nao seja digito
v_digits := regexp_replace(p_phone, '\D', '', 'g');
-- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
v_digits := substr(v_digits, 3);
END IF;
-- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
IF length(v_digits) > 11 THEN
v_digits := right(v_digits, 11);
END IF;
RETURN v_digits;
END;
$$;
COMMENT ON FUNCTION public.normalize_phone_br(TEXT) IS
'Normaliza telefone BR para os ultimos 11 digitos (DDD+numero), removendo DDI +55 e formatacao.';
-- ---------------------------------------------------------------------------
-- Funcao: match paciente por telefone dentro de um tenant
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id UUID, p_phone TEXT)
RETURNS UUID
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_normalized TEXT;
v_patient_id UUID;
BEGIN
v_normalized := public.normalize_phone_br(p_phone);
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN
RETURN NULL;
END IF;
-- prioridade: telefone principal, depois alternativo, depois responsavel
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_alternativo) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_responsavel) = v_normalized
LIMIT 1;
RETURN v_patient_id;
END;
$$;
COMMENT ON FUNCTION public.match_patient_by_phone(UUID, TEXT) IS
'Encontra patient_id do tenant cujo telefone (principal/alternativo/responsavel) bate com o numero.';
-- ---------------------------------------------------------------------------
-- View: threads agrupadas por paciente ou numero anonimo
-- ---------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown'
)) AS thread_key
FROM public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status
FROM latest l
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
LEFT JOIN public.patients p ON p.id = l.patient_id;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou por numero anonimo. Base do Kanban.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; ninguem escreve direto (so via edge function service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_msg: select tenant" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: update kanban" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct insert" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct delete" ON public.conversation_messages;
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- tenant member pode atualizar apenas kanban_status/read_at/responded_at/resolved_at
-- (nao pode mexer em body, provider, etc)
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages
FOR UPDATE TO authenticated
USING (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages
FOR DELETE TO authenticated
USING (false);
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260420000005_search_global_rpc
-- Busca global do topbar — RPC única que retorna resultados agrupados por
-- entidade (pacientes, agendamentos, documentos, serviços).
--
-- Segurança:
-- • SECURITY INVOKER → respeita RLS do chamador (terapeuta vê só os dele,
-- clínica vê do tenant, saas_admin vê global). Sem reinvenção de permissão.
-- • GRANT apenas para `authenticated` (paciente anônimo não tem busca global).
--
-- Índices trigram:
-- • patients(nome_completo, email_principal, cpf)
-- • services(name)
-- • agenda_eventos(titulo, titulo_custom)
-- • documents(nome_original) — já existe em 06_indexes/indexes.sql (skip)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Índices trigram (GIN) pra ILIKE/similarity performarem
-- pg_trgm instalado em schema `extensions`; ops class vive em `public`.
-- -----------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_patients_nome_trgm
ON public.patients USING gin (nome_completo public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_patients_email_trgm
ON public.patients USING gin (email_principal public.gin_trgm_ops)
WHERE email_principal IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patients_cpf_trgm
ON public.patients USING gin (cpf public.gin_trgm_ops)
WHERE cpf IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_services_name_trgm
ON public.services USING gin (name public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_trgm
ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops)
WHERE titulo IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_custom_trgm
ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops)
WHERE titulo_custom IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patient_intake_requests_nome_trgm
ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops)
WHERE status = 'new';
-- -----------------------------------------------------------------------------
-- RPC principal
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.search_global(
p_q text,
p_scope text[] DEFAULT NULL,
p_limit int DEFAULT 8
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
STABLE
SET search_path = public, pg_temp
AS $$
DECLARE
v_q text;
v_pattern text;
v_limit int;
v_patients jsonb := '[]'::jsonb;
v_appointments jsonb := '[]'::jsonb;
v_documents jsonb := '[]'::jsonb;
v_services jsonb := '[]'::jsonb;
v_intakes jsonb := '[]'::jsonb;
BEGIN
-- Sanitize + length guards
v_q := nullif(btrim(coalesce(p_q, '')), '');
IF v_q IS NULL OR length(v_q) < 2 THEN
RETURN jsonb_build_object(
'patients', '[]'::jsonb,
'appointments', '[]'::jsonb,
'documents', '[]'::jsonb,
'services', '[]'::jsonb,
'intakes', '[]'::jsonb
);
END IF;
v_q := left(v_q, 80);
v_pattern := '%' || v_q || '%';
v_limit := GREATEST(1, LEAST(coalesce(p_limit, 8), 20));
-- ─────────────────────────────────────────────────────────────────────
-- Pacientes
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
p.id,
p.nome_completo,
p.email_principal,
p.telefone,
p.avatar_url,
GREATEST(
similarity(coalesce(p.nome_completo, ''), v_q),
similarity(coalesce(p.email_principal, ''), v_q) * 0.7,
similarity(coalesce(p.telefone, ''), v_q) * 0.5,
similarity(coalesce(p.cpf, ''), v_q) * 0.6
) AS score
FROM public.patients p
WHERE p.nome_completo ILIKE v_pattern
OR p.email_principal ILIKE v_pattern
OR p.telefone ILIKE v_pattern
OR p.cpf ILIKE v_pattern
ORDER BY score DESC, p.nome_completo ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_completo,
'sublabel', coalesce(nullif(email_principal, ''), nullif(telefone, ''), ''),
'avatar_url', avatar_url,
'deeplink', '/therapist/patients/cadastro/' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_patients
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Agendamentos (com nome do paciente via join)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
e.id,
coalesce(nullif(e.titulo_custom, ''), nullif(e.titulo, ''), 'Sessão') AS label,
e.inicio_em,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(e.titulo, ''), v_q),
similarity(coalesce(e.titulo_custom, ''), v_q),
similarity(coalesce(pat.nome_completo, ''), v_q) * 0.9
) AS score
FROM public.agenda_eventos e
LEFT JOIN public.patients pat ON pat.id = e.patient_id
WHERE e.titulo ILIKE v_pattern
OR e.titulo_custom ILIKE v_pattern
OR pat.nome_completo ILIKE v_pattern
ORDER BY score DESC, e.inicio_em DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', label,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| to_char(inicio_em, 'DD/MM/YYYY HH24:MI')),
'deeplink', '/therapist/agenda?event=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_appointments
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Documentos
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
d.id,
d.patient_id,
d.nome_original,
d.tipo_documento,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(d.nome_original, ''), v_q),
similarity(coalesce(d.descricao, ''), v_q) * 0.7
) AS score
FROM public.documents d
LEFT JOIN public.patients pat ON pat.id = d.patient_id
WHERE d.nome_original ILIKE v_pattern
OR d.descricao ILIKE v_pattern
ORDER BY score DESC, d.nome_original ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_original,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| coalesce(tipo_documento, '')),
'deeplink', '/therapist/patients/' || patient_id::text || '/documents',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_documents
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Serviços (ativos)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
s.id,
s.name,
s.price,
s.duration_min,
GREATEST(
similarity(coalesce(s.name, ''), v_q),
similarity(coalesce(s.description, ''), v_q) * 0.7
) AS score
FROM public.services s
WHERE s.active IS TRUE
AND (s.name ILIKE v_pattern
OR s.description ILIKE v_pattern)
ORDER BY score DESC, s.name ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', name,
'sublabel', trim(both ' · ' from
'R$ ' || to_char(price, 'FM999G999G990D00') || ' · '
|| coalesce(duration_min::text || ' min', '')),
'deeplink', '/configuracoes/precificacao',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_services
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Intakes pendentes (patient_intake_requests com status='new')
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
r.id,
r.nome_completo,
r.email_principal,
r.telefone,
r.created_at,
GREATEST(
similarity(coalesce(r.nome_completo, ''), v_q),
similarity(coalesce(r.email_principal, ''), v_q) * 0.7,
similarity(coalesce(r.telefone, ''), v_q) * 0.5
) AS score
FROM public.patient_intake_requests r
WHERE r.status = 'new'
AND (r.nome_completo ILIKE v_pattern
OR r.email_principal ILIKE v_pattern
OR r.telefone ILIKE v_pattern)
ORDER BY score DESC, r.created_at DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', coalesce(nullif(trim(nome_completo), ''), '(sem nome)'),
'sublabel', trim(both ' · ' from
coalesce(nullif(email_principal, ''), nullif(telefone, ''), '') || ' · '
|| 'recebido ' || to_char(created_at, 'DD/MM/YYYY')),
'deeplink', '/therapist/patients/cadastro/recebidos?id=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_intakes
FROM ranked;
END IF;
RETURN jsonb_build_object(
'patients', v_patients,
'appointments', v_appointments,
'documents', v_documents,
'services', v_services,
'intakes', v_intakes
);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.search_global(text, text[], int) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.search_global(text, text[], int) TO authenticated;
COMMENT ON FUNCTION public.search_global(text, text[], int) IS
'Busca global do topbar — retorna jsonb agrupado por entidade. SECURITY INVOKER (RLS do chamador aplica).';
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260420000006_conv_messages_notifications
-- Sessao 11 - Fase 5a (extensao).
--
-- Integra conversation_messages ao sistema de notifications existente:
-- - Adiciona 'inbound_message' ao CHECK do notifications.type
-- - Trigger em conversation_messages: quando chega inbound, fan-out para
-- members do tenant apropriados (responsible_member do paciente, ou
-- todos tenant_admin/clinic_admin/therapist ativos se sem vinculo)
-- =============================================================================
-- Ajusta CHECK do type
ALTER TABLE public.notifications
DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE public.notifications
ADD CONSTRAINT notifications_type_check
CHECK (type = ANY (ARRAY[
'new_scheduling',
'new_patient',
'recurrence_alert',
'session_status',
'inbound_message'
]));
-- ---------------------------------------------------------------------------
-- Trigger function: fan-out mensagem inbound para notifications dos members
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_target_user UUID;
v_title TEXT;
v_detail TEXT;
v_initials TEXT;
v_deeplink TEXT;
v_patient_name TEXT;
v_payload JSONB;
BEGIN
-- so processa inbound
IF NEW.direction <> 'inbound' THEN
RETURN NEW;
END IF;
-- busca nome do paciente (se vinculado)
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
END IF;
-- titulo e detalhes
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
-- iniciais
IF v_patient_name IS NOT NULL THEN
v_initials := upper(left(v_patient_name, 1)) ||
COALESCE(upper(left(split_part(v_patient_name, ' ', 2), 1)), '');
ELSE
v_initials := '?';
END IF;
-- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
v_deeplink := '/admin/conversas';
v_payload := jsonb_build_object(
'title', v_title,
'detail', v_detail,
'avatar_initials', v_initials,
'deeplink', v_deeplink,
'channel', NEW.channel,
'conversation_message_id', NEW.id,
'patient_id', NEW.patient_id,
'from_number', NEW.from_number
);
-- ─── decide destinatarios ─────────────────────────────────────────────
-- Caso 1: paciente vinculado e tem responsible_member_id
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM public.patients p
JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
WHERE p.id = NEW.patient_id
AND tm.status = 'active'
LIMIT 1;
IF v_target_user IS NOT NULL THEN
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = NEW.tenant_id
AND tm.status = 'active'
AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');
RETURN NEW;
END;
$$;
-- Trigger
DROP TRIGGER IF EXISTS trg_fanout_inbound_to_notifications ON public.conversation_messages;
CREATE TRIGGER trg_fanout_inbound_to_notifications
AFTER INSERT ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
COMMENT ON FUNCTION public.fanout_inbound_message_to_notifications() IS
'Cria registros em notifications pra members apropriados quando chega mensagem inbound. Respeita responsible_member do paciente.';
@@ -0,0 +1,26 @@
-- =============================================================================
-- Migration: 20260420000007_notif_channels_saas_admin_insert
--
-- Fix: SaaS admin nao conseguia INSERT em notification_channels via /saas/whatsapp
-- porque a policy de insert exigia owner_id = auth.uid() e o saas_admin esta
-- inserindo em nome do tenant_admin (outro user). As policies de update/delete
-- ja tinham OR is_saas_admin() — o insert foi esquecido.
-- =============================================================================
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
CREATE POLICY "notif_channels_insert" ON public.notification_channels
FOR INSERT TO authenticated
WITH CHECK (
public.is_saas_admin()
OR (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
COMMENT ON POLICY "notif_channels_insert" ON public.notification_channels IS
'SaaS admin pode inserir em nome de qualquer tenant; tenant_member insere pra si mesmo.';
@@ -0,0 +1,12 @@
-- =============================================================================
-- Migration: 20260420000008_conv_messages_realtime
--
-- Adiciona conversation_messages na publicacao supabase_realtime para que
-- INSERT/UPDATE sejam entregues ao subscribe do frontend.
-- =============================================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_messages;
-- REPLICA IDENTITY FULL permite que o payload do Realtime traga a row completa
-- (necessario pra usar filters e receber old/new em UPDATEs)
ALTER TABLE public.conversation_messages REPLICA IDENTITY FULL;
@@ -0,0 +1,17 @@
-- =============================================================================
-- Migration: 20260420000009_conv_messages_delivery_status
--
-- Adiciona colunas para rastrear status de entrega/leitura das mensagens
-- outbound (envio pelo sistema). Evolution dispara evento messages.update
-- com status = SENT | DELIVERED | READ que vamos capturar.
-- =============================================================================
ALTER TABLE public.conversation_messages
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS read_by_recipient_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS delivery_status TEXT
CHECK (delivery_status IS NULL OR delivery_status IN ('pending','sent','delivered','read','failed'));
CREATE INDEX IF NOT EXISTS idx_conv_msg_delivery_status
ON public.conversation_messages (tenant_id, delivery_status)
WHERE direction = 'outbound';
@@ -0,0 +1,91 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Bucket para midia de WhatsApp
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Cria bucket privado `whatsapp-media` para armazenar audio/imagem/video/
-- documentos recebidos via Evolution API. URLs do WhatsApp sao encriptadas
-- com mediaKey da Meta — precisamos decriptar via Evolution getBase64 e
-- subir pro nosso storage para playback no browser.
--
-- Privacidade LGPD:
-- - Bucket privado (public=false)
-- - Upload apenas via service_role (edge function)
-- - Leitura via signed URLs gerados on-demand pelo frontend (expiracao curta)
-- - Paths tenant-scoped: <tenant_id>/<yyyy>/<mm>/<uuid>.<ext>
-- ==========================================================================
-- Bucket whatsapp-media
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'whatsapp-media',
'whatsapp-media',
false,
26214400, -- 25 MB (WhatsApp aceita ate 16MB audio/video, margem extra)
ARRAY[
-- Audio
'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/wav', 'audio/webm',
-- Imagem
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
-- Video
'video/mp4', 'video/3gpp', 'video/quicktime', 'video/webm',
-- Documento
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'application/zip',
'application/octet-stream'
]
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — whatsapp-media
-- --------------------------------------------------------------------------
-- Politica: APENAS service_role faz upload (edge function).
-- Usuarios autenticados leem se forem membros ativos do tenant no path[0].
-- --------------------------------------------------------------------------
-- Read: membro ativo do tenant cujo id e o primeiro segmento do path
CREATE POLICY "whatsapp-media: read tenant members"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND (
-- SaaS admins leem qualquer tenant
public.is_saas_admin()
OR
-- Membros ativos do tenant (tenant_id e o primeiro segmento do path)
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND (storage.foldername(name))[1] = tm.tenant_id::text
)
)
);
-- Insert: bloqueado para authenticated (apenas service_role sobe)
-- Sem policy de INSERT para authenticated = bloqueado por default no RLS.
-- Delete: SaaS admin pode deletar (retention policy futura)
CREATE POLICY "whatsapp-media: delete saas admin"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND public.is_saas_admin()
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,116 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Notas internas de conversa (CRM Grupo 3.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Notas internas da equipe em cada thread de conversa (WhatsApp/SMS/etc).
-- NAO sao enviadas ao paciente — apenas visiveis aos membros do tenant.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - READ/CREATE: qualquer membro ativo do tenant
-- - UPDATE/DELETE: apenas o criador da nota OU saas_admin
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
body TEXT NOT NULL CHECK (length(body) > 0 AND length(body) <= 4000),
created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_conv_notes_tenant_thread
ON public.conversation_notes (tenant_id, thread_key, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_patient
ON public.conversation_notes (patient_id, created_at DESC)
WHERE deleted_at IS NULL AND patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_created_by
ON public.conversation_notes (created_by, created_at DESC)
WHERE deleted_at IS NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_notes_updated_at ON public.conversation_notes;
CREATE TRIGGER trg_conv_notes_updated_at
BEFORE UPDATE ON public.conversation_notes
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_notes IS
'Notas internas por thread de conversa. Visiveis apenas aos membros do tenant; nao enviadas ao paciente.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
-- SELECT: membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_notes: select tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: select tenant members"
ON public.conversation_notes
FOR SELECT
TO authenticated
USING (
deleted_at IS NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- INSERT: membro ativo do tenant, created_by deve ser o proprio usuario
DROP POLICY IF EXISTS "conv_notes: insert tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: insert tenant members"
ON public.conversation_notes
FOR INSERT
TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: apenas criador OU saas_admin
DROP POLICY IF EXISTS "conv_notes: update creator or saas" ON public.conversation_notes;
CREATE POLICY "conv_notes: update creator or saas"
ON public.conversation_notes
FOR UPDATE
TO authenticated
USING (
deleted_at IS NULL
AND (created_by = auth.uid() OR public.is_saas_admin())
)
WITH CHECK (
created_by = (SELECT created_by FROM public.conversation_notes WHERE id = conversation_notes.id)
);
-- DELETE: soft delete via UPDATE deleted_at (nao permite hard delete)
-- Mantemos politica de DELETE bloqueada por default (sem policy = nao permitido)
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Tags de conversa (CRM Grupo 3.1)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Tags aplicaveis a uma thread de conversa (urgente, primeira consulta,
-- remarcacao, etc). Cada tenant pode criar tags custom alem das padrao.
--
-- Tabelas:
-- - conversation_tags — definicoes (system com tenant_id NULL + custom)
-- - conversation_thread_tags — join (tenant_id, thread_key, tag_id)
--
-- thread_key: mesma logica de conversation_threads
-- - '<uuid>' → thread de paciente
-- - 'anon:<phone>' → thread anonima
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: conversation_tags (definicoes)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system tag
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
color TEXT NOT NULL DEFAULT '#6366f1' CHECK (color ~ '^#[0-9a-fA-F]{6}$'),
icon TEXT, -- classe de icone primeicons (ex: 'pi pi-exclamation-triangle')
position INT NOT NULL DEFAULT 100,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: (tenant_id, slug). Para tenant_id NULL (system), um indice parcial separado.
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_tenant_slug
ON public.conversation_tags (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_system_slug
ON public.conversation_tags (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_tags_tenant
ON public.conversation_tags (tenant_id, position);
DROP TRIGGER IF EXISTS trg_conv_tags_updated_at ON public.conversation_tags;
CREATE TRIGGER trg_conv_tags_updated_at
BEFORE UPDATE ON public.conversation_tags
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_tags IS
'Definicoes de tags aplicaveis a threads. tenant_id NULL = tag do sistema (todos veem).';
-- ---------------------------------------------------------------------------
-- Tabela: conversation_thread_tags (join many-to-many)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_thread_tags (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
tag_id UUID NOT NULL REFERENCES public.conversation_tags(id) ON DELETE CASCADE,
tagged_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
tagged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tenant_thread
ON public.conversation_thread_tags (tenant_id, thread_key);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tag
ON public.conversation_thread_tags (tag_id);
COMMENT ON TABLE public.conversation_thread_tags IS
'Join de tags aplicadas a cada thread de conversa.';
-- ---------------------------------------------------------------------------
-- Seed de tags padrao (system)
-- ---------------------------------------------------------------------------
INSERT INTO public.conversation_tags (tenant_id, name, slug, color, icon, position, is_system)
VALUES
(NULL, 'Urgente', 'urgente', '#ef4444', 'pi pi-exclamation-triangle', 10, true),
(NULL, 'Primeira consulta','primeira-consulta','#0ea5e9', 'pi pi-user-plus', 20, true),
(NULL, 'Remarcação', 'remarcacao', '#f59e0b', 'pi pi-calendar-times', 30, true),
(NULL, 'Confirmada', 'confirmada', '#22c55e', 'pi pi-check-circle', 40, true),
(NULL, 'Follow-up', 'follow-up', '#a855f7', 'pi pi-reply', 50, true)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- RLS: conversation_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
-- SELECT: system tags (tenant_id NULL) = todos; custom = membros ativos do tenant
DROP POLICY IF EXISTS "conv_tags: select" ON public.conversation_tags;
CREATE POLICY "conv_tags: select"
ON public.conversation_tags
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membros ativos do tenant criam custom. Nao podem criar system (tenant_id NULL)
DROP POLICY IF EXISTS "conv_tags: insert custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: insert custom"
ON public.conversation_tags
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: tenant members para tags proprias (custom). Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: update custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: update custom"
ON public.conversation_tags
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
)
WITH CHECK (is_system = false);
-- DELETE: tenant members removem tags custom. Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: delete custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: delete custom"
ON public.conversation_tags
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: conversation_thread_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_thread_tags: select" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: select"
ON public.conversation_thread_tags
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "conv_thread_tags: insert" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: insert"
ON public.conversation_thread_tags
FOR INSERT
TO authenticated
WITH CHECK (
tagged_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "conv_thread_tags: delete" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: delete"
ON public.conversation_thread_tags
FOR DELETE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,143 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Auto-reply fora do horario (CRM Grupo 2.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente manda mensagem fora do horario de atendimento, dispara
-- resposta automatica configuravel. Anti-spam via cooldown por thread.
--
-- Modos de horario:
-- - 'agenda' → usa agenda_regras_semanais dos membros do tenant
-- - 'business_hours' → janela semanal do tenant (clinica inteira)
-- - 'custom' → janela semanal especifica deste auto-reply
--
-- business_hours e custom_window usam mesma estrutura JSONB:
-- [{ "dow": 0-6, "start": "HH:MM", "end": "HH:MM" }, ...]
-- Multiplas entradas por dia permitidas (ex: 08:00-12:00 + 13:00-18:00)
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
message TEXT NOT NULL DEFAULT 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!'
CHECK (length(message) > 0 AND length(message) <= 2000),
cooldown_minutes INT NOT NULL DEFAULT 180
CHECK (cooldown_minutes >= 0 AND cooldown_minutes <= 43200), -- 0 min a 30 dias
schedule_mode TEXT NOT NULL DEFAULT 'agenda'
CHECK (schedule_mode IN ('agenda', 'business_hours', 'custom')),
-- Janela de funcionamento da clinica (reutilizavel por outras features)
-- Ex: [{ "dow": 1, "start": "08:00", "end": "18:00" }, ...]
business_hours JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Janela especifica deste auto-reply (quando schedule_mode = 'custom')
custom_window JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_conv_autoreply_settings_updated_at ON public.conversation_autoreply_settings;
CREATE TRIGGER trg_conv_autoreply_settings_updated_at
BEFORE UPDATE ON public.conversation_autoreply_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_autoreply_settings IS
'Configuracao por tenant do auto-reply fora do horario.';
-- ---------------------------------------------------------------------------
-- Log (anti-spam: uma resposta auto por thread por cooldown)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
message_id UUID -- referencia opcional pra message na tabela conversation_messages
);
CREATE INDEX IF NOT EXISTS idx_autoreply_log_cooldown
ON public.conversation_autoreply_log (tenant_id, thread_key, sent_at DESC);
COMMENT ON TABLE public.conversation_autoreply_log IS
'Log de auto-replies enviados. Usado pra respeitar cooldown por thread.';
-- ---------------------------------------------------------------------------
-- RLS: settings
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_settings: select" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: select"
ON public.conversation_autoreply_settings
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: insert" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: insert"
ON public.conversation_autoreply_settings
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: update" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: update"
ON public.conversation_autoreply_settings
FOR UPDATE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: log (read-only pra tenant members; escrita via service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_log: select" ON public.conversation_autoreply_log;
CREATE POLICY "autoreply_log: select"
ON public.conversation_autoreply_log
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_log.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Opt-out de conversas (CRM Grupo 5.2, LGPD)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente envia keyword de opt-out (PARAR, SAIR, CANCELAR, STOP...),
-- bloqueia envios automaticos (auto-reply + futuros lembretes).
--
-- LGPD: direito de oposicao (Art. 18, §2). Pedido de interrupcao deve ser
-- respeitado. Mensagens manuais do terapeuta nao sao bloqueadas — relacao
-- terapeutica existe.
--
-- Phone e normalizado (apenas digitos) pra matching consistente.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_optouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
phone TEXT NOT NULL CHECK (phone ~ '^\d{6,15}$'),
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
-- 'keyword' = detectado automaticamente por palavra-chave
-- 'manual' = adicionado manualmente pelo terapeuta/admin
source TEXT NOT NULL DEFAULT 'keyword'
CHECK (source IN ('keyword', 'manual')),
keyword_matched TEXT, -- palavra/frase que disparou (quando source='keyword')
original_message TEXT, -- texto completo da msg original (truncado)
notes TEXT, -- observacao do terapeuta (quando manual)
blocked_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- quem adicionou manual
opted_out_at TIMESTAMPTZ NOT NULL DEFAULT now(),
opted_back_in_at TIMESTAMPTZ, -- quando usuario restaurou (opt-in)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: um registro ativo por tenant+phone. Permite historico se fizer opt-in e depois opt-out de novo.
-- Active = opted_back_in_at IS NULL
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_optouts_active
ON public.conversation_optouts (tenant_id, phone)
WHERE opted_back_in_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_optouts_tenant_phone
ON public.conversation_optouts (tenant_id, phone);
CREATE INDEX IF NOT EXISTS idx_conv_optouts_patient
ON public.conversation_optouts (patient_id)
WHERE patient_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_conv_optouts_updated_at ON public.conversation_optouts;
CREATE TRIGGER trg_conv_optouts_updated_at
BEFORE UPDATE ON public.conversation_optouts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_optouts IS
'Numeros que pediram pra nao receber mensagens automaticas. LGPD Art. 18 Sec.2.';
-- ---------------------------------------------------------------------------
-- Keywords de opt-out — lista do tenant (reutilizavel)
-- Cada tenant pode customizar suas palavras-chave. Default aplicado via seed.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_optout_keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system (todos)
keyword TEXT NOT NULL CHECK (length(keyword) > 0 AND length(keyword) <= 100),
enabled BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_optout_kw_tenant
ON public.conversation_optout_keywords (tenant_id)
WHERE enabled = true;
-- Seed keywords padrao (system, tenant_id NULL, todos veem)
INSERT INTO public.conversation_optout_keywords (tenant_id, keyword, is_system, enabled) VALUES
(NULL, 'parar', true, true),
(NULL, 'sair', true, true),
(NULL, 'cancelar', true, true),
(NULL, 'stop', true, true),
(NULL, 'descadastrar', true, true),
(NULL, 'remover', true, true),
(NULL, 'nao quero mais', true, true),
(NULL, 'não quero mais', true, true),
(NULL, 'desinscrever', true, true),
(NULL, 'unsubscribe', true, true)
ON CONFLICT DO NOTHING;
COMMENT ON TABLE public.conversation_optout_keywords IS
'Palavras-chave que disparam opt-out quando paciente envia. Sistema (tenant_id NULL) + custom do tenant.';
-- ---------------------------------------------------------------------------
-- RLS: optouts
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optouts: select" ON public.conversation_optouts;
CREATE POLICY "optouts: select"
ON public.conversation_optouts
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: insert" ON public.conversation_optouts;
CREATE POLICY "optouts: insert"
ON public.conversation_optouts
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: update" ON public.conversation_optouts;
CREATE POLICY "optouts: update"
ON public.conversation_optouts
FOR UPDATE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: keywords
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optout_kw: select" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: select"
ON public.conversation_optout_keywords
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optout_kw: insert custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: insert custom"
ON public.conversation_optout_keywords
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: update/delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: update/delete custom"
ON public.conversation_optout_keywords
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: delete custom"
ON public.conversation_optout_keywords
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,152 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Lembretes automáticos de sessão (CRM Grupo 2.4)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Envia WhatsApp automatico antes das sessoes agendadas (24h e 2h antes).
-- Respeita opt-out (LGPD), quiet hours, canal ativo do tenant.
--
-- Arquitetura:
-- - pg_cron agenda edge function `send-session-reminders` a cada 15 min
-- - edge function busca eventos na janela de lembretes, envia + registra log
-- - UNIQUE (event_id, reminder_type) no log impede envio duplicado
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
-- Lead times (quais lembretes enviar)
send_24h BOOLEAN NOT NULL DEFAULT true,
send_2h BOOLEAN NOT NULL DEFAULT true,
-- Templates com variaveis: {{nome_paciente}}, {{data_sessao}}, {{hora_sessao}}, {{nome_clinica}}, {{modalidade}}
template_24h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!'
CHECK (length(template_24h) > 0 AND length(template_24h) <= 2000),
template_2h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊'
CHECK (length(template_2h) > 0 AND length(template_2h) <= 2000),
-- Quiet hours (não envia lembretes nessa janela, mesmo se a sessão estiver na janela)
-- Format: 'HH:MM'. Se start > end, janela atravessa a meia-noite (ex: 22:00 → 08:00).
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT true,
quiet_hours_start TIME NOT NULL DEFAULT '22:00',
quiet_hours_end TIME NOT NULL DEFAULT '08:00',
-- Respeita opt-out (LGPD)? default true, mas expomos pra caso haja tenant com regra especifica.
respect_opt_out BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_session_reminder_settings_updated_at ON public.session_reminder_settings;
CREATE TRIGGER trg_session_reminder_settings_updated_at
BEFORE UPDATE ON public.session_reminder_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.session_reminder_settings IS
'Configuracao por tenant dos lembretes automaticos de sessao via WhatsApp.';
-- ---------------------------------------------------------------------------
-- Log (anti-duplicata + auditoria)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_logs (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES public.agenda_eventos(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
reminder_type TEXT NOT NULL CHECK (reminder_type IN ('24h', '2h')),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
provider TEXT, -- 'evolution', 'twilio', 'skipped'
skip_reason TEXT, -- quando provider='skipped': opted_out, quiet_hours, no_phone, etc
to_phone TEXT,
provider_message_id TEXT,
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_session_reminder_event_type
ON public.session_reminder_logs (event_id, reminder_type);
CREATE INDEX IF NOT EXISTS idx_session_reminder_tenant_sent
ON public.session_reminder_logs (tenant_id, sent_at DESC);
COMMENT ON TABLE public.session_reminder_logs IS
'Log de lembretes disparados. UNIQUE (event_id, reminder_type) previne duplicata.';
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.session_reminder_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "reminder_settings: tenant members all" ON public.session_reminder_settings;
CREATE POLICY "reminder_settings: tenant members all"
ON public.session_reminder_settings
FOR ALL
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "reminder_logs: tenant members select" ON public.session_reminder_logs;
CREATE POLICY "reminder_logs: tenant members select"
ON public.session_reminder_logs
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_logs.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- pg_cron: agenda send-session-reminders a cada 15 minutos
-- ---------------------------------------------------------------------------
-- Uses pg_net.http_post to hit the edge function. O secret do service_role
-- deve estar configurado em app.settings.service_role_key (ou use Vault).
--
-- Como alternativa mais simples: o user pode configurar um cron externo
-- (ex: Supabase Dashboard → Database → Cron) apontando pra edge function.
-- Deixo o schedule abaixo comentado; descomentar em produção quando
-- app.settings.service_role_key e app.settings.supabase_url estiverem setados.
-- ---------------------------------------------------------------------------
-- SELECT cron.schedule(
-- 'session-reminders-every-15min',
-- '*/15 * * * *',
-- $$
-- SELECT net.http_post(
-- url := current_setting('app.settings.supabase_url') || '/functions/v1/send-session-reminders',
-- headers := jsonb_build_object(
-- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'),
-- 'Content-Type', 'application/json'
-- ),
-- body := '{}'::jsonb
-- );
-- $$
-- );
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,342 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Sistema de créditos WhatsApp (Marco B)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Modelo:
-- - whatsapp_credits_balance → saldo atual por tenant (snapshot)
-- - whatsapp_credits_transactions → extrato (purchase, usage, topup, adj)
-- - whatsapp_credit_packages → pacotes oferecidos (SaaS-managed)
-- - whatsapp_credit_purchases → ordens de compra via Asaas
--
-- Helpers (RPC):
-- - add_whatsapp_credits(tenant, amount, kind, ...) → novo saldo
-- - deduct_whatsapp_credits(tenant, amount, message_id) → boolean
--
-- Creditos so sao deduzidos quando o tenant usa provider='twilio'
-- (Evolution e free).
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Saldo por tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_balance (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
balance INT NOT NULL DEFAULT 0 CHECK (balance >= 0),
lifetime_purchased INT NOT NULL DEFAULT 0,
lifetime_used INT NOT NULL DEFAULT 0,
low_balance_threshold INT NOT NULL DEFAULT 20 CHECK (low_balance_threshold >= 0),
low_balance_alerted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credits_balance_updated_at ON public.whatsapp_credits_balance;
CREATE TRIGGER trg_wa_credits_balance_updated_at
BEFORE UPDATE ON public.whatsapp_credits_balance
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.whatsapp_credits_balance IS
'Saldo atual de creditos WhatsApp por tenant. 1 credito = 1 mensagem Twilio.';
-- ---------------------------------------------------------------------------
-- Extrato (transações)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('purchase', 'usage', 'topup_manual', 'refund', 'adjustment')),
amount INT NOT NULL, -- positivo = credito, negativo = debito
balance_after INT NOT NULL,
-- Referencias opcionais
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL,
purchase_id UUID,
admin_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_tenant_created
ON public.whatsapp_credits_transactions (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_kind
ON public.whatsapp_credits_transactions (tenant_id, kind, created_at DESC);
COMMENT ON TABLE public.whatsapp_credits_transactions IS
'Extrato de creditos WhatsApp. Append-only — nao editar/deletar.';
-- ---------------------------------------------------------------------------
-- Pacotes (global, gerenciado pelo SaaS admin)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 100),
description TEXT,
credits INT NOT NULL CHECK (credits > 0),
price_brl NUMERIC(10,2) NOT NULL CHECK (price_brl > 0),
is_active BOOLEAN NOT NULL DEFAULT true,
is_featured BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_packages_updated_at ON public.whatsapp_credit_packages;
CREATE TRIGGER trg_wa_credit_packages_updated_at
BEFORE UPDATE ON public.whatsapp_credit_packages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_packages_active
ON public.whatsapp_credit_packages (is_active, position, price_brl)
WHERE is_active = true;
COMMENT ON TABLE public.whatsapp_credit_packages IS
'Pacotes de creditos disponiveis pra compra. Gerenciado pelo SaaS admin.';
-- Seed: pacotes padrao
INSERT INTO public.whatsapp_credit_packages (name, description, credits, price_brl, is_featured, position) VALUES
('Iniciante', 'Ideal pra conhecer a plataforma', 100, 49.90, false, 10),
('Profissional', 'Mais vendido pra clínicas pequenas', 500, 199.90, true, 20),
('Clínica', 'Pra clínicas com alto volume', 1500, 499.90, false, 30),
('Enterprise', 'Pacote grande com desconto', 5000, 1499.90, false, 40)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Ordens de compra (Asaas integration)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
package_id UUID REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL,
-- Snapshot do pacote no momento da compra (caso mude de preço/creditos depois)
package_name TEXT NOT NULL,
credits INT NOT NULL CHECK (credits > 0),
amount_brl NUMERIC(10,2) NOT NULL CHECK (amount_brl > 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'refunded', 'cancelled')),
-- Asaas integration
asaas_customer_id TEXT,
asaas_payment_id TEXT,
asaas_payment_link TEXT,
asaas_pix_qrcode TEXT, -- base64 da imagem
asaas_pix_copy_paste TEXT, -- codigo PIX copia-cola
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_purchases_updated_at ON public.whatsapp_credit_purchases;
CREATE TRIGGER trg_wa_credit_purchases_updated_at
BEFORE UPDATE ON public.whatsapp_credit_purchases
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_tenant
ON public.whatsapp_credit_purchases (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_status
ON public.whatsapp_credit_purchases (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_asaas_payment
ON public.whatsapp_credit_purchases (asaas_payment_id)
WHERE asaas_payment_id IS NOT NULL;
-- FK pra transactions.purchase_id (circular, então define depois)
ALTER TABLE public.whatsapp_credits_transactions
DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_purchase_id_fkey;
ALTER TABLE public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey
FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL;
COMMENT ON TABLE public.whatsapp_credit_purchases IS
'Ordens de compra de creditos via Asaas. Webhook atualiza status.';
-- ---------------------------------------------------------------------------
-- RPC: add_whatsapp_credits (SECURITY DEFINER — atualiza saldo + registra tx)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.add_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_kind TEXT,
p_purchase_id UUID DEFAULT NULL,
p_admin_id UUID DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN
RAISE EXCEPTION 'invalid kind for credit: %', p_kind;
END IF;
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased)
VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
lifetime_purchased = whatsapp_credits_balance.lifetime_purchased
+ CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END,
low_balance_alerted_at = NULL -- reset alerta quando recebe creditos
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, purchase_id, admin_id, note)
VALUES
(p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RPC: deduct_whatsapp_credits (atomico, falha se saldo insuficiente)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.deduct_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_conversation_message_id BIGINT DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
v_row RECORD;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
-- Lock a linha e valida saldo
SELECT balance, low_balance_threshold
INTO v_row
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
IF v_row.balance < p_amount THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance - p_amount,
lifetime_used = lifetime_used + p_amount
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, conversation_message_id, note)
VALUES
(p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
-- Balance: members do tenant leem, saas_admin tudo
DROP POLICY IF EXISTS "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: select tenant"
ON public.whatsapp_credits_balance FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Settings update: members do tenant podem alterar low_balance_threshold
DROP POLICY IF EXISTS "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: update tenant"
ON public.whatsapp_credits_balance FOR UPDATE TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Transactions: read-only pra tenant members, write via RPC
DROP POLICY IF EXISTS "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions;
CREATE POLICY "wa_credits_tx: select tenant"
ON public.whatsapp_credits_transactions FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_transactions.tenant_id AND tm.status = 'active'
)
);
-- Packages: todos leem os ativos; saas_admin gerencia
DROP POLICY IF EXISTS "wa_packages: select active" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: select active"
ON public.whatsapp_credit_packages FOR SELECT TO authenticated
USING (is_active = true OR public.is_saas_admin());
DROP POLICY IF EXISTS "wa_packages: manage saas admin" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: manage saas admin"
ON public.whatsapp_credit_packages FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- Purchases: members do tenant leem as proprias
DROP POLICY IF EXISTS "wa_purchases: select tenant" ON public.whatsapp_credit_purchases;
CREATE POLICY "wa_purchases: select tenant"
ON public.whatsapp_credit_purchases FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credit_purchases.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,356 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Telefones polimorficos com tipo + principal
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Substitui campos fixos de telefone (patients.telefone, medicos.telefone_*)
-- por estrutura flexivel:
--
-- - contact_types → tipos configuraveis (Celular, Fixo, WhatsApp, ...)
-- System (tenant_id NULL) + custom por tenant
-- - contact_phones → telefones polimorficos (entity_type + entity_id)
-- Suporta patient, medico, futuramente emergency, etc
--
-- Ate 1 telefone marcado como is_primary por entidade (UNIQUE parcial).
-- Triggers mantem patients.telefone, telefone_alternativo, medicos.telefone_*
-- sincronizados pra nao quebrar codigo legado.
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: contact_types
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
icon TEXT, -- classe primeicons (ex: 'pi pi-mobile')
is_mobile BOOLEAN NOT NULL DEFAULT true, -- true = mascara celular; false = mascara fixo
is_system BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_types_tenant_slug
ON public.contact_types (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_types_system_slug
ON public.contact_types (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_contact_types_tenant
ON public.contact_types (tenant_id, position);
DROP TRIGGER IF EXISTS trg_contact_types_updated_at ON public.contact_types;
CREATE TRIGGER trg_contact_types_updated_at
BEFORE UPDATE ON public.contact_types
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_types IS
'Tipos de contato (Celular, Fixo, WhatsApp, ...). System (tenant_id NULL) visiveis a todos; custom por tenant.';
-- Seed: tipos system padrao
INSERT INTO public.contact_types (tenant_id, name, slug, icon, is_mobile, is_system, position) VALUES
(NULL, 'Celular', 'celular', 'pi pi-mobile', true, true, 10),
(NULL, 'WhatsApp', 'whatsapp', 'pi pi-whatsapp', true, true, 20),
(NULL, 'Fixo', 'fixo', 'pi pi-phone', false, true, 30),
(NULL, 'Residencial', 'residencial', 'pi pi-home', false, true, 40),
(NULL, 'Comercial', 'comercial', 'pi pi-building', true, true, 50),
(NULL, 'Fax', 'fax', 'pi pi-print', false, true, 60)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Tabela: contact_phones (polimorfica)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_phones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('patient', 'medico')),
entity_id UUID NOT NULL,
contact_type_id UUID NOT NULL REFERENCES public.contact_types(id) ON DELETE RESTRICT,
number TEXT NOT NULL CHECK (number ~ '^\d{8,15}$'), -- digits only, 8-15 (DDI+DDD+num)
is_primary BOOLEAN NOT NULL DEFAULT false,
-- Vinculado automaticamente via drawer de conversa (CRM 3.5)
whatsapp_linked_at TIMESTAMPTZ,
notes TEXT,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contact_phones_entity
ON public.contact_phones (tenant_id, entity_type, entity_id, position);
CREATE INDEX IF NOT EXISTS idx_contact_phones_number
ON public.contact_phones (tenant_id, number);
-- Partial unique: apenas 1 primary por entidade
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_phones_primary
ON public.contact_phones (entity_type, entity_id)
WHERE is_primary = true;
DROP TRIGGER IF EXISTS trg_contact_phones_updated_at ON public.contact_phones;
CREATE TRIGGER trg_contact_phones_updated_at
BEFORE UPDATE ON public.contact_phones
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_phones IS
'Telefones polimorficos (patients, medicos, ...). Max 1 primary por entidade. Triggers sincronizam campos legados.';
-- ---------------------------------------------------------------------------
-- Helper: pega o telefone primary (ou primeiro) de uma entidade
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(
p_entity_type TEXT,
p_entity_id UUID
) RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT number FROM public.contact_phones
WHERE entity_type = p_entity_type
AND entity_id = p_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION public.get_entity_primary_phone(TEXT, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_entity_primary_phone(TEXT, UUID) TO authenticated, service_role;
-- ---------------------------------------------------------------------------
-- Trigger: sincroniza campos legados de patients/medicos apos mudanca
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields() RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
v_whatsapp_slug TEXT;
v_whatsapp TEXT;
BEGIN
-- Identifica entidade afetada (pode ser OLD em delete)
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
-- Pega primary (ou primeiro)
SELECT number INTO v_primary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
-- Pega segundo (depois do primary)
SELECT number INTO v_secondary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
-- Sincroniza campos legados
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET telefone = v_primary,
telefone_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
-- Medicos: telefone_profissional = primary; telefone_pessoal = secundario
UPDATE public.medicos
SET telefone_profissional = v_primary,
telefone_pessoal = v_secondary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
DROP TRIGGER IF EXISTS trg_contact_phones_sync_legacy ON public.contact_phones;
CREATE TRIGGER trg_contact_phones_sync_legacy
AFTER INSERT OR UPDATE OR DELETE ON public.contact_phones
FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields();
-- ---------------------------------------------------------------------------
-- Backfill: migra dados existentes pra contact_phones
-- ---------------------------------------------------------------------------
-- Patients: telefone → Celular primary, telefone_alternativo → Fixo
DO $$
DECLARE
v_celular_id UUID;
v_fixo_id UUID;
v_profissional_id UUID;
BEGIN
SELECT id INTO v_celular_id FROM public.contact_types WHERE slug = 'celular' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_fixo_id FROM public.contact_types WHERE slug = 'fixo' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_profissional_id FROM public.contact_types WHERE slug = 'comercial' AND tenant_id IS NULL LIMIT 1;
-- Patients.telefone → Celular primary
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
p.tenant_id,
'patient',
p.id,
v_celular_id,
regexp_replace(p.telefone, '\D', '', 'g'),
true,
10
FROM public.patients p
WHERE p.telefone IS NOT NULL
AND length(regexp_replace(p.telefone, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient' AND cp.entity_id = p.id
)
ON CONFLICT DO NOTHING;
-- Patients.telefone_alternativo → Fixo
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
p.tenant_id,
'patient',
p.id,
v_fixo_id,
regexp_replace(p.telefone_alternativo, '\D', '', 'g'),
false,
20
FROM public.patients p
WHERE p.telefone_alternativo IS NOT NULL
AND length(regexp_replace(p.telefone_alternativo, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient' AND cp.entity_id = p.id
AND cp.number = regexp_replace(p.telefone_alternativo, '\D', '', 'g')
)
ON CONFLICT DO NOTHING;
-- Medicos.telefone_profissional → Comercial primary
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
m.tenant_id,
'medico',
m.id,
v_profissional_id,
regexp_replace(m.telefone_profissional, '\D', '', 'g'),
true,
10
FROM public.medicos m
WHERE m.telefone_profissional IS NOT NULL
AND length(regexp_replace(m.telefone_profissional, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'medico' AND cp.entity_id = m.id
)
ON CONFLICT DO NOTHING;
-- Medicos.telefone_pessoal → Celular
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
m.tenant_id,
'medico',
m.id,
v_celular_id,
regexp_replace(m.telefone_pessoal, '\D', '', 'g'),
false,
20
FROM public.medicos m
WHERE m.telefone_pessoal IS NOT NULL
AND length(regexp_replace(m.telefone_pessoal, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'medico' AND cp.entity_id = m.id
AND cp.number = regexp_replace(m.telefone_pessoal, '\D', '', 'g')
)
ON CONFLICT DO NOTHING;
END $$;
-- ---------------------------------------------------------------------------
-- RLS: contact_types
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_types: select" ON public.contact_types;
CREATE POLICY "contact_types: select"
ON public.contact_types FOR SELECT TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "contact_types: manage custom" ON public.contact_types;
CREATE POLICY "contact_types: manage custom"
ON public.contact_types FOR ALL TO authenticated
USING (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
)
)
WITH CHECK (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: contact_phones
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_phones: all tenant" ON public.contact_phones;
CREATE POLICY "contact_phones: all tenant"
ON public.contact_phones FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_phones.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_phones.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,64 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Retroativa WhatsApp-linked em contact_phones
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Pacientes que foram vinculados via drawer de conversa ANTES do refactor
-- polimorfico de telefones tiveram o numero preservado em patients.telefone
-- mas sem marca "vinculado WhatsApp" (nao existia o conceito).
--
-- Este script:
-- 1. Detecta pacientes com conversation_messages (direction=inbound)
-- 2. Encontra o contact_phone que match o numero da conversa
-- 3. Muda o contact_type_id pra 'whatsapp' + seta whatsapp_linked_at
-- pra first_message_received_at
--
-- Nao destrutivo: so altera phones que claramente foram vinculados via CRM.
-- Se paciente tem multiplos phones, apenas o que match o numero da conversa
-- e afetado.
-- ==========================================================================
DO $$
DECLARE
v_whatsapp_type_id UUID;
BEGIN
SELECT id INTO v_whatsapp_type_id
FROM public.contact_types
WHERE slug = 'whatsapp' AND tenant_id IS NULL
LIMIT 1;
IF v_whatsapp_type_id IS NULL THEN
RAISE NOTICE 'Contact type WhatsApp nao encontrado — pule a migration retroativa';
RETURN;
END IF;
-- Atualiza contact_phones que match conversation_messages inbound
WITH convs AS (
SELECT
cm.patient_id,
regexp_replace(cm.from_number, '\D', '', 'g') AS phone_digits,
MIN(COALESCE(cm.received_at, cm.created_at)) AS first_msg_at
FROM public.conversation_messages cm
WHERE cm.patient_id IS NOT NULL
AND cm.direction = 'inbound'
AND cm.from_number IS NOT NULL
AND cm.channel = 'whatsapp'
GROUP BY cm.patient_id, regexp_replace(cm.from_number, '\D', '', 'g')
)
UPDATE public.contact_phones cp
SET
contact_type_id = v_whatsapp_type_id,
whatsapp_linked_at = COALESCE(cp.whatsapp_linked_at, convs.first_msg_at)
FROM convs
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits;
-- Log
RAISE NOTICE 'Retroactive WhatsApp link complete';
END $$;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,95 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Retroativa WhatsApp-linked v2
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Complementa a migration 20260421000009_retroactive_whatsapp_link:
--
-- Pacientes vinculados via drawer antes do refactor ficaram sem a info
-- de "vinculado WhatsApp". Algumas pecularidades:
--
-- 1. Mensagens podem ser outbound (to_number) ou inbound (from_number)
-- 2. O numero da conversa pode NAO existir ainda em contact_phones
-- (paciente tinha outro telefone cadastrado, CRM WhatsApp linkou um numero diferente)
--
-- Estrategia:
-- - Pra cada paciente com conversa no channel 'whatsapp', coleta TODOS
-- os numeros unicos (from_number + to_number conforme direction)
-- - Pra cada numero:
-- - Se paciente JA tem um contact_phone com esse numero → update pra WhatsApp + linked
-- - Se NAO tem → insere novo com type='whatsapp' + linked
-- ==========================================================================
DO $$
DECLARE
v_whatsapp_type_id UUID;
BEGIN
SELECT id INTO v_whatsapp_type_id
FROM public.contact_types
WHERE slug = 'whatsapp' AND tenant_id IS NULL
LIMIT 1;
IF v_whatsapp_type_id IS NULL THEN
RAISE NOTICE 'Contact type WhatsApp nao encontrado';
RETURN;
END IF;
-- CTE: extrai (patient_id, phone_digits, first_msg_at) de conversation_messages
WITH convs AS (
SELECT
cm.tenant_id,
cm.patient_id,
CASE
WHEN cm.direction = 'inbound' THEN regexp_replace(cm.from_number, '\D', '', 'g')
WHEN cm.direction = 'outbound' THEN regexp_replace(cm.to_number, '\D', '', 'g')
END AS phone_digits,
MIN(COALESCE(cm.received_at, cm.responded_at, cm.created_at)) AS first_msg_at
FROM public.conversation_messages cm
WHERE cm.patient_id IS NOT NULL
AND cm.channel = 'whatsapp'
AND (cm.from_number IS NOT NULL OR cm.to_number IS NOT NULL)
GROUP BY cm.tenant_id, cm.patient_id, 3
HAVING CASE
WHEN cm.direction = 'inbound' THEN regexp_replace(cm.from_number, '\D', '', 'g')
WHEN cm.direction = 'outbound' THEN regexp_replace(cm.to_number, '\D', '', 'g')
END IS NOT NULL
),
-- Atualiza phones que ja existem
updated AS (
UPDATE public.contact_phones cp
SET
contact_type_id = v_whatsapp_type_id,
whatsapp_linked_at = COALESCE(cp.whatsapp_linked_at, convs.first_msg_at)
FROM convs
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits
RETURNING cp.entity_id, cp.number
)
-- Insere phones que nao existem ainda pro paciente
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, whatsapp_linked_at, position)
SELECT
convs.tenant_id,
'patient',
convs.patient_id,
v_whatsapp_type_id,
convs.phone_digits,
false, -- nao e primary (paciente ja tem outro)
convs.first_msg_at,
100 -- final da lista
FROM convs
WHERE NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits
)
AND length(convs.phone_digits) BETWEEN 8 AND 15;
RAISE NOTICE 'Retroactive WhatsApp link v2 complete';
END $$;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,266 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Emails polimorficos com tipo + principal
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Mesmo padrao dos telefones (migration 20260421000008):
-- - contact_email_types → tipos configuraveis (Principal, Comercial, Pessoal, ...)
-- - contact_emails → emails polimorficos (entity_type + entity_id)
--
-- Triggers mantem patients.email_principal/email_alternativo e medicos.email
-- sincronizados pra nao quebrar codigo legado.
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: contact_email_types
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_email_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
icon TEXT,
is_system BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_email_types_tenant_slug
ON public.contact_email_types (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_email_types_system_slug
ON public.contact_email_types (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_contact_email_types_tenant
ON public.contact_email_types (tenant_id, position);
DROP TRIGGER IF EXISTS trg_contact_email_types_updated_at ON public.contact_email_types;
CREATE TRIGGER trg_contact_email_types_updated_at
BEFORE UPDATE ON public.contact_email_types
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_email_types IS
'Tipos de email (Principal, Comercial, Pessoal, ...). System (tenant_id NULL) + custom.';
-- Seed
INSERT INTO public.contact_email_types (tenant_id, name, slug, icon, is_system, position) VALUES
(NULL, 'Principal', 'principal', 'pi pi-envelope', true, 10),
(NULL, 'Pessoal', 'pessoal', 'pi pi-user', true, 20),
(NULL, 'Comercial', 'comercial', 'pi pi-building', true, 30),
(NULL, 'Faturamento', 'faturamento', 'pi pi-dollar', true, 40),
(NULL, 'Alternativo', 'alternativo', 'pi pi-reply', true, 50)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Tabela: contact_emails (polimorfica)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_emails (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('patient', 'medico')),
entity_id UUID NOT NULL,
contact_email_type_id UUID NOT NULL REFERENCES public.contact_email_types(id) ON DELETE RESTRICT,
email TEXT NOT NULL CHECK (email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'),
is_primary BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contact_emails_entity
ON public.contact_emails (tenant_id, entity_type, entity_id, position);
CREATE INDEX IF NOT EXISTS idx_contact_emails_email
ON public.contact_emails (tenant_id, email);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_emails_primary
ON public.contact_emails (entity_type, entity_id)
WHERE is_primary = true;
DROP TRIGGER IF EXISTS trg_contact_emails_updated_at ON public.contact_emails;
CREATE TRIGGER trg_contact_emails_updated_at
BEFORE UPDATE ON public.contact_emails
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_emails IS
'Emails polimorficos (patients, medicos, ...). Max 1 primary por entidade. Triggers sincronizam campos legados.';
-- ---------------------------------------------------------------------------
-- Trigger: sincroniza campos legados apos mudanca
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields() RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
SELECT email INTO v_primary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
SELECT email INTO v_secondary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET email_principal = v_primary,
email_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE public.medicos
SET email = v_primary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END;
$$;
DROP TRIGGER IF EXISTS trg_contact_emails_sync_legacy ON public.contact_emails;
CREATE TRIGGER trg_contact_emails_sync_legacy
AFTER INSERT OR UPDATE OR DELETE ON public.contact_emails
FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields();
-- ---------------------------------------------------------------------------
-- Backfill: migra emails existentes
-- ---------------------------------------------------------------------------
DO $$
DECLARE
v_principal_id UUID;
v_alternativo_id UUID;
BEGIN
SELECT id INTO v_principal_id FROM public.contact_email_types WHERE slug = 'principal' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_alternativo_id FROM public.contact_email_types WHERE slug = 'alternativo' AND tenant_id IS NULL LIMIT 1;
-- Patients.email_principal → Principal primary
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
p.tenant_id, 'patient', p.id, v_principal_id, lower(trim(p.email_principal)), true, 10
FROM public.patients p
WHERE p.email_principal IS NOT NULL
AND trim(p.email_principal) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'patient' AND ce.entity_id = p.id
)
ON CONFLICT DO NOTHING;
-- Patients.email_alternativo → Alternativo
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
p.tenant_id, 'patient', p.id, v_alternativo_id, lower(trim(p.email_alternativo)), false, 20
FROM public.patients p
WHERE p.email_alternativo IS NOT NULL
AND trim(p.email_alternativo) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'patient' AND ce.entity_id = p.id
AND ce.email = lower(trim(p.email_alternativo))
)
ON CONFLICT DO NOTHING;
-- Medicos.email → Principal primary
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
m.tenant_id, 'medico', m.id, v_principal_id, lower(trim(m.email)), true, 10
FROM public.medicos m
WHERE m.email IS NOT NULL
AND trim(m.email) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'medico' AND ce.entity_id = m.id
)
ON CONFLICT DO NOTHING;
END $$;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_email_types: select" ON public.contact_email_types;
CREATE POLICY "contact_email_types: select"
ON public.contact_email_types FOR SELECT TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "contact_email_types: manage custom" ON public.contact_email_types;
CREATE POLICY "contact_email_types: manage custom"
ON public.contact_email_types FOR ALL TO authenticated
USING (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
)
)
WITH CHECK (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "contact_emails: all tenant" ON public.contact_emails;
CREATE POLICY "contact_emails: all tenant"
ON public.contact_emails FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_emails.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_emails.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,203 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Atribuicao de conversa a terapeuta (CRM Grupo 3.2)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Uma linha por (tenant_id, thread_key) — UPSERT em cada reatribuicao.
-- Historico (quem atribuiu pra quem e quando) pode ser adicionado depois
-- via trigger INSERT em conversation_assignment_history se virar requisito.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - SELECT: qualquer membro ativo do tenant
-- - INSERT/UPDATE: qualquer membro ativo do tenant (self-assign ou delegar)
-- - DELETE: nao permitido (unassign = UPDATE assigned_to=NULL)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_assignments (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key)
);
CREATE INDEX IF NOT EXISTS idx_conv_assign_tenant_user
ON public.conversation_assignments (tenant_id, assigned_to)
WHERE assigned_to IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_assign_patient
ON public.conversation_assignments (patient_id)
WHERE patient_id IS NOT NULL;
-- Trigger de updated_at
DROP TRIGGER IF EXISTS trg_conv_assign_updated_at ON public.conversation_assignments;
CREATE TRIGGER trg_conv_assign_updated_at
BEFORE UPDATE ON public.conversation_assignments
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_assignments IS
'Atribuicao de threads de conversa a membros do tenant. Uma linha por (tenant_id, thread_key). assigned_to=NULL significa nao atribuida.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_assignments ENABLE ROW LEVEL SECURITY;
-- SELECT: qualquer membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_assign: select tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: select tenant"
ON public.conversation_assignments
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membro ativo do tenant. assigned_by deve ser o proprio user.
-- assigned_to deve ser membro ativo do mesmo tenant (ou NULL).
DROP POLICY IF EXISTS "conv_assign: insert tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: insert tenant"
ON public.conversation_assignments
FOR INSERT
TO authenticated
WITH CHECK (
assigned_by = auth.uid()
AND EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
AND (
assigned_to IS NULL
OR EXISTS (
SELECT 1 FROM public.tenant_members tm2
WHERE tm2.user_id = conversation_assignments.assigned_to
AND tm2.tenant_id = conversation_assignments.tenant_id
AND tm2.status = 'active'
)
)
);
-- UPDATE: membro ativo do tenant pode reatribuir. Mesma validacao pra assigned_to.
DROP POLICY IF EXISTS "conv_assign: update tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: update tenant"
ON public.conversation_assignments
FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
assigned_by = auth.uid()
AND (
assigned_to IS NULL
OR EXISTS (
SELECT 1 FROM public.tenant_members tm2
WHERE tm2.user_id = conversation_assignments.assigned_to
AND tm2.tenant_id = conversation_assignments.tenant_id
AND tm2.status = 'active'
)
)
);
-- DELETE: bloqueado (unassign = UPDATE assigned_to=NULL)
-- --------------------------------------------------------------------------
-- Atualiza view conversation_threads pra incluir assignment
-- --------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown'
)) AS thread_key
FROM public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status,
ca.assigned_to,
ca.assigned_at
FROM latest l
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
LEFT JOIN public.patients p ON p.id = l.patient_id
LEFT JOIN public.conversation_assignments ca
ON ca.tenant_id = l.tenant_id AND ca.thread_key = l.thread_key;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou numero anonimo. Base do Kanban. Inclui assignment atual do thread.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,31 @@
-- ==========================================================================
-- Agencia PSI — Migracao: CPF/CNPJ do tenant (fiscal / Asaas)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Adiciona coluna `cpf_cnpj` em tenants para suportar geracao de cobrancas
-- Asaas (ex: credits WhatsApp via PIX) e, no futuro, notas fiscais do SaaS.
-- Armazena apenas digitos (11 pra CPF, 14 pra CNPJ). Frontend formata.
--
-- Permissoes: admin do tenant (tenant_admin) pode ler/atualizar via RLS
-- existente em tenants. Nao adiciona policy extra aqui.
-- ==========================================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cpf_cnpj TEXT;
-- Permite apenas digitos; comprimento 11 (CPF) ou 14 (CNPJ); NULL tambem ok.
ALTER TABLE public.tenants
DROP CONSTRAINT IF EXISTS tenants_cpf_cnpj_format;
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_cpf_cnpj_format
CHECK (cpf_cnpj IS NULL OR cpf_cnpj ~ '^[0-9]{11}$' OR cpf_cnpj ~ '^[0-9]{14}$');
COMMENT ON COLUMN public.tenants.cpf_cnpj IS
'CPF (11 digitos) ou CNPJ (14 digitos) do titular do tenant. Usado por gateways de pagamento (Asaas). Apenas digitos.';
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================

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