Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a87c29dd0 | |||
| a2f3b9fae4 | |||
| 1594dc9426 | |||
| 31c4f08451 | |||
| 12d5c3b6dc | |||
| a979bdf1de | |||
| a73b82fa86 | |||
| 98fe183bac | |||
| 8b620f9b04 | |||
| 96f4543138 | |||
| dc2363b4e1 | |||
| 4493e78349 | |||
| cdb9ce10ee | |||
| af2395c723 | |||
| dc7826d0b5 | |||
| 218d342181 | |||
| ee82985dc3 | |||
| 423aa5ac2a | |||
| 1243a12ced | |||
| f079192698 | |||
| 02acc88da5 | |||
| d3620f99ea | |||
| d240c6678f | |||
| 120b1e44d8 | |||
| b6bd6fdd89 | |||
| bedbb9bafc | |||
| 77ef06fde7 | |||
| 5741e10e28 | |||
| d58b939e1c | |||
| c3220f159c | |||
| 003f2eb2a6 | |||
| a85716b0ea | |||
| 6b542cd03a | |||
| 07437c9ff4 | |||
| f17e9ee786 | |||
| 9b21642e15 | |||
| ba8348d4a6 | |||
| a7f6bcbe66 | |||
| 05c6746e33 | |||
| b0b636c660 | |||
| 701d9f4fcc | |||
| 34412c6883 | |||
| 3a42b0696d | |||
| 7dd8cde8b4 | |||
| ec56f9429b | |||
| 89bf181742 | |||
| 342defecde | |||
| fff70e4a71 | |||
| 550c4ade44 | |||
| 473e0f026e | |||
| 9f3a047d6d | |||
| 8bf992910d | |||
| fa2b431a56 | |||
| eb42759979 | |||
| c17c547ed2 | |||
| 4f05c2cf1b | |||
| 512bcc979c | |||
| 61bb0d9c26 | |||
| 6c39c58dc8 | |||
| 4e1ebeba13 | |||
| 51c33e73b9 | |||
| 682840f355 | |||
| c6105df98a | |||
| 402def7539 | |||
| 5dc91614ad | |||
| 597f8c05d5 | |||
| 79425a3c9a | |||
| 87a1ac1358 | |||
| 6860628087 | |||
| 134f562a1f | |||
| bbbb08ba9d | |||
| 17f114f32f | |||
| c9afe8f009 | |||
| c7e311b851 | |||
| 0aabea7753 | |||
| 80cce772db | |||
| f1c24242e0 | |||
| b821db6438 | |||
| 0fafc28581 | |||
| 75e67eae5d | |||
| 9a6eb56827 | |||
| 652571da69 | |||
| 30367392ff | |||
| b40116fe5d | |||
| ffd8eab72d | |||
| dee89ccd84 | |||
| 6a8ee52ad8 | |||
| 7516468f78 | |||
| 20d2b3aee4 | |||
| ae1e1388b9 | |||
| 4024469952 | |||
| 661790d577 | |||
| 6807b447cb | |||
| 034c2c0f3d | |||
| 87833d4ec6 | |||
| 049dd91b9b | |||
| e7e3d1beb1 | |||
| aa587e849c | |||
| ee117eafe6 | |||
| b7f3c23ad6 | |||
| 9c518a2b44 | |||
| d7cd2541e4 | |||
| b1e8e010c0 | |||
| 2dae4a11ae | |||
| e7a9bdab5f | |||
| 36402cd0bf | |||
| 6ae651a8ae | |||
| 114d755f84 | |||
| 19caa42f3b | |||
| 4e42881d5e | |||
| 934c620295 | |||
| 8601ac0d70 | |||
| 3ce22dd236 | |||
| cd67f7e9f5 | |||
| de3898878a | |||
| ee2967a075 | |||
| 0956e4facc | |||
| fbfb95648e | |||
| 388e9a4186 | |||
| 1c2a2b6e19 | |||
| 27467bbb68 | |||
| f94a4ae97f | |||
| 5b345c5598 | |||
| 4da0bc2e11 | |||
| f83315baba | |||
| 7d2a405d05 | |||
| b5e00a7022 | |||
| 272c804335 | |||
| 00c4168393 | |||
| 9ead3fdc42 | |||
| 5965b05378 | |||
| 45984e885b | |||
| 3f3f2acc70 | |||
| 5684297243 | |||
| 16dfa02bd1 | |||
| 079509e001 | |||
| 7dc7dcede0 | |||
| 1e74a115de | |||
| 753182cfad | |||
| 3caf5792f8 | |||
| d6423da9b4 | |||
| ec0a24f5c8 | |||
| fad1f4ebd4 | |||
| 1feb7112ff | |||
| c23d0a574f | |||
| e95ed9b585 | |||
| 41c44272a3 | |||
| dba595fd2d | |||
| af8aee9188 |
+404
-103
@@ -1,147 +1,448 @@
|
||||
# HANDOFF — 2026-05-08 (MelissaPaciente — port completo + iteração de UX)
|
||||
# HANDOFF — 2026-05-20 (C10 ✅ + C11 ✅ + C12 ⏳ deferido · testando C13)
|
||||
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página.**
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
|
||||
|
||||
> **🟢 PLANO DE 8 FASES COMPLETO** — `MelissaPaciente.vue` (~2400L) é a versão
|
||||
> Melissa nativa do `PatientProntuario.vue` legacy (3593L). Todas as 7 abas
|
||||
> entregues, wire-up final feito (Dialog → route `/melissa/paciente?id=X`).
|
||||
> 5 composables + utils compartilhados extraídos.
|
||||
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** C10 e C11 fechados.
|
||||
> **C12 fluxo crítico OK no DB mas UX confusa** — adiado pra iterar
|
||||
> pós-Rail/Clínica (memória project_c12_antecipar_iterar). Agora
|
||||
> **testando C13** (edit cobrada — invariante imutabilidade SimplePractice).
|
||||
> Implementação JÁ existe (Fase 6 do commit 1feb711 — Message com cadeado +
|
||||
> AgendaEventoFinanceiroPanel embedded). Só validação visual + persistência.
|
||||
|
||||
> **🟢 ITERAÇÃO PÓS-FASE 8** — Várias rodadas de feedback do user com fixes:
|
||||
> full-width, sidebar "Voltar pra Pacientes" (no lugar de Configurações),
|
||||
> editar inline, openWhatsapp fix, dialog Lançamento, dialog Nova Sessão
|
||||
> com `AgendaEventDialog` real, recorrências do paciente.
|
||||
> **🟢 14 COMMITS NO DIA**. C10 (5/5), C11 (4/4), C12 deferred (DB OK),
|
||||
> reverse transition trava implementada, popover watch sync implementado.
|
||||
> Pós-C13: replicar Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
|
||||
> + iterar C12 UX + doc de ajuda (pendência separada).
|
||||
|
||||
> **🟢 COMMITADO + PUSHED** — Working tree limpa.
|
||||
### C13 — passos de teste (próximo)
|
||||
Paciente: **João Almeida Martins** (sessão 20/05 9:00 realizada + paid R$ 40 maquininha) ou **André Green 20/05** (paid PIX).
|
||||
|
||||
Esperado ao abrir o AgendaEventDialog:
|
||||
- Message azul com cadeado: "Cobrança de R$ X já emitida..."
|
||||
- AgendaEventoFinanceiroPanel renderiza embaixo do Message
|
||||
- Card "Aplicar alterações em" oculto (v-if="!occFinancialRecord")
|
||||
- Só horário/observações editáveis; valor/serviços/tipo travados
|
||||
|
||||
### C11 sub-test results
|
||||
| # | Teste | DB validado |
|
||||
|---|---|---|
|
||||
| 11A | Realizada + markPaid PIX | sessions_used 0→1, record paid R$ 40 PIX |
|
||||
| 11B | Falta + Descontar saldo | sessions_used 1→2, sem multa |
|
||||
| 11C | Falta + Multa SEM consumir | sessions_used stays 2, multa pending R$ 30 |
|
||||
| 11D | Cancelado + default_consume_on_miss=true | sessions_used 2→3, sem multa (>2h) |
|
||||
|
||||
### Bugs descobertos + corrigidos durante C11
|
||||
- UI "Como cobrar?" com options "Já recebi" misturadas → refatorado pra "Já recebi?" radio Sim/Não + select condicional
|
||||
- `billing_contracts` sem coluna `updated_at` → UPDATE falhava silently em Promise.allSettled (root cause do saldo não incrementar). Trocado pra awaits sequenciais com error handling explícito
|
||||
- Reverse transitions deixavam multa órfã → dialog reverse implementado com radio "cancelar pending" + "devolver saldo" + warning pra paid
|
||||
- Botão "Gerar cobrança" em sessão encerrada → bloqueado
|
||||
- Lock total em cancelado/faltou: Editar sessão some, status mudanças disabled exceto Agendada (recovery)
|
||||
- Label "A cobrar R$ X" em pacote saldo state=none → "Aguardando uso do pacote"
|
||||
- Badge $ amber em pacote saldo state=none → suprimido
|
||||
- billing_contract_id não amarrado em alguns flows → link universal antes dos blocos forward
|
||||
- Reverse saldo decrementar: refresh sessions_used FRESH do DB antes do UPDATE (anti-race)
|
||||
|
||||
### Pendências mapeadas pós-C13
|
||||
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot. Fix: guardar ev.id, derivar via computed
|
||||
- ~~Reverse transitions~~ ✓ implementado ahead of schedule
|
||||
- **Cleanup teste**: Otto sessão 5364f631 leftover (não-critical)
|
||||
|
||||
### C10 sub-test results
|
||||
| # | Teste | DB validado | Notas |
|
||||
|---|---|---|---|
|
||||
| A | Realizada sem markPaid | ✅ status=realizado, record=pending | Bubble do C9 funcionou |
|
||||
| A2 | Realizada + markPaid maquininha | ✅ status=realizado, record=paid, payment_method=cartao_maquininha, paid_at set | João Almeida |
|
||||
| B | Faltou + multa R$ 30 (fixed_fee) | ✅ original cancelled + nova multa "Multa por falta · sessão dd/mm/aa" | Otto Rank |
|
||||
| C | Cancelado >2h antecedência | ✅ original cancelled, sem multa | Otto / Karen |
|
||||
| C2 | Cancelado tardio (<2h) full charge | ✅ original cancelled + nova "Taxa de cancelamento tardio · sessão dd/mm/aa" | Karen Horney |
|
||||
|
||||
### Pendências mapeadas durante C10 — pós-C13
|
||||
- **Reverse transitions**: faltou/cancelado → agendado deixa multa órfã. Implementar confirm dialog oferecendo auto-cancelar multa.
|
||||
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot, não acompanha _paymentStateMap. Fix: guardar ev.id, derivar via computed.
|
||||
- **Cleanup teste**: Otto sessão 5364f631 às 19:30 UTC tem record pending R$ 40 leftover do teste A original. Apagar quando convenient.
|
||||
|
||||
Memórias relevantes:
|
||||
- `project_agenda_reverse_transitions.md`
|
||||
- `project_melissa_popover_snapshot.md`
|
||||
|
||||
### Code-fix aplicado em 20/05 (pré-C10)
|
||||
- **`useMelissaAgenda.js:1450-1505`** — `_applyStatusDecisions` agora cancela
|
||||
o `ctx.pendingRecord` quando faltou/cancelado (com ou sem multa). Antes
|
||||
inseria a multa mas DEIXAVA o original pending → cobrança dupla
|
||||
(R$ 200 + R$ 30 = R$ 230). Audit trail vai em `notes` do record
|
||||
cancelado, descrição da multa nova carrega data: "Multa por falta · sessão 20/05/26".
|
||||
- **`useAgendaFinanceiro.js:59`** — fix dormente `'fixed'` → `'fixed_fee'`
|
||||
(off-by-key contra schema; path nunca exercitado na Melissa, mas iria
|
||||
quebrar se algum dia fosse).
|
||||
|
||||
### Financial exceptions seedadas (tenant Bruno Terapeuta / owner Leonardo)
|
||||
- `patient_no_show` → `fixed_fee R$ 30`
|
||||
- `patient_cancellation` → `full`, `min_hours_notice=2`, `default_consume_on_miss=true`
|
||||
|
||||
---
|
||||
|
||||
## 🚦 STATUS — Working tree LIMPA
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 10 (Status change AVULSA)
|
||||
|
||||
```
|
||||
On branch main
|
||||
Your branch is up to date with 'origin/main'.
|
||||
nothing to commit, working tree clean
|
||||
```
|
||||
Doc HTML diz: testar status change numa sessão avulsa com cobrança pendente,
|
||||
mudando entre realizado / faltou / cancelado. As consequências financeiras
|
||||
seguem `financial_exceptions` (regras configuradas pelo terapeuta sobre o
|
||||
que acontece com a cobrança nesses casos).
|
||||
|
||||
Possíveis pacientes pra teste: usar Joyce, Sándor ou outro com cobrança
|
||||
avulsa pendente já criada.
|
||||
|
||||
**Esperado** (depende das `financial_exceptions` configuradas no tenant):
|
||||
- Realizada: status muda; cobrança permanece (caminho default)
|
||||
- Faltou: pode ter regra → cobrança 100% (paciente paga falta) ou cancela
|
||||
- Cancelado: pode ter regra → cancelar cobrança ou cobrar parcial
|
||||
|
||||
Conferir:
|
||||
- `STATUS_TO_EXCEPTION` mapping em `useAgendaFinanceiro.js`
|
||||
- `getFinancialExceptionRule(tenantId, exceptionType)` retorna a regra
|
||||
- `handleStatusChange` orquestra: agenda update + financial adjust
|
||||
|
||||
Após C10: C11 (status change pacote saldo — usar a infra do Usar/Revogar)
|
||||
→ C12 (antecipar pagamento) → C13 (edit cobrada).
|
||||
|
||||
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e
|
||||
**Clínica** (`AgendaClinicaPage.vue`).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Histórico de commits da sessão (mais recente → mais antigo)
|
||||
## 📦 O que foi feito em 20/05 madrugada (C9 + rowGroup financeiro + bubble cobranca-atualizada)
|
||||
|
||||
| # | Hash | Resumo |
|
||||
|---|------|--------|
|
||||
| 24 | `6ad91e7` | passa preset-commitment-id pro AgendaEventDialog (fix botão Salvar sumido) |
|
||||
| 23 | `cf1cd67` | pré-popula eventRow com commitment_id + paciente nome/avatar/status |
|
||||
| 22 | `73788c7` | AgendaEventDialog: lockType auto-seleciona commitment "Sessão" (fix jornada/billing/freq sumidos) |
|
||||
| 21 | `30d09eb` | AgendaEventDialog: props lockType + lockPatient + slot #headerLeft (aditivos) |
|
||||
| 20 | `88dff50` | (REVERTIDO em 30d09eb) usa AgendaEventDialog GLOBAL via inject |
|
||||
| 19 | `b040e15` | header custom do dialog Nova Sessão (ícone + título + nome) |
|
||||
| 18 | `42a39ed` | dialog Nova Sessão usa "Frequência" estilo AgendaEventDialog |
|
||||
| 17 | `9e76e4e` | bloco "Recorrências do paciente" na Tab Agenda |
|
||||
| 16 | `f1d6fba` | dialog nova sessão integra useRecurrence (recorrência semanal) |
|
||||
| 15 | `a8ab13b` | dialog inline nova sessão + createSession mutation |
|
||||
| 14 | `21c71f7` | addFinancial navega pra Financeiro + novo botão Agendar |
|
||||
| 13 | `64005a5` | fix openWhatsapp + dialog inline novo lançamento financeiro |
|
||||
| 12 | `301a712` | editPatient abre PatientCadastroDialog INLINE (sem sair) |
|
||||
| 11 | `5d2c389` | fix sidebar cards encolhendo + gap das abas main |
|
||||
| 10 | `159b80d` | full-width + sidebar "Voltar pra Pacientes" no lugar de Configurações |
|
||||
| 9 | `71ee51d` | **Fase 8** wire-up final (Dialog → route /melissa/paciente?id=X) |
|
||||
| 8 | `167e864` | **Fase 7** Tabs Documentos + Conversas (KPIs + embed componentes existentes) |
|
||||
| 7 | `e7c0f6c` | **Fase 6** Tab Financeiro + mark paid (mutation que legacy não tem) |
|
||||
| 6 | `8a8d2e0` | **Fase 5** Tab Agenda (KPIs + filtros + grupos por mês + ações) |
|
||||
| 5 | `1278e93` | **Fase 4** Tab Prontuário MVP (evolução via session.observacoes) |
|
||||
| 4 | `4fc0e3a` | **Fase 3** Tab Perfil (6 sections stacked + anchors) |
|
||||
| 3 | `ab7526b` | **Fase 2** Tab Visão Geral (4 KPIs + timeline + msgs + notas) |
|
||||
| 2 | `df61cc4` | **Fase 1** Foundation (5 composables + skeleton 7 tabs + slug paciente) |
|
||||
| 1 | `f3f0d83` | (pré-MelissaPaciente) preview teleport 3-way no Agendador/LinkExterno + chrome 6 páginas |
|
||||
### Cenário 9 ✅ (Per-session — Michael Balint 12 × R$ 150)
|
||||
Testado e passou. Criou-se 1 rule + 12 agenda_eventos materializadas + 12 financial_records pending. Sem billing_contract. Cada sessão com badge $ amber individual. **Sem nenhuma `linha de pacote`** no popover (não tem contract → não aparece). Conforme esperado.
|
||||
|
||||
### `/melissa/financeiro-lancamentos` agrupado por paciente
|
||||
- DataTable com `rowGroupMode='subheader'` + `groupRowsBy='patient_id'`
|
||||
- Default: todos os grupos da página expandidos (watcher popula `expandedGroups` com unique patient_ids quando `recordsGrouped` muda)
|
||||
- Header de grupo: avatar pequeno + nome + badge "N lançamento(s)"
|
||||
- Click no chevron contrai/expande (auto via PrimeVue `expandableRowGroups`)
|
||||
- Sort estável: ordena outer por nome do paciente, preserva inner order (pai → filhos de multas/taxas)
|
||||
|
||||
### Bubble-up `@cobranca-atualizada`
|
||||
Antes: `AgendaEventoFinanceiroPanel.@cobranca-atualizada` disparava só `loadOccFinancialRecord` (interno do dialog). O `_paymentStateMap` da agenda ficava stale → card no FC só atualizava ao trocar de view.
|
||||
|
||||
Agora: `AgendaEventDialog._onCobrancaAtualizada` faz duas coisas:
|
||||
1. `loadOccFinancialRecord()` — refresca estado interno do dialog
|
||||
2. `emit('cobranca-atualizada')` — bubble pra MelissaLayout
|
||||
|
||||
MelissaLayout escuta nos 2 dialogs (principal + occurrenceMode) e chama `onCobrancaAtualizada` que dispara `M.refetch() + refetchEventosHoje()`. Resultado: card na agenda passa pra borda verde imediatamente após marcar pago.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Arquivos novos / modificados
|
||||
## 📦 O que foi feito em 19/05 madrugada (C8 + Usar/Revogar saldo + UI de pacote)
|
||||
|
||||
### NOVOS — composables + utils compartilhados
|
||||
- `src/features/patients/composables/usePatientDetail.js` (~108L) — patient + groups + tags
|
||||
- `src/features/patients/composables/usePatientSessions.js` (~155L) — agenda_eventos + computeds + `updateStatus`/`createSession` mutations
|
||||
- `src/features/patients/composables/usePatientFinancial.js` (~155L) — financial_records + computeds + `markPaid`/`markUnpaid`/`createRecord` mutations
|
||||
- `src/features/patients/composables/usePatientMessages.js` (~80L) — conversation_messages + computeds
|
||||
- `src/features/patients/composables/usePatientDocuments.js` (~110L) — documents + computeds (topType, pendentes, sizeTotalFormatted)
|
||||
- `src/features/patients/composables/usePatientRecurrences.js` (~110L) — recurrence_rules + cancel/reactivate + ativas/canceladas computeds
|
||||
- `src/features/patients/utils/patientFormatters.js` (~280L) — fmt* helpers + STATUS_LABEL/SEVERITY + tagStyle (luminance) + DOC_TYPE_LABEL + chConvLabel + recordStatus + RECORD_STATUS_LABEL + WEEKDAY_LABEL + fmtRecurrenceLabel/Fim
|
||||
### Cenário 8 ✅ (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
|
||||
Testado e validado. Contract criado com `charging_style='saldo'`, 0 events materializadas, 0 records. Modelo Cliniko: sessões materializam on-demand via Usar.
|
||||
|
||||
### NOVO — página Melissa
|
||||
- `src/layout/melissa/MelissaPaciente.vue` (~2400L) — 7 abas funcionais, header custom, sidebar com Voltar pra Pacientes, dialog Lançamento inline, dialog Nova Sessão usando AgendaEventDialog real
|
||||
### UI do pacote (saldo + upfront)
|
||||
- **`_ruleContractMap`** em useMelissaAgenda: bulk-load agora popula contract info (id, style, totalSessions, sessionsUsed, packagePrice) por `recurrence_id`. Query usa `recurrence_rules.patient_id` como fonte autoritativa (cobre saldo sem materializadas).
|
||||
- **Normalize** injeta `contract` no evento → popover acessa via `ev.contract`.
|
||||
- **Popover** (`MelissaEventoPanel`): nova linha colorida abaixo do payment:
|
||||
- Saldo: violeta `"Pacote saldo · N/M usadas"` + botão verde **"Usar"** (paymentState=none) OU vermelho **"Revogar"** (paymentState=pending)
|
||||
- Upfront: verde `"Pacote · N/M realizadas"` (sem botão; cobrança já tratada)
|
||||
- **AgendaEventDialog**: info card mt-4 (saldo violeta / upfront emerald) com header (pacote+contador), body (total/per-session/restam), botão "Usar agora" ou "Revogar uso", hint explicando o modelo. Gateado por `occFinancialLoading` (spinner durante carga) pra evitar piscar entre estados.
|
||||
|
||||
### MODIFICADOS — wire-up
|
||||
- `src/layout/melissa/MelissaLayout.vue` — entry SECOES.paciente + render condicional `<MelissaPaciente :patient-id="String(route.query.id || '')" @close="fecharSecao" />`
|
||||
- `src/layout/melissa/MelissaPacientes.vue` — `abrirProntuario` agora navega via `router.push('/melissa/paciente', query: { id })`. Removeu `<PatientProntuario>` Dialog. Watch em `route.query.edit` pra abrir cadastro full quando vem de `MelissaPaciente.editPatient`.
|
||||
- `src/layout/melissa/MelissaAgenda.vue` — `abrirProntuarioPorId` igual. Removeu Dialog legacy.
|
||||
- `src/layout/melissa/composables/useMelissaAgenda.js` — adicionou `onCreateEventoForPatient(patientId)` (não usado mais após reverter inject, mas mantido)
|
||||
- `src/features/agenda/components/AgendaEventDialog.vue` — **3 props aditivas** (lockType, lockPatient, slot #headerLeft) + watch que auto-seleciona commitment "Sessão" quando lockType. **Zero regressão (301 specs passando)**.
|
||||
### Handlers Usar/Revogar atômicos
|
||||
**`onUsarSessao`** em MelissaLayout (aceita payload do popover OU do dialog):
|
||||
1. Materializa virtual se necessário (preserva `determined_commitment_id` da regra)
|
||||
2. Status='realizado' + link `billing_contract_id`
|
||||
3. `create_financial_record_for_session` RPC com per-session amount
|
||||
4. Incrementa `billing_contracts.sessions_used`
|
||||
5. Se atingiu total → contract `status='completed'`
|
||||
6. Toast verde + fecha popover/dialog
|
||||
|
||||
**`onRevogarSessao`** desfaz tudo:
|
||||
1. Cancela financial_record (status='cancelled')
|
||||
2. Decrementa sessions_used (não fica negativo)
|
||||
3. Reativa contract se estava completed
|
||||
4. Status volta pra 'agendado'
|
||||
5. Bloqueia se record já está `paid` (precisa estorno formal pelo Financeiro)
|
||||
6. **Backfill** de `determined_commitment_id` se NULL (fix de legado)
|
||||
|
||||
### Fix: enum status_evento_agenda
|
||||
Era `'realizada'` no insert/update, DB exige `'realizado'` (masculino). Corrigido em todas as ocorrências.
|
||||
|
||||
### Fix: campo "Título" indevido no dialog
|
||||
Sessão sem `determined_commitment_id` → `selectedCommitment=null` → `isSessionEvent=false` → mostra campo Título (que é só pra não-sessão). Fix:
|
||||
- Materialize do Usar inclui `determined_commitment_id` da regra
|
||||
- Update path do Usar (sessão real após revogar) backfilla via query da rule
|
||||
- Revogar também backfilla — garante consistência mesmo sem novo Usar
|
||||
- SQL massivo de backfill disponível no HANDOFF pra limpar rows legadas
|
||||
|
||||
### Fix: "Gerar fatura" não cabe em sessão de saldo
|
||||
Hide do botão "Gerar fatura" no popover quando há `contractInfo`. Geraria cobrança solta sem incrementar saldo → duplicação. Fluxo correto: usar "Usar".
|
||||
|
||||
### Recorrências Aplicadas: cores + badges
|
||||
- Header stats: total **azul**, realizadas **verde**, faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
|
||||
- Pills: badge sólido por status (Realizado=emerald-600, Faltou=amber-600, Cancelado=stone-500, Remarcado=violet-600)
|
||||
|
||||
### Race condition no dialog
|
||||
- AgendaEventDialog mostrava botões "Usar"/"Revogar" baseado em `occFinancialRecord` que carrega async
|
||||
- Durante load (~500ms), botão errado podia aparecer → snap pro correto depois
|
||||
- Fix: spinner "Verificando estado…" enquanto `occFinancialLoading=true`; botões só renderizam após
|
||||
- Popover decidiu manter como está (race window pequena, fechar/reabrir resolve)
|
||||
|
||||
---
|
||||
|
||||
## 🟡 PENDENTES PRA PRÓXIMA SESSÃO
|
||||
## 📦 O que foi feito em 19/05 noite (C7 + lock-edit + propagação cross-week)
|
||||
|
||||
User mencionou: **"tem alguns ajustes pra fazer nessa tela ainda"** após o último fix
|
||||
do botão Salvar (commit `6ad91e7`). Ajustes específicos não foram detalhados ainda
|
||||
— próxima sessão começa pelo feedback do user no dialog Nova Sessão.
|
||||
### Cenário 7 ✅ (Pacote UPFRONT — Ana Souza Ferreira)
|
||||
Testado e validado. Usuária criou Ana, R$ 200/sessão × 4 = R$ 800, marcou como pago em dinheiro pelo Financeiro. Visualização correta em mês AND em semana navegando pelas 4 semanas.
|
||||
|
||||
### Pendentes conhecidos (não ditos pelo user, mas observados)
|
||||
- **PatientProntuario.vue legacy (3593L)** continua existindo intocado. Usado por:
|
||||
- `TherapistDashboard.vue` (homepage role therapist sem Melissa)
|
||||
- `PatientsListPage.vue` (rota `/therapist/patients`)
|
||||
- Quando user troca pra Melissa em `/account/profile`, vê a versão nativa
|
||||
- Pra deletar de vez precisa portar TherapistDashboard + PatientsListPage também
|
||||
- **Tab Prontuário** é MVP usando `agenda_eventos.observacoes` como evolução. Quando schema clínico (`anamnese`, `clinical_notes`, `plano_terapeutico`) for adicionado, vira o real
|
||||
- **2 errors pré-existentes em MelissaLayout.vue** (duplicate key 'financeiro' L242, empty block L1130) — não foram tocados durante o port
|
||||
### Fase 6 (lock-edit cobrada) ativada em Melissa
|
||||
Antes: `loadOccFinancialRecord` tinha guard `if (!props.occurrenceMode) return;` — só carregava em Rail/Clínica (edição de ocorrência). Em Melissa, `sessionPaymentRecord` paralelo alimentava só o Resumo lateral, sem trigger de lock.
|
||||
|
||||
Agora unificado: `occFinancialRecord` carrega em ambos modos:
|
||||
- Card Sessão / Honorários ganha **Tag** (em vez de Select billingType) quando há cobrança
|
||||
- Body do card mostra **Message "Cobrança de R$ X já emitida"** + cadeado
|
||||
- Tipo de cobrança (Particular/Convênio/Gratuito) bloqueado
|
||||
- Edição de serviços/preço bloqueada
|
||||
|
||||
### Propagação cross-week de pacote upfront pago/pendente
|
||||
**Bug descoberto durante C7:** ao navegar pra semanas futuras (onde só virtual da Ana 2/3/4 aparecia, sem real event paid na view), o `_rulePaymentMap` era zerado pelo else branch do bulk-load → virtuais perdiam estado paid.
|
||||
|
||||
Fix em `useMelissaAgenda.js _reloadRange`:
|
||||
- Maps (paymentStateMap, amountMap, rulePaymentMap) inicializados SEMPRE no início
|
||||
- Propagação agora roda **independente de realIds.length** (ie, mesmo em semanas só-com-virtuais)
|
||||
- Coleta `ruleIdsInView` de TODOS eventos da view (reais + virtuais com recurrence_id)
|
||||
- Cross-week query: pra cada rule em view, busca QUALQUER evento sibling (inclusive em outras semanas) + seus records paid/pending → determina estado do contrato
|
||||
- Propaga estado pra eventos reais (via map) + virtuais (via rulePaymentMap acessado pelo normalize)
|
||||
|
||||
### Atalho "Gerar fatura" no popover
|
||||
- Pill amber pequeno ao lado de "A cobrar R$ X" no popover (`paymentVariant === 'none' && !is_occurrence`)
|
||||
- Click → `gerarCobrancaManual` direto, fecha popover pra impedir double-click
|
||||
- Tooltip: "Gerar fatura agora"
|
||||
|
||||
### Info de pacote no popover
|
||||
- Header agora mostra `Sessão · Pacote · N sessões` (computed `seriesLabel` lê de `_raw` do rule)
|
||||
|
||||
### Botão "Excluir série inteira"
|
||||
- Novo emit `delete-series` em `MelissaEventoPanel` + botão ao lado de "Excluir sessão" quando evento tem `recurrence_id`
|
||||
- Handler `onDeleteSeries` em MelissaLayout faz hard delete: `financial_records` pendentes → `agenda_eventos` materializados → `recurrence_rules` (CASCADE leva exceptions + rule_services)
|
||||
- Bloqueia se algum record tem `status='paid'` (estornar primeiro)
|
||||
|
||||
### RPC `create_financial_record_for_session` ignora cancelled
|
||||
**Migration 20260519000001:** idempotência da RPC passou a filtrar `AND status != 'cancelled'` além de `deleted_at IS NULL`. Antes: cancelar cobrança sem querer → todo "Gerar fatura" subsequente retornava o cancelado em vez de criar nova. Toast verde mentindo.
|
||||
|
||||
Memória durável em `memory/project_rpc_idempotency_cancelled.md`.
|
||||
|
||||
### `cancel_session` exception some da agenda
|
||||
- `useRecurrence.expandRules` agora pula ocorrência com `exception.type === 'cancel_session'` (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha)
|
||||
- `patient_missed` / `therapist_canceled` / `holiday_block` permanecem visíveis como histórico
|
||||
|
||||
### `recurrence_exceptions` cancel idempotente
|
||||
- Cancel de ocorrência (virtual e materializada) usa `upsert` com `onConflict: 'recurrence_id,original_date'` — não quebra mais com unique violation quando há exception zumbi de tentativa anterior.
|
||||
|
||||
### Visualização paid/pending de upfront em virtuais
|
||||
- `MelissaEventoPanel.showPaymentRow` antes excluía virtuais incondicionalmente. Agora só esconde quando `paymentState === 'none'` (saldo/sem pacote continua limpo; upfront propagado mostra).
|
||||
- `MelissaAgenda.fcEvents`: removida exigência de `!is_occurrence` no `isPaidSession` e no badge $ pendente. Virtuais herdadas via propagação mostram borda verde/badge amber.
|
||||
|
||||
### `onVerLancamentos` cobre virtual de upfront
|
||||
- Antes: virtual sempre toast "Sem lançamentos". Agora: busca records via siblings da série pra encontrar o do pacote. Saldo/sem pacote continua com toast.
|
||||
|
||||
### Confirmação 3 decisões UX (não codar)
|
||||
Antes de C7, user perguntou e concordou:
|
||||
1. Editar serviço já lançado e pago → **NÃO** (cobrança fiscal imutável)
|
||||
2. Alternar Particular/Convênio/Gratuito em série com cobrança ativa → **NÃO** (mesma razão)
|
||||
3. "Gerar fatura" extra em sessão coberta por contrato upfront → **NÃO** (duplicaria cobrança)
|
||||
Tudo isso o lock-edit (Fase 6 ativada acima) cobre.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Conhecimento adicional acumulado nesta sessão
|
||||
## 📦 O que foi feito em 18/05
|
||||
|
||||
### Decisões arquiteturais
|
||||
1. **`MelissaPaciente` segue o padrão Melissa** (mesmo prefix `mpa-`, glass chrome, sidebar 320px à esquerda, drawer mobile). Diferente das outras Melissa Pages: **largura total** (sem `right: max(...)`) porque conteúdo (KPIs grid + tabelas + timeline) precisa de espaço.
|
||||
### Cenário 4 (Joyce · "Já recebi") ✅
|
||||
- Testado e passou: toast "Cobrança paga R$ 180,00 recebido via PIX", record nasceu `paid + payment_method=pix + paid_at=now()`.
|
||||
|
||||
2. **Reuso do `AgendaEventDialog`** via 2 props aditivas (`lockType` + `lockPatient`) + slot `#headerLeft` — caminho A escolhido após discussão honesta sobre drift risk de duplicação.
|
||||
### Novo indicador: barra esquerda verde para sessão paga
|
||||
- Brainstorm de 6 opções; user escolheu #6 (3 canais visuais distintos por estado).
|
||||
- `MelissaAgenda.vue:395-419` — computa `isPaidSession` (sessão+paciente+não-virtual+`paymentState==='paid'`) e adiciona classe `ma-evt--paid` ao FC event (combina com `ma-evt--inactive-patient` se ambos).
|
||||
- `MelissaAgenda.vue:2325-2335` — CSS força `border-left-color: #10b981 !important` (emerald-500, 4px). `!important` necessário porque FC seta `borderColor` inline. Trata também list view (`.fc-list-event-dot`).
|
||||
- Doc HTML atualizado: legenda "Indicadores visuais" agora descreve **3 estados** (pendente / pago / neutro) com 3 mocks empilhados; estado-alvo do C4 reescrito mencionando a barra verde.
|
||||
- Decisão salva em `memory/project_agenda_payment_indicators.md`.
|
||||
|
||||
3. **Inject vs state local** — `MelissaPaciente` injeta `MELISSA_AGENDA_KEY` SÓ pra ler dados pesados (commitmentOptions, workRules, etc) e mantém state LOCAL pro dialog (sessaoDialogOpen/EventRow/StartISO/EndISO). Não colide com dialog global da Agenda.
|
||||
### Linha "Cobrança" no popover + Resumo do dialog
|
||||
- **Popover `MelissaEventoPanel`** — antes só mostrava amber "A receber R$ X" pra pendente. Agora cobre os 3 estados, com cor + ícone por variante:
|
||||
- `paid` → `pi-check-circle` verde, label **"Pago · R$ X,XX"**
|
||||
- `pending` → `pi-dollar` amber, label **"A receber R$ X (cobrança pendente)"** (mantido)
|
||||
- `none` → `pi-dollar` amber, label **"A cobrar R$ X"** ou **"Cobrança ainda não gerada"** (mantido)
|
||||
- CSS reescrito em 3 modificadores `.evento-row--pay-{paid|pending|none}` (com dark mode).
|
||||
- **Resumo lateral do `AgendaEventDialog`** — nova linha entre `pi-clock` e `pi-map-marker` em ambas as cópias (mobile inline + desktop floating).
|
||||
- Novo ref `sessionPaymentRecord` em `useAgendaEventLifecycle.js:104+` (sem guard de `occurrenceMode`, contrário ao `occFinancialRecord` que continua só pra Rail/Clínica). Loader `loadSessionPaymentRecord` chamado no mesmo lifecycle.
|
||||
- Computed `paymentSummary` em `AgendaEventDialog.vue:951+` retorna `{icon, cls, label}` pra 5 casos: paid (verde + paid_at), overdue (vermelho + due_date), pending (amber + due_date), sem cobrança c/ valor (neutro), sem cobrança s/ valor (neutro).
|
||||
- `@cobranca-atualizada` do `AgendaEventoFinanceiroPanel` agora também dispara `loadSessionPaymentRecord` pra a linha refrescar.
|
||||
- **Importante:** `occFinancialRecord` (que aciona lock-edit) NÃO foi tocado de propósito — esse é território da Fase 6/C13 (Edit cobrada). Manter dois refs separados evita ativar lock prematuro em Melissa.
|
||||
|
||||
4. **Inicialização do dialog**: precisa passar **TANTO** `determined_commitment_id` no eventRow **QUANTO** prop `presetCommitmentId` separada. O resetForm lê o primeiro pra popular `form.commitment_id`; o lifecycle lê o segundo pra decidir step inicial. Sem ambos, cair em race condition (lifecycle reset desfaz selectCommitment).
|
||||
### Preparação do C5 (Sándor + Unimed Nacional) — UX de convênio refinado (3 issues)
|
||||
|
||||
5. **Pré-popular paciente_nome/avatar/status no eventRow** é obrigatório pra não-edit — o composer só faz fetch async do nome quando isEdit=true.
|
||||
User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
|
||||
|
||||
### Hotspots de drift no `AgendaEventDialog`
|
||||
Arquivo tem 5 composables que fazem o trabalho pesado: `useAgendaEventComposer` (state + computeds), `useAgendaEventActions` (save/delete), `useAgendaEventLifecycle` (watchers + init), `useAgendaEventPickerBilling` (selectCommitment, paciente picker), `agendaEventHelpers` (utils). Mexer aqui é seguro pelo coverage de **301 specs**.
|
||||
1. **Botão "Cadastrar" do procedimento navegava pra `/pages/notfound`**
|
||||
- Root cause: `goToConveniosConfig` em `AgendaEventDialog.vue` prefixava com `/therapist` ou `/admin`, mas `/configuracoes/*` é rota **raiz** sob `AppLayout` (sibling, não filho). Em Melissa, convênios mora dentro do próprio layout via `secao: 'cfg-convenios'` (sem URL própria).
|
||||
- Fix descartado: o user não queria sair da agenda. Em vez disso, criamos um quick-create inline (ver #2). `goToConveniosConfig` foi removida (dead code virou armadilha).
|
||||
|
||||
### Slug `/melissa/paciente?id=<uuid>`
|
||||
Registrado em `MelissaLayout.vue` SECOES + adicionado a `MELISSA_NON_CONFIG_SLUGS`. ID vem via query param. Funciona pra deep-link.
|
||||
2. **Quick-create de procedimento inline (sem sair da agenda)**
|
||||
- Novo componente `InsurancePlanServiceQuickCreateDialog.vue` (modelo do `InsurancePlanQuickCreateDialog`). 2 campos: nome do procedimento + valor que o convênio paga. Insere em `insurance_plan_services` pro `insurance_plan_id` ativo.
|
||||
- Wiring em `useAgendaEventLifecycle.js`: novo `planServiceQuickDlgOpen` + `openPlanServiceQuickCreate()` + `onPlanServiceCreated(service)`. Após criar, recarrega `loadInsurancePlans` e **auto-seleciona** o novo procedimento **só quando nada estava selecionado antes** (preserva escolha quando user já tinha selecionado X e está só cadastrando Y pra próxima).
|
||||
- UI refatorada (`AgendaEventDialog.vue:3110+`): a caixa cinza com botão "Cadastrar" agora aparece **sempre** que um convênio está selecionado. Quando 0 procedimentos: **"Este convênio ainda não tem procedimentos cadastrados."** Quando 1+: **"Se quiser adicionar mais procedimentos a este convênio:"**.
|
||||
- `planServiceQuickDlgOpen` adicionado ao `anyChildDialogOpen` pra esconder o Resumo flutuante enquanto o quick-create está aberto.
|
||||
|
||||
3. **Botão "+ Novo convênio" faltando em `/melissa/cfg-convenios` (e na rota canônica também)**
|
||||
- Root cause: `ConfiguracoesConveniosPage.vue` tinha o form de "Novo convênio" condicionado a `addingNew === true`, mas **nenhum botão setava esse flag**. Empty state mandava "Clique em 'Novo convênio'" sem botão pra clicar.
|
||||
- Fix: toolbar simples no topo do template `<template v-else>` com `<Button label="Novo convênio" icon="pi pi-plus" @click="addingNew = true">`. Empty state corrigida pra apontar pro botão certo.
|
||||
|
||||
### Hint contextual abaixo do card Sessão / Honorários
|
||||
|
||||
- User pediu mensagem clarificando que "Nº da guia" é opcional em convênio.
|
||||
- **Tentativa 1 (errou o lugar):** coloquei o hint em `AgendaEventDialog.vue:1826` dentro do bloco `v-if="occurrenceMode"` (só edita ocorrência em Rail/Clínica). User não viu.
|
||||
- **Tentativa 2 (correta):** adicionado em `AgendaEventDialog.vue:2305+` (fluxo principal Melissa, fora do occurrenceMode). Mantive a tentativa 1 também — não atrapalha, só ativa em outro contexto.
|
||||
- Texto: convênio = **"Nº da guia é opcional — você pode salvar a sessão e preencher depois, quando o convênio responder."** Gratuito = **"Sessão gratuita — nenhum lançamento será gerado no Financeiro."** Particular = sem hint (não há ambiguidade).
|
||||
- Condição: `isSessionEvent && !occFinancialRecord && billingType === 'convenio'|'gratuito'`. Esconde quando há cobrança paga/pendente (lock-edit) — Message do panel já cobre.
|
||||
- CSS: `.aed-billing-hint` em `AgendaEventDialog.vue:3558+` — barra esquerda primary, fundo neutro leve, fonte 0.78rem.
|
||||
- Label do "Nº da Guia" no service-picker dialog também ganhou **(opcional)**.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Comandos úteis
|
||||
## 📦 O que foi feito antes (16/05 noite/madrugada)
|
||||
|
||||
```bash
|
||||
# Specs do agenda (regression check pro AgendaEventDialog)
|
||||
npx vitest run src/features/agenda/composables/__tests__
|
||||
### Cenário 1 (Bloqueio) ✅
|
||||
|
||||
# Lint só dos arquivos do MelissaPaciente
|
||||
npx eslint src/layout/melissa/MelissaPaciente.vue src/features/patients/composables/usePatient*.js src/features/patients/utils/patientFormatters.js
|
||||
1. **Fix `bloqueioCobrindo is not defined`** — função estava no escopo de `useMelissaAgenda` mas `onSelectTime` mora no `_buildHandlers` (outro escopo). Passada via `deps`. Mesmo padrão que `_openStatusDialog`.
|
||||
2. **Soft warn dentro do dialog** em vez de toast atrás do overlay — novo ref `dialogBlockOverlap` no composable + nova prop `blockOverlapWarning` no `AgendaEventDialog` + Message warn no topo do step 1. Reset nos outros openers (`onCreateEvento`, `onCreateEventoForPatient`, `onEditEvento`).
|
||||
3. **Doc HTML Cenário 1 expandido** em 1a (criar bloqueio) + 1b (agendar sobre bloqueio), com mock visual da Message + comparação com agendador público (que veta).
|
||||
|
||||
# Testar visualmente
|
||||
npm run dev
|
||||
# → http://localhost:5173/melissa/paciente?id=<uuid-real-de-paciente>
|
||||
```
|
||||
### Cenário 2 (Avulsa sem cobrança) ✅
|
||||
|
||||
4. **Fonte da hint chargeMode** subiu de `0.72rem` → `0.8125rem` (acima de `text-xs`).
|
||||
5. **Card Frequência avulsa** refeito — antes era empty state convidando configurar; agora renderiza com `.aed-pay-summary` (mesma estrutura do estado configurado: "Tipo: Avulsa · Sessão única, sem repetição" + botão Editar).
|
||||
6. Doc HTML Cenário 2 atualizado.
|
||||
|
||||
### Cenário 3 (Avulsa cobrar ao salvar) ✅
|
||||
|
||||
7. **Refactor payment: `paymentSettlement` → `paymentMethod` + `markPaidNow`**
|
||||
- UI antiga misturava método e status num único Select ("Já recebi — PIX").
|
||||
- Agora 2 controles: Select forma (Enviar link / PIX / Dinheiro / Depósito / Cartão maquininha — SEM prefixo "Já recebi —") + SelectButton status (Cobrança pendente / Já recebi (dar baixa)).
|
||||
- SelectButton só aparece quando método ≠ link (Asaas só liquida via webhook).
|
||||
- Watcher força `markPaidNow=false` se voltar pra 'link'.
|
||||
- Wire: AgendaEventDialog → useAgendaEventActions → useMelissaAgenda (handler avulsa + `_createPackageContract`).
|
||||
8. **Indicadores visuais de pagamento** (novidade da sessão):
|
||||
- Bulk-load de `financial_records` em `_reloadRange` etapa 4 (1 query única, mapa eventId → 'paid' | 'pending' | 'none').
|
||||
- `normalizeForMelissa` agora injeta `paymentState` + `price` no evento.
|
||||
- **Badge $ no canto** dos eventos da agenda — círculo amber 16px no canto superior direito. Só pra sessão + paciente + não-virtual + paymentState !== 'paid'.
|
||||
- **Linha "A receber"** no popover (`MelissaEventoPanel`) — texto adaptativo: "A receber R$ X (cobrança pendente)" se pending, "A cobrar R$ X" se none, "Cobrança ainda não gerada" se sem valor.
|
||||
9. **🐛 Bug fix `pickDbFields` faltando `modalidade`** — sessões avulsas eram salvas sem modalidade, DB caía no default 'presencial' independente da escolha. Adicionado ao whitelist em `useMelissaAgenda.js:74`. **TODAS as sessões avulsas criadas no Melissa antes desse fix estão como 'presencial' no DB** — pode precisar rodar UPDATE manual no banco se quiser corrigir histórico. Gotcha salvo em `memory/project_pickdbfields_whitelist.md`.
|
||||
10. **Doc HTML atualizada amplamente**:
|
||||
- Nova seção topo `★ Indicadores visuais de pagamento` com mocks (badge $ + linha popover) e link em violeta no TOC.
|
||||
- Caixa violeta "Indicadores visuais" em cada cenário relevante (C2-C9).
|
||||
- C4 ganhou caixa verde "estado-alvo" (sem badge, sem linha — pago).
|
||||
- Receita do C3 e C4 atualizadas com os 3 controles (Cobrança ao salvar / Forma de pagamento / Status do pagamento) e opções limpas (sem prefixo "Já recebi —").
|
||||
|
||||
---
|
||||
|
||||
## ▶️ Próxima sessão — onde retomar
|
||||
## 🧭 Onde estamos no plano de 9 fases
|
||||
|
||||
1. **Ler primeiro**: este HANDOFF.md (você já está nele)
|
||||
2. **Aguardar feedback do user** sobre ajustes específicos no dialog Nova Sessão (mencionou que tem mais alguma coisa)
|
||||
3. **Possíveis frentes**:
|
||||
- Polish do dialog Nova Sessão pós-feedback
|
||||
- Port do TherapistDashboard pra remover dependência do PatientProntuario legacy
|
||||
- Schema clínico (anamnese/evolução) pra Tab Prontuário sair do MVP
|
||||
4. **Antes de mexer em `AgendaEventDialog`**: rodar `npx vitest run src/features/agenda/composables/__tests__` (301 specs) pra confirmar baseline limpo
|
||||
| Fase | Status |
|
||||
|---|---|
|
||||
| **1** Compromisso SEM paciente | ✅ |
|
||||
| **2** Compromisso COM paciente | ✅ testado (C1-C3 done) |
|
||||
| **3** Recorrência + replicar occurrenceMode Rail/Clínica | ⏳ |
|
||||
| **4** Modo disparo cobrança híbrido | ⚠️ parcial |
|
||||
| **5** Status change → confirm dialog | 🔄 Melissa codado + indicadores visuais done; falta testar (C10-C12) + replicar Rail/Clínica |
|
||||
| **6** Edit cobrada | ✅ |
|
||||
| **7** Pagamento separado | ⏳ |
|
||||
| **8** Refund/credit note | ⏳ |
|
||||
| **9** Plano Inicial | 📋 |
|
||||
|
||||
Boa sessão!
|
||||
---
|
||||
|
||||
## 📋 Roteiro de testes restantes (`src/docs/agenda-compromisso-financeiro-cenarios.html`)
|
||||
|
||||
| # | Cenário | Status |
|
||||
|---|---|---|
|
||||
| 1 | Bloqueio (criar + agendar sobre) | ✅ |
|
||||
| 2 | Avulsa sem cobrança | ✅ |
|
||||
| 3 | Avulsa cobrar ao salvar | ✅ |
|
||||
| 4 | Avulsa "já recebi" no salvar | ✅ |
|
||||
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
|
||||
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
||||
| 7 | Pacote upfront (Ana Souza Ferreira 4 × R$ 200) | ✅ |
|
||||
| 8 | Pacote saldo (Otávio 12 × R$ 50) | ✅ |
|
||||
| 9 | 1 por sessão (Michael Balint 12 × R$ 150) | ✅ |
|
||||
| **10** | **Status change avulsa (realizado/faltou/cancelado)** | 🔴 **PRÓXIMO** |
|
||||
| 10 | Status change avulsa (realizado/faltou/cancelado) | ⏳ |
|
||||
| 11 | Status change pacote saldo | ⏳ |
|
||||
| 12 | Antecipar pagamento (Carl Jung) | ⏳ |
|
||||
| 13 | Edit cobrada | ⏳ (parcialmente — lock ativo em Melissa pós-19/05 noite) |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Como retomar amanhã (cego)
|
||||
|
||||
1. `git status` — confirmar working tree intacto
|
||||
2. **Ler HANDOFF até o fim**
|
||||
3. Abrir `src/docs/agenda-compromisso-financeiro-cenarios.html` no browser pra ver o estado atual do doc viva
|
||||
4. **Começar pelo Cenário 4** (Joyce, "Já recebi (dar baixa)")
|
||||
5. Cada cenário que passar:
|
||||
- Atualizar status pra ✅ aqui no HANDOFF
|
||||
- Se descobrir bug ou texto divergente, corrigir código + doc na hora
|
||||
6. Quando todos os 13 passarem: replicar em **Rail** e **Clínica**
|
||||
7. Adicionar `professional_cancellation` no `STATUS_TO_EXCEPTION`
|
||||
8. Marcar Fase 5 como ✅
|
||||
9. Decidir Fase 4 (modo disparo cobrança híbrido) OU Fase 3 (replicar occurrenceMode)
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Pendência IMPORTANTE — não esquecer
|
||||
|
||||
**Pós-Fase 9** (quando concluirmos TODAS as fases 1-9):
|
||||
- User vai passar prompt específico pra criar **documentação completa da parte de ajuda** do sistema
|
||||
- Está em `memory/project_pendencia_doc_ajuda.md`
|
||||
- O doc `agenda-compromisso-financeiro-cenarios.html` já está sendo escrito de forma que vira a doc final pra usuário (cada teste validado vira parte da doc)
|
||||
|
||||
**Histórico modalidade='presencial' no DB:**
|
||||
- Bug do `pickDbFields` afetou TODAS as sessões avulsas criadas no Melissa até 16/05/2026
|
||||
- Se quiser corrigir histórico, rodar UPDATE manual identificando sessões cuja modalidade visual era online (não há como saber retroativamente — perdido)
|
||||
- Going forward o fix já cobre
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Gotchas duráveis (atualizados)
|
||||
|
||||
- **`MelissaBloqueios.vue` admin ≠ `BloqueioDialog` (4 modos)** — casos distintos
|
||||
- **`agenda_excecoes` foi dropada** em 13/05
|
||||
- **`financial_records.type` undefined sem `type` no BASE_SELECT** — fix 14/05 cedo
|
||||
- **`financial_records.description` undefined sem `description` no BASE_SELECT** — fix 14/05 noite
|
||||
- **`handleStatusChange` em `useAgendaFinanceiro.js` está ÓRFÃO** — não reativar
|
||||
- **`_openStatusDialog` + `bloqueioCobrindo` + `dialogBlockOverlap`** declarados no `useMelissaAgenda` mas usados em `_buildHandlers` — passados via `deps`. **NÃO ESQUECER ao replicar em Rail/Clínica**
|
||||
- **`billing_contracts.charging_style`** distingue upfront/saldo/per_session
|
||||
- **Ocorrência virtual tem `id="rec::<rule>::<date>"`** — detectar via `typeof === 'string' && startsWith('rec::')` antes de query Supabase
|
||||
- **`chargeMode` default dinâmico:** `'session'` em avulsa, `'none'` em recorrente
|
||||
- **Toast atrás do overlay do dialog** — usar Message no topo do dialog em vez de toast quando contexto for dentro de dialog modal
|
||||
- **Cuidado com `pickDbFields` whitelist** — `useMelissaAgenda.js:74` descarta campos não listados silenciosamente. Sintoma: campo escolhido na UI mas DB tem valor default. Memória: `memory/project_pickdbfields_whitelist.md`
|
||||
- **`paymentSettlement` foi renomeado** em 16/05 — agora `paymentMethod` (string) + `markPaidNow` (bool). Handler aplica `payment_method` sempre, `status='paid'` só quando markPaidNow=true && method!='link'
|
||||
- **Bulk-load de paymentState em `_reloadRange` etapa 4** — 1 query única em `financial_records` mapeada por `agenda_evento_id`. Anota `paymentState` no normalize. Badge na agenda + linha popover lêem daqui
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Decisões persistidas (memory/)
|
||||
|
||||
**Indicadores visuais (16/05):**
|
||||
- Badge $ no canto: só sessão + paciente + não-virtual + !paid
|
||||
- Linha popover: 3 textos (a receber pending / a cobrar none / cobrança não gerada)
|
||||
- Bulk-load 1x por _reloadRange, não query por evento
|
||||
- Ocorrências virtuais sempre paymentState='none' (cobertas por contrato)
|
||||
|
||||
**Payment refactor (16/05):**
|
||||
- Separar método (forma) de status (já pago?) — controles independentes na UI
|
||||
- Método 'link' (Asaas) força markPaidNow=false (gateway externo)
|
||||
- Wire format: `arg.paymentMethod` + `arg.markPaidNow` (no lugar de `arg.paymentSettlement`)
|
||||
|
||||
**Bugs evitar repetir:**
|
||||
- Sempre adicionar campo novo ao `pickDbFields.allowed` quando adicionar coluna em agenda_eventos
|
||||
- Sempre adicionar campo novo ao `BASE_SELECT` quando query custom
|
||||
- Detectar `is_occurrence` ou `rec::` antes de query por UUID
|
||||
- Refs/funções do composable principal NÃO ficam acessíveis em `_buildHandlers` — passar via `deps`
|
||||
- Toast dentro de dialog modal fica atrás do overlay — usar Message
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
# Freemium / PLG
|
||||
|
||||
Épico iniciado em 2026-06-13, branch `feat/freemium-plg` (sobre [[Migracao Schema-per-Tenant]]). Objetivo: qualquer visitante cria conta gratuita sozinho, confirma e-mail, e o ambiente do tenant é provisionado automaticamente. Plano gratuito limitado + botão "Upgrade PRO". Blueprint-diretor: `novo-rumo.txt` (raiz), vindo do sistema-irmão (sindicato) e adaptado a clínica.
|
||||
|
||||
## Descoberta (Fase 0) — o que já existia
|
||||
|
||||
O sistema já estava ~70-85% pronto:
|
||||
- **Planos free existem**: `clinic_free`, `therapist_free` (+ supervisor/patient) com `plan_features.limits` semeado (`clinic_free` → `clinic_calendar {max_patients:30, max_therapists:5}`, `online_scheduling {sessions_per_month:40}`, `reminders {reminders_per_month:50}`, `documents.upload {max_storage_mb:500}`; 14 features premium OFF).
|
||||
- **Feature gating completo**: `entitlementsStore.js` (views `v_tenant_entitlements`/`v_user_entitlements`), `FeatureGate.vue`, guard `meta.feature` → `/upgrade` (`guards.js:814`), badge PRO no menu.
|
||||
- **Provisionamento schema-per-tenant**: `ensure_personal_tenant`/`provision_account_tenant` → `clone_tenant_template`. Setup Wizard.
|
||||
- **Signup self-service**: `/lp` (pricing dinâmico de `v_public_pricing`) → `/auth/signup` (`Signup.vue:219` `signUp` inline, cria intent só no pago).
|
||||
- RPCs `activate_subscription_from_intent`, `change_subscription_plan`. `tenants.slug` 100% populado.
|
||||
|
||||
**Gap confirmado:** limites semeados mas **ninguém lê/enforça**. Sem confirmação de e-mail (`enable_confirmations=false`), sem /onboarding, signup só coleta email+senha, sem welcome email, sem os extras.
|
||||
|
||||
## Decisões (Fase 0.5)
|
||||
|
||||
1. **Modelo do blueprint** — confirmação de e-mail ON; signup grava escolha em `raw_user_meta_data` + signOut-local + tela "confirme e-mail"; provisionamento+intent viram RPCs idempotentes no 1º login (`auto_provision_free_tenant(p_slug_override)`, `processar_pos_signup`); guard manda logado-sem-tenant → `/onboarding`. Reescreve o signup inline.
|
||||
2. **Pacientes** = recurso limitado. Trigger BEFORE INSERT em `patients` lê limits em runtime, resolve tenant por `TG_TABLE_SCHEMA`, conta linhas vivas, `RAISE 'PLAN_LIMIT_REACHED|patients|<n>'`. clinic_free=30, therapist_free=20. No template + backfill 9 schemas.
|
||||
3. **Slug escolhido** no signup (sugestão sanitizada + `slug_disponivel(p_slug)→{ok,motivo}`), imutável, trava 3 camadas.
|
||||
4. **Todos os 4 extras**: /saas/usuarios + `notify_all_devs`; esqueci-email (magic link por slug, dica mascarada); blacklist (email|slug); root_redirect.
|
||||
|
||||
## Pegadinhas (do blueprint, ⚠️ caras no irmão)
|
||||
|
||||
- **#1** Signup sem sessão (confirmação ON) → tudo com `auth.uid()` quebra em silêncio. Gravar escolha em metadata, processar pós-confirmação.
|
||||
- **#2** signOut `scope:'local'` se não veio sessão — senão vaza sessão anterior e joga no painel errado.
|
||||
- **#3** Logado-sem-tenant nunca cai em painel quebrado → `/onboarding` resolve estados (provisionando, slug-colidiu, pago-aguardando, sem-acesso, erro).
|
||||
- **#4** Sino de notificação singleton precisa re-buscar ao trocar de user (logout+login).
|
||||
|
||||
## Divergência de infra
|
||||
|
||||
Blueprint pede welcome email via **Resend**; aqui é **SMTP/Mailpit** (`process-email-queue`). Reusar o pipeline SMTP existente (best-effort), não Resend.
|
||||
|
||||
## Fases
|
||||
|
||||
- **F1** ✅ DONE (2026-06-13) — therapist_free ganhou max_patients=20; trigger `enforce_patient_plan_limit` em patients (lê `plan_features.limits` em runtime, resolve plano via `tenant_active_plan_id`, conta vivos, RAISE `PLAN_LIMIT_REACHED|patients|n`); helpers globais + wiring + backfill 9 schemas. Front: `utils/planLimit.js` (toast com CTA via grupo system-alerts) nos 3 pontos de criação de paciente + botão **Upgrade PRO** no AppTopbar quando plano é free. Migrations: `20260613000005_*` + `manual/freemium_f1_plan_limits.supabase_admin.sql`. Testado em ROLLBACK (clinic_free bloqueia em 30, therapist_free em 20, PRO ilimitado).
|
||||
- **F2** 🟡 NÚCLEO DONE (2026-06-13) — `enable_confirmations=true` (config.toml, gitignored, ativa no restart do stack); RPCs `slug_disponivel`/`auto_provision_free_tenant`/`processar_pos_signup` (manual/freemium_f2_provisioning.supabase_admin.sql, testados em ROLLBACK clínica+terapeuta); **fix de regressão** `log_audit_change` (migration 20260613000006) que quebrava INSERT em tenant_members; Signup.vue reescrito (kind+nome+slug ao vivo+metadata, signOut-local + tela confirme-email); OnboardingPage.vue (provision+estados slug-colidiu/erro); guard → /onboarding; rota registrada. Build OK. **Restam (polish):** welcome email best-effort (infra SMTP schema-per-tenant) + apresentação do free na vitrine (public_name/preço "Grátis"/bullets — os planos já são is_visible=true mas sem nome/preço).
|
||||
- **F3** — Extras (4).
|
||||
- **F4** — Deploy (hosted, dirigido pelo Leonardo).
|
||||
|
||||
Método: commits por assunto; cada migration testada em transação com ROLLBACK antes de aplicar; build a cada bloco front.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Migração Schema-per-Tenant
|
||||
|
||||
**Status:** F6.0 + F6.1 concluídas e verificadas (2026-06-13). Dados dos 9 tenants migrados pros schemas, AINDA espelhados em public (nada dropado), backup em `database-novo/backups/pre-F6/`. Próximo: **F6.2 (rewrite das funções — push dedicado)**, depois checkpoint de teste do app, depois F6.3 (DROP, só com OK do Leonardo). F0-F2 em `main`; F3+/F1b/F5/F6.0-1 no branch.
|
||||
|
||||
## F6.0 + F6.1 — entregue (commit 003f2eb)
|
||||
- F6.0 (migration 20260613000003): clone dos 9 tenants reais (schemas vazios, expostos no PostgREST via trigger F5).
|
||||
- F6.1 (manual `database-novo/manual/f6_1_migrate_data.supabase_admin.sql`, rodar como supabase_admin): copia dados public→schemas com `session_replication_role=replica`. Tabelas com tenant_id por filtro; 3 filhas sem tenant_id (commitment_services, insurance_plan_services, recurrence_rule_services) por JOIN no pai; exclui colunas GENERATED (`net_amount`, `margin_brl`); reset de 4 sequences; ON CONFLICT DO NOTHING.
|
||||
- **Verificado**: contagens public vs schemas batem (35 patients, 37 eventos, 355 mensagens, 54 financeiro, 13 commitment_services…). notification_templates "146" = 144 seeds (16×9) + 2 tenant — esperado.
|
||||
- Gotcha: colunas GENERATED não aceitam INSERT → excluir via `is_generated='NEVER'`. DO block é atômico → erro no meio dá rollback total (re-rodar é seguro com ON CONFLICT).
|
||||
|
||||
## F6.2 — PLANO (rewrite de 66 funções + split notifications) — próximo push
|
||||
Decisões já tomadas: parar antes do DROP pra testar app; split notifications JUNTO na F6.
|
||||
|
||||
Inventário de triggers (81 attachments nas tabelas tenant em public):
|
||||
- **Schema-agnósticos** (só NEW/OLD, sem refs a tabela): família `set_updated_at`/`set_*_updated_at`, `fn_clinical_notes_updated_at`, `prevent_promoting_to_system`, `prevent_system_group_changes`, `patients_validate_member_consistency`, `fn_agenda_regras_semanais_no_overlap` → anexar nos schemas como estão.
|
||||
- **Schema-aware** (~10, escrevem em OUTRA tabela tenant / audit / notifications) → reescrever com `set_config('search_path', TG_TABLE_SCHEMA||',public,pg_temp', true)` e `tenant_id_for_schema(TG_TABLE_SCHEMA)` onde precisam do tenant_id (audit_logs/notifications_sistema são globais): `auto_create_financial_record_from_session`, `sync_busy_mirror_agenda_eventos`, `notify_on_session_status`, `fanout_inbound_message_to_notifications`, `log_audit_change`, `fn_sla_resolve_on_outbound`, `fn_clinical_note_version`, `fn_document_signature_timeline`, `fn_documents_timeline_insert`, `trg_fn_patient_status_history/timeline/risco`, `sync_legacy_email/phone_fields`, `agenda_cfg_sync`, `cancel_notifications_*`, `fn_notify_agenda_status_change`, `trg_fn_financial_records_auto_overdue`.
|
||||
- **`financial_records_inject_tenant` → OBSOLETO** no schema (não há coluna tenant_id) — NÃO anexar.
|
||||
|
||||
Sub-lotes propostos (cada um com smoke test + commit):
|
||||
- **A** ✅ DONE (commit d58b939, migration 20260613000004): `attach_agnostic_triggers(schema)` recria os triggers agnósticos (8 setters updated_at + 2 prevent_*) nos 9 schemas (54 triggers/schema). Smoke: set_updated_at dispara. Wiring no clone fica pro fim da F6.2.
|
||||
- **B** ✅ DONE (commit 5741e10, manual/f6_2b_schema_aware_triggers.supabase_admin.sql — roda como supabase_admin pois trigger fns são owned por supabase_admin). 14 funcs reescritas (set_config search_path dinâmico + tenant_id_for_schema p/ audit_logs global); sync_busy_mirror cross-tenant via tenant_schema_for+EXECUTE format; financial_records_inject_tenant obsoleto (não anexado). Detach dos 14 de public + attach 22 triggers/schema (defs reais, tenant_id removido de WHEN/UPDATE OF). Smoke: sessão→realizado cria financial_record no schema + audit roteia tenant_id certo + timeline OK.
|
||||
- **Gotchas Lote B**: (1) trigger functions owned por supabase_admin → CREATE OR REPLACE só como supabase_admin (vira manual, não db.cjs). (2) Triggers reais tinham `WHEN (new.tenant_id=new.owner_id)` e `UPDATE OF tenant_id,...` → quebram no schema; remover tenant_id dos WHEN/colunas ao re-anexar. (3) Estratégia hybrid: detach de public pra função reescrita não rodar errada lá.
|
||||
- **C** ✅ DONE (commit bedbb9b, manual/f6_2c_notifications_split.supabase_admin.sql). DESCOBERTA: neste projeto TODAS as notifs atuais (inbound_message, session_status, system_alert, new_patient) são tenant-LOCAIS — avisos cross-tenant do SaaS vivem em `global_notices`, não em notifications. Então: notifications fica tenant-local (já nos schemas); `public.notifications_sistema` criado como canal SaaS→tenant FUTURO (vazio hoje) + RLS + realtime + notify_user_sistema(). 4 notif-triggers tenant reescritos schema-aware + detach public + attach (5/schema); notify_on_intake/scheduling disparam em tabelas PUBLIC (F1b) → roteiam pro schema via tenant_schema_for+EXECUTE format; cancel_patient_pending herda search_path do chamador. Smoke: msg inbound → notif no schema, destinatário certo. Frontend notificationStore.js: load 2 fontes + merge por created_at + `_origem`; realtime 2 canais; markRead/archive roteiam por _origem. conversation_messages.id é bigint (gotcha no teste).
|
||||
- **D** ✅ DONE (commit d240c66, manual/f6_2d_user_rpcs.supabase_admin.sql + 18 sites FE; build passa). 14 RPCs (list_my_signatures→F). Helper `_tenant_route(p_tenant_id)` valida + RETORNA schema (não seta — set_config em helper com SET search_path próprio é revertido na saída! cada RPC faz o set_config). Grupo3 RETURNS<tabela>→jsonb (mark_as_paid, create_financial_record_for_session, mark_payout, create_therapist_payout). FE: p_tenant_id de activeTenantId; SETOF→jsonb transparente (nenhum consumidor indexava array). Smoke: mark_as_paid + search_global OK.
|
||||
- **Gotchas Lote D**: (1) set_config em função-helper com `SET search_path` próprio é REVERTIDO ao retornar → helper retorna schema, RPC faz o set_config. (2) %ROWTYPE/RETURNS<tabela_tenant> quebram → RECORD/jsonb. (3) search_global é MISTO (patients/agenda no schema, patient_intake_requests em public/F1b). (4) seed_* chamados por provision ANTES do clone → no-op se schema não existe (fix de ordem no wiring). (5) can_delete_patient SQL sem SET search_path herda do chamador.
|
||||
- Categorias originais (ref):
|
||||
- **CREATE OR REPLACE, já têm p_tenant_id, RETURNS jsonb/void** (sem ripple FE): `delete_commitment_full`, `delete_determined_commitment`, `seed_default_patient_groups`, `seed_determined_commitments` (⚠️ provision_account_tenant chama seed ANTES de clone — inverter ordem no wiring do clone, senão seed escreve em public). 0 chamadas FE.
|
||||
- **DROP+CREATE (novo p_tenant_id 1º param) + FE passa p_tenant_id, RETURNS scalar/jsonb**: `cancel_recurrence_from`(void,1 FE), `cancelar_eventos_serie`(int,0), `split_recurrence_at`(uuid,1), `safe_delete_patient`(jsonb,1), `export_patient_data`(jsonb,1 — toca ~10 tabelas tenant), `search_global`(jsonb STABLE,2), `list_my_signatures`(jsonb,1).
|
||||
- **RETURNS `<tabela_tenant>`/%ROWTYPE → jsonb (ripple FE: consumidores esperam row)**: `mark_as_paid`(SETOF financial_records,3 FE), `create_financial_record_for_session`(SETOF financial_records,6 FE — já tem p_tenant_id), `mark_payout_as_paid`(therapist_payouts,0), `create_therapist_payout`(therapist_payouts,0 — agregação financeira, testar com cuidado).
|
||||
- Owned mix postgres/supabase_admin → rodar migration como supabase_admin.
|
||||
- **E** ✅ DONE (commit 02acc88, manual/f6_2e_cron_rpcs.supabase_admin.sql + 2 edge). E2 (cleanup/unstick/sync_overdue/populate_notification_queue) varrem todos os schemas via loop `FROM tenant_schemas`. E1 (sla_*, whatsapp_heartbeat_*, convert_abandoned_intake_to_lead) per-tenant via service_role: helper `_tenant_schema_unchecked` (SEM is_tenant_member, pq service_role não é membro) + REVOKE de anon/authenticated. first_response_stats/_runs user-facing via _tenant_route. Edge whatsapp-heartbeat/sla ajustadas (admin.rpc + p_tenant_id). Smoke OK.
|
||||
- **Gotchas Lote E**: (1) service_role NÃO é tenant_member → RPCs de cron precisam de helper sem auth-check + REVOKE de authenticated (senão um user chamaria com tenant arbitrário). (2) conversation_messages NÃO tem coluna thread_key (é computada na view) → analytics computa inline. (3) DROP+CREATE de nova assinatura: dropar AMBAS (velha+nova) p/ idempotência.
|
||||
- **F** ✅ DONE (commit 1243a12, manual/f6_2f_anon_token_rpcs.supabase_admin.sql + 2 FE; build passa). Documentos anon resolvem tenant de `document_share_links.tenant_id` (public/F1b); agendador de `agendador_configuracoes.tenant_id`. document/document_signatures/access_logs/agenda no schema; share_links/agendador_* ficam public. %ROWTYPE→RECORD, RETURNS document_signatures→jsonb. sign_document_by_signature_id (paciente logado, NÃO é member): unchecked + auth por LINHA (signatario_id/email/doc do paciente). match_patient_by_phone: unchecked + REVOKE authenticated (só service). list_my_signatures: fan-out cross-schema. RPCs public-only (intake/invite/agendador_gerar_slug) SEM mudança. FE: signByPortal(tenantId,...).
|
||||
- **Gotcha Lote F**: paciente assinante NÃO é tenant_member → autorizar por LINHA (dono da assinatura), não por membership. Anon resolve tenant SEMPRE da tabela public que tem o token+tenant_id.
|
||||
- **G** ✅ DONE (commit ee82985, manual/f6_2g_sql_to_plpgsql.supabase_admin.sql + 3 FE; build passa). 5 funções SQL→plpgsql + p_tenant_id + _tenant_route (get_financial_summary/report, list_financial_records SETOF→jsonb, get_patient_session_counts sem filtro tenant_id). get_entity_primary_phone (interno) herda search_path. can_delete_patient/_first_response_runs já feitas em D/E. FE: p_tenant_id nas 3 RPCs financeiras.
|
||||
|
||||
## ✅✅ F6.2 COMPLETA (2026-06-13) — 66 funções migradas
|
||||
Triggers (A agnósticos + B schema-aware + C notif) + RPCs (D usuário + E cron + F anon/token + G SQL→plpgsql). Tudo smoke-testado, build passa. Próximo: **wiring no clone** + **F6.3 DROP** (com OK do Leonardo).
|
||||
- ✅ **wiring DONE** (commit dc7826d, manual/f6_2h_clone_wiring.supabase_admin.sql): trigger AFTER INSERT em tenant_schemas (trg_attach_business_triggers) dispara os 3 attach pro schema novo → tenant novo nasce com 84 triggers. attach_agnostic agora SELF-CONTAINED (dirigido por colunas, não lê public — sobrevive ao DROP). provision_account_tenant: clone ANTES do seed. Smoke OK.
|
||||
- **F6.3 DROP** 📋 PREPARADA não-aplicada (commit cdb9ce1, manual/f6_3_drop_public_tenant_tables.supabase_admin.sql): pré-flight assert + 2 FKs viram coluna solta (document_share_links.documento_id, whatsapp_credits_transactions.conversation_message_id) + dropa 9 views public + DROP CASCADE das 78 + limpa financial_records_inject_tenant. **BLOQUEADA** pelos itens em aberto abaixo.
|
||||
|
||||
## ✅ SUPERFÍCIE SaaS-ADMIN RESOLVIDA (F6.4, commit dc2363b)
|
||||
RPCs `saas_admin` (manual/f6_4_saas_admin_rpcs.supabase_admin.sql): defaults editados no `_tenant_template` + fan-out pros schemas (saas_list/add/remove_default_feriado; saas_*_default_notif_template; saas_count_notif_template_overrides). Cross-tenant: `saas_list_all_whatsapp_channels` (fan-out, substitui v_twilio_whatsapp_overview). FE: SaasFeriadosPage/SaasNotificationTemplatesPage → RPCs; SaasWhatsappPage → `supabase.schema(tenant_<slug>)` (RLS permite saas_admin) p/ tenant selecionado + RPC p/ overview; getAllChannels → RPC. **Varredura confirma ZERO supabase.from('<tabela_tenant>') público no FE.** F6.3 DESBLOQUEADA (falta só Leonardo testar app + backup). TODOs deixados: stat-cards de feriados (cidade/estado) e incidents-7d viraram 0 (UI degrada sem crash).
|
||||
|
||||
## (histórico) ITENS EM ABERTO antes do F6.3 DROP — RESOLVIDOS acima
|
||||
Superfície **SaaS-admin / cross-tenant** que ainda lê `public.<tabela_tenant>` e quebraria no DROP:
|
||||
1. **SaasWhatsappPage.vue + v_twilio_whatsapp_overview + twilioWhatsappService.getAllChannels()** — admin cross-tenant de canais WhatsApp (notification_channels/whatsapp_connection_incidents). Reescrever fan-out por schema OU usar `public.channel_routing`.
|
||||
2. **SaasNotificationTemplatesPage.vue** — gerencia templates DEFAULT do sistema (tenant_id NULL). Apontar pra `_tenant_template.notification_templates` (os defaults vivem lá agora).
|
||||
3. **SaasFeriadosPage.vue** — gerencia feriados nacionais default. Idem `_tenant_template.feriados`.
|
||||
4. **notification-webhook** (Meta) — conferir fan-out/channel_routing.
|
||||
Decisão de arquitetura: as páginas que editam DEFAULTS do sistema devem editar `_tenant_template` (propaga a tenants novos); as views cross-tenant admin devem fan-out por schema ou usar channel_routing. Resolver, testar, então aplicar F6.3.
|
||||
|
||||
## 🟢 APP TESTÁVEL AGORA (pós-wiring, pré-DROP)
|
||||
Dados nos schemas (F6.1) + 66 funções/triggers/RPCs roteiam (F6.2) + PostgREST expõe (F5) + frontend usa tenantDb (F3) + edge roteia (F4). Os dados ainda estão ESPELHADOS em public (nada dropado). Leonardo deve abrir o app no branch `feat/schema-per-tenant` e testar fluxos reais (agenda, financeiro, pacientes, documentos, notificações). Só após validação → F6.3 DROP.
|
||||
|
||||
## F5 — entregue (commit 6b542cd) — PRIMEIRO teste real via HTTP do PostgREST
|
||||
- `postgres` NÃO é superuser neste stack → não consegue `ALTER ROLE authenticator`. Quem consegue: `supabase_admin` (superuser, conecta com senha `postgres` via `psql -U supabase_admin -h 127.0.0.1`).
|
||||
- `database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql` (aplicar como supabase_admin, fora do db.cjs): `public.refresh_pgrst_schemas()` (SECDEF owned supabase_admin) deriva a lista de `tenant_schemas`, seta `pgrst.db_schemas` in-database na role authenticator, `NOTIFY pgrst reload config/schema`. **Expõe/retira schema SEM restart**; a GUC persiste em pg_db_role_setting (sobrevive a stop/start) e SUPERSEDE o config.toml em runtime.
|
||||
- migration `20260613000002`: trigger em `tenant_schemas` (AFTER INSERT/DELETE/UPDATE, statement-level) dispara o refresh → clone_tenant_template e drop_tenant_schema NÃO precisaram ser tocados.
|
||||
- config.toml (gitignored): baseline `public, graphql_public` + comentário; in-db config supersede.
|
||||
- **E2E via curl**: clone → `pgrst.db_schemas` inclui tenant_x → `GET /rest/v1/patients` com `Accept-Profile: tenant_x` retorna **200** (vs **406** pra schema inexistente); drop → volta 406. Tudo sem restart de container. Primeira validação real do stack F1-F5 pelo caminho HTTP do PostgREST.
|
||||
|
||||
### Gotcha F5
|
||||
- PostgREST in-database config (db-config ligada por padrão, sem `PGRST_DB_CONFIG=false`): `ALTER ROLE authenticator SET pgrst.db_schemas` + `NOTIFY pgrst, 'reload config'` é a via pra schemas dinâmicos sem restart. `reload schema` sozinho NÃO adiciona schema novo à lista exposta — só recarrega o cache dos já expostos.
|
||||
|
||||
## F4 — entregue (branch, commit 9b21642)
|
||||
- `_shared/tenant.ts`: helper das edge functions — `adminClient()` (service_role/public), `tenantDbForId(admin, tenantId)`, `schemaForTenant`, `listTenantSchemas` (crons varrem todos), `resolveTenantByChannel` (webhook→tenant via channel_routing), `tenantSchemaName`
|
||||
- `_shared/whatsapp-hooks.ts` refatorado: hooks de tabela tenant recebem `tdb`; RPCs de crédito (deduct/add_whatsapp_credits) + tenant_members continuam em `supa`+p_tenant_id
|
||||
- 23 edge functions migradas. Categorias:
|
||||
- **inbound** (twilio/evolution): tenant_id da URL → tdb
|
||||
- **crons de fila** (process-notification/email/sms/whatsapp-queue): varrem `listTenantSchemas` e drenam a fila de CADA schema — consequência direta da Q3 (filas viraram per-tenant). Modo single-tenant se `body.tenant_id` vier.
|
||||
- **crons reminders/checks** (send-session-reminders, conversation-sla-check, whatsapp-heartbeat-check, convert-abandoned-intakes, sync-email-templates): loop por tenant
|
||||
- **routing por tenant_id** (send-whatsapp-message, send-session-reminder-manual, twilio-provision, de/reactivate-channel, twilio-webhook): tenantDbForId; channel-actions sem tenant_id varrem schemas por channel_id (O(n) tenants)
|
||||
- **asaas-***: tenant_id do body → tdb; asaas-webhook fica global
|
||||
- **notification-webhook** (Meta Cloud API): resolve via channel_routing por phone_number_id, fan-out por message_id quando não casa
|
||||
- caller `useAgendaEventLifecycle.js` passa tenant_id pro send-session-reminder-manual (evento vive no schema)
|
||||
- Sem deno local → validado por grep (zero tenant_id em cadeias tdb, clients todos declarados, imports batem). Type-check real só no deploy.
|
||||
|
||||
### ⚠️ DECISÃO PENDENTE — roteamento anon-por-token (bloqueia F5/F6)
|
||||
Fluxos anônimos identificam o tenant por TOKEN/SLUG, não por login, então não sabem o schema: `save-intake-progress` (lê patient_intake_requests por token), intake RPCs (get-intake-invite-info, submit-patient-intake), `AgendadorPublicoPage`+RPCs do agendador (link_slug), document share links (validate_share_token, sign_document_by_token). Opções:
|
||||
- **A** Índice global `public_access_tokens(token_hash→tenant_id)` + triggers de sync (O(1), +1 tabela global + triggers)
|
||||
- **B** RPCs SECURITY DEFINER que varrem schemas pelo token (sem tabela nova, O(n) por request)
|
||||
- **C** Manter as tabelas anon-facing (patient_intake_requests, patient_invites, document_share_links, agendador_configuracoes/solicitacoes) em PUBLIC com RLS por token — sidesteppa o problema; custo: essas não ganham isolamento físico (mas são as menos sensíveis, feitas pra acesso anon)
|
||||
|
||||
## F3 — entregue (branch feat/schema-per-tenant, migration 07)
|
||||
- `src/lib/supabase/tenantClient.js` (`tenantDb()`, `tenantSchemaName()`) + `src/composables/useTenantDb.js`
|
||||
- `tenantStore`: getters `activeTenantSlug`/`activeTenantSchema`; `my_tenants()` RPC agora devolve slug+name (migration 20260612000007)
|
||||
- codemod `scripts/codemod-tenant-db.py`: `supabase.from('<84 tabelas + 6 views tenant>')` → `tenantDb().from(...)` em 139 arquivos (777 chamadas), removeu 173 `.eq('tenant_id')` de cadeias tenant
|
||||
- 4 agentes (2 ondas) fizeram a passada manual: tenant_id fora de payloads/selects/.or/.is; onConflict ajustado (singletons → `'singleton'`); realtime de tabelas tenant aponta pro `activeTenantSchema`; repos dropam tenant_id defensivamente de payloads de callers externos
|
||||
- **descoberta importante: ZERO embeds cross-schema** — todos os FK embeds são tenant→tenant (mesmo schema, ex. `agenda_eventos`→`patients`,`insurance_plans`) ou global→global (`profile_specialties`→`profiles`). O `attachProfiles`/fake-embed do blueprint NÃO é necessário aqui.
|
||||
- gotcha: `AGENDA_EVENT_SELECT` (constante de select) tinha tenant_id — selecionar coluna inexistente quebra PostgREST; varrer constantes `*_SELECT`, não só `.from()`
|
||||
|
||||
### Pendências F3 (fora do escopo, cross-tenant/anon → tratar em F4/F6)
|
||||
- `AgendadorPublicoPage.vue` — scheduler público anon, resolve tenant por `link_slug` (precisa RPC/edge de resolução slug→schema, igual channel_routing)
|
||||
- `Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page.vue` — gerenciam defaults do sistema (tenant_id NULL) ou views cross-tenant; após F6 devem mirar `_tenant_template` ou `channel_routing`. Continuam apontando pra public (funcional até o drop da F6).
|
||||
|
||||
## F2 — entregue (migration 20260612000006)
|
||||
Os 3 únicos pontos de criação de tenant (`provision_account_tenant`, `create_clinic_tenant`, `ensure_personal_tenant_for_user` — este último também acionado pelo trigger de signup `handle_new_user_create_personal_tenant`) agora chamam `clone_tenant_template()` na mesma transação: clone falhou → tenant não nasce. Smoke: ensure_personal criou tenant pessoal `tenant_terapeuta_pessoal` com 84 tabelas + registro, 2ª chamada idempotente, drop limpou tudo. Não há fluxo de exclusão de tenant no sistema (drop_tenant_schema fica pra uso admin/manual).
|
||||
|
||||
## F1 — entregue (migrations 20260612000001–05 em database-novo/migrations/)
|
||||
- `tenants.slug` criado + backfill dos 9 + trigger auto-gera/imutável
|
||||
- Helpers: `tenant_schema_name/for`, `tenant_id_for_schema`, `tenant_schema_checked(p_tenant_id)` (valida `is_tenant_member` — substitui current_tenant_schema do blueprint)
|
||||
- `_tenant_template`: 84 tabelas sem tenant_id, 6 singletons (`singleton boolean PK/UQ` nas configs 1-linha: company_profiles, email_layout_config, conversation_autoreply_settings/bots/sla_rules, session_reminder_settings), 4 sequences locais, 94 FKs (62 intra + 32 pra public/auth), 6 views com placeholders `__SCHEMA__`/`__TENANT_ID__` em `_views`, seeds de sistema (whitelist 8 lookups)
|
||||
- `clone_tenant_template(uuid)` → tabelas+seqs+seeds+FKs+views+RLS (policies com tenant_id EMBUTIDO: `is_tenant_member('<uuid>')` + saas_admin_full)+realtime+grants+trigger routing+registro em `tenant_schemas`
|
||||
- `drop_tenant_schema(uuid)` protegido; `public.channel_routing` (webhook inbound acha tenant do canal) sincronizada por trigger
|
||||
- Smoke: clone tenant_smoke_f1 → 84 tabelas/168 policies/roundtrip/routing sync/singleton rejeitando 2ª linha → drop limpo
|
||||
|
||||
### Gotchas aprendidos na F1
|
||||
- **`postgres` não é superuser no Supabase** → `session_replication_role` proibido; seeds usam retry-loop de FK (rounds). Vale pro F6 (migração de dados): rodar como `supabase_admin` ou retry-loop.
|
||||
- **db.cjs aplicava migration sem `ON_ERROR_STOP`** → rollback silencioso reportado como sucesso. Corrigido (psqlFile agora usa `-v ON_ERROR_STOP=1`).
|
||||
- Linhas operacionais órfãs com tenant_id NULL (intakes/convites/notifs) NÃO são seeds — whitelist explícita.
|
||||
- Clones F1/F2 ainda SEM triggers de negócio (F6) e fora do PostgREST (F5) — `_meta.triggers_pending=true`.
|
||||
|
||||
Migração de multi-tenant RLS-only (tenant_id em cada tabela) para schema físico por tenant (`tenant_<slug>`), seguindo blueprint do projeto irmão (`novo-rumo.txt` na raiz), adaptado.
|
||||
|
||||
## Artefatos
|
||||
- `docs/F0_categorizacao.md` — varredura completa: classificação das 137 tabelas, 66 funções, 6 views, FKs, edge functions, divergências.
|
||||
- `novo-rumo.txt` (raiz) — blueprint original com lições do projeto irmão.
|
||||
|
||||
## Números-chave
|
||||
- 137 tabelas public → 79 tenant-scoped + 5 em decisão (infra mensageria) + 53 globais
|
||||
- 66 funções afetadas (blueprint avisava: listas pré-feitas subestimam — era "29" lá, 66 aqui)
|
||||
- 1 única FK global→tenant problemática: `whatsapp_credits_transactions.conversation_message_id`
|
||||
- 0 policies de tabelas globais usando funções a refatorar
|
||||
- 9 tenants (3 clínicas + 6 therapists), volumetria minúscula (<400 linhas/tabela)
|
||||
|
||||
## Divergências vs blueprint (decisivas)
|
||||
1. **Sem `tenants.slug`** — precisa criar coluna ou usar uuid no nome do schema.
|
||||
2. **Multi-membership**: `profiles.tenant_id` 100% NULL; verdade vive em `tenant_members` (4 users multi-tenant). `current_tenant_schema()` do blueprint não funciona → frontend escolhe schema ([[tenantStore]] já tem `activeTenantId`), segurança via policy com tenant_id embutido por schema + RPCs recebem `p_tenant_id` validado com `is_tenant_member()`.
|
||||
3. **6/9 tenants são terapeutas individuais** — schema por signup; custo operacional do config.toml do PostgREST cresce com tenants.
|
||||
4. `email_layout_config.tenant_id` e `email_templates_tenant.tenant_id` apontam pra **auth.users** (legado) — mapear na migração de dados.
|
||||
5. View `current_tenant_id` é código morto (claim JWT nunca populado).
|
||||
|
||||
## Decisões (2026-06-12)
|
||||
- Q1: **criar `tenants.slug`** → schemas `tenant_<slug>`
|
||||
- Q2: **todo tenant ganha schema** (clínicas e therapists)
|
||||
- Q3: **mensageria tenant-scoped** (isolamento máximo, contra rec. global) → crons varrem tenants em loop; webhooks inbound precisam de índice global `channel_routing` (channel_external_id → tenant_id) pra rotear antes de gravar
|
||||
- Q4: **asaas tenant** (staging `asaas_webhook_events` global roteia)
|
||||
|
||||
Total final: **84 tabelas tenant-scoped, 53 globais.**
|
||||
|
||||
## Fases (tasks #1–#7 na sessão)
|
||||
F0 categorização ✅ · F1 template+helpers · F2 provisionamento · F3 frontend useTenantDb · F4 edge functions · F5 PostgREST config · F6 rewrite funções + migração dados + drops (lotes, backup antes de cada um)
|
||||
|
||||
Relacionados: [[Decisões de Billing da Agenda]], [[Supabase Local]], [[index]]
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: Pesquisa de mercado — fluxo de compromisso e cobrança
|
||||
date: 2026-05-13
|
||||
status: levantamento
|
||||
players: Cliniko, SimplePractice, TherapyNotes
|
||||
---
|
||||
|
||||
## Contexto do produto
|
||||
|
||||
SaaS BR pra clínicas de psicologia, multi-tenant. Agenda + paciente + recorrência já funcionando. Invariante "cobrança emitida é imutável pelo dialog da agenda" já implementada (padrão SimplePractice). Auditando fase-a-fase o fluxo antes de fechar gaps. Restrições fiscais BR: PIX, NFS-e, LGPD.
|
||||
|
||||
Cross-links: [[recorrencia-agenda]], [[index]]
|
||||
|
||||
---
|
||||
|
||||
## 1. Criação de compromisso SEM paciente
|
||||
|
||||
### Cliniko
|
||||
- **Default:** existe entidade dedicada chamada **Unavailable block**. Não é appointment — não interfere em relatórios clínicos. Funciona como bloqueio puro de calendário (almoço, reunião, férias, manutenção).
|
||||
- **Admin pode:** criar **Unavailable block types** customizados (nome, duração default, cor). Aceita arquivamento individual ("Archive" remove o bloco).
|
||||
- **Fonte:** [Scheduling time off](https://help.cliniko.com/en/articles/1023892-scheduling-time-off), [Changing Your Calendar to Time Blocks](https://help.cliniko.com/en/articles/1024048-changing-your-calendar-to-time-blocks).
|
||||
|
||||
### SimplePractice
|
||||
- **Default:** duas entidades distintas — **Calendar event** (cinza escuro, para reunião, supervisão, tempo pessoal) e **Out of office (OOO) block** (cinza claro, para indisponibilidade que deve bloquear request de agendamento). Calendar events também podem ser recorrentes.
|
||||
- **Admin pode:** marcar evento como recorrente; OOO bloqueia automaticamente o widget de pedidos de horário online.
|
||||
- **Fonte:** [Creating a calendar event](https://support.simplepractice.com/hc/en-us/articles/41930878513933-Creating-a-calendar-event), [Managing out of office blocks](https://support.simplepractice.com/hc/en-us/articles/41931023345165-Managing-out-of-office-blocks).
|
||||
|
||||
### TherapyNotes
|
||||
- **Default:** dois tipos — **Scheduled Event** (atividade não-clínica: reunião, supervisão, treinamento; aparece no calendário do clínico) e **Unavailable** (vetar agendamento de pacientes em horários específicos: férias, almoço, compromisso pessoal). Ambos suportam descrição, duração e recorrência sem vincular paciente.
|
||||
- **Admin pode:** decidir clínico-alvo, frequência (one-time ou recurring), texto livre.
|
||||
- **Fonte:** [Schedule Non-Clinical Events](https://support.therapynotes.com/hc/en-us/articles/30661451456667-Schedule-Non-Clinical-Events), [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
|
||||
|
||||
**Convergência:** os 3 têm entidade não-clínica separada de "appointment" — nunca usam appointment-sem-paciente como hack.
|
||||
|
||||
---
|
||||
|
||||
## 2. Criação de compromisso COM paciente
|
||||
|
||||
### Cliniko
|
||||
- **Default:** appointment exige paciente + appointment type + data/hora + practitioner. Paciente pode ser criado on-the-fly direto do dialog do appointment com apenas nome (descrição/categoria são opcionais).
|
||||
- **Admin pode:** definir custom patient fields opcionais; appointment type carrega billable items default associados.
|
||||
- **Fonte:** [Booking an appointment](https://help.cliniko.com/en/articles/1024061-booking-an-appointment), [Set up appointment types](https://help.cliniko.com/en/articles/1023911-set-up-appointment-types).
|
||||
|
||||
### SimplePractice
|
||||
- **Default:** appointment exige cliente. Existe entidade intermediária chamada **Prospective client / Inquiry** — perfil parcial usado pra leads vindos de contact form ou pedido online. Pode-se enviar intake antes mesmo de aceitar o appointment (perfil definitivo só nasce ao aceitar).
|
||||
- **Admin pode:** mandar link de agendamento; criar task de follow-up; enviar intake; rodar prescreener; converter inquiry em client.
|
||||
- **Fonte:** [Managing prospective clients on the Inquiries page](https://support.simplepractice.com/hc/en-us/articles/33726366744589-Managing-prospective-clients-on-the-Inquiries-page), [Adding a new client](https://support.simplepractice.com/hc/en-us/articles/12416306860429-Adding-a-new-client-and-navigating-your-Clients-and-contacts-list).
|
||||
|
||||
### TherapyNotes
|
||||
- **Default:** appointment clínico exige client + clinician + appointment type + date. Cliente novo precisa pelo menos de **last name**; demais campos (DOB, endereço, e-mail, sexo administrativo, HIPAA acknowledgment) só viram obrigatórios quando se vai submeter claim de plano ou ativar portal.
|
||||
- **Admin pode:** liberar last-name-only para um "stub client" que recebe billable items mas não é submetível a plano até completar cadastro.
|
||||
- **Fonte:** [Add a New Client](https://support.therapynotes.com/hc/en-us/articles/30661347776539-Add-a-New-Client), [Schedule a Clinical Appointment](https://support.therapynotes.com/hc/en-us/articles/30661407698203-Schedule-a-Clinical-Appointment).
|
||||
|
||||
**Convergência:** todos aceitam appointment com cadastro de paciente mínimo. SimplePractice é o único com camada formal de "lead" pré-prontuário.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cobrança / fatura — quando é gerada?
|
||||
|
||||
### Cliniko
|
||||
- **Default:** invoice é **explicitamente criada** pelo usuário a partir do appointment (botão "Create invoice" no card do compromisso). Não há geração automática no agendamento.
|
||||
- **Admin pode:** vincular billable items / produtos a um appointment type, então o "Create invoice" já vem populado. Em fluxo de pagamento online, a invoice é gerada e marcada como paga automaticamente no momento do pagamento confirmando o appointment.
|
||||
- **Fonte:** [Create an invoice](https://help.cliniko.com/en/articles/1023907-create-an-invoice), [Relate billable items and products to an appointment type](https://help.cliniko.com/en/articles/1023847-relate-billable-items-and-products-to-an-appointment-type).
|
||||
|
||||
### SimplePractice
|
||||
- **Default:** geração **automática**, configurável globalmente entre Daily (overnight, à meia-noite do timezone da prática), Monthly ou Manual. Status do appointment determina se vira invoice: apenas appointments com status **Show**, **Late canceled** ou **No show** geram invoice automaticamente.
|
||||
- **Admin pode:** escolher daily/monthly/manual em Settings → Client billing → Client billing documents. Recomendação oficial: Daily quando cobra na hora da sessão; Monthly quando fecha o mês.
|
||||
- **Fonte:** [Setting up your billing and automations](https://support.simplepractice.com/hc/en-us/articles/207925643-Setting-up-your-billing-and-automations), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing), [Best practices for time-of-session billing](https://support.simplepractice.com/hc/en-us/articles/115000837406-Best-practices-for-time-of-session-billing).
|
||||
|
||||
### TherapyNotes
|
||||
- **Default:** billing line item é gerado **quando a nota da sessão é completada e assinada** pelo clínico. Cada appointment tem aba Billing acessível direto do dialog, mas o disparo de claim/invoice depende de note signed.
|
||||
- **Admin pode:** configurar default billing method por payer; o To-Do list cria o lembrete pra submeter claim ou gerar CMS-1500 assim que a nota é assinada.
|
||||
- **Fonte:** [Billing Overview](https://support.therapynotes.com/hc/en-us/articles/30661437130139-Billing-Overview), [Submit Electronic Claims](https://support.therapynotes.com/hc/en-us/articles/30661415430811-Submit-Electronic-Claims), [Quick Start: Billing](https://support.therapynotes.com/hc/en-us/articles/30661397280155-Quick-Start-Billing).
|
||||
|
||||
**Convergência:** ninguém cobra no momento de criar o appointment (futuro). Cliniko = manual sob demanda. SimplePractice = automático pós-sessão (status driven). TherapyNotes = automático pós-assinatura de nota (clinical-doc driven).
|
||||
|
||||
---
|
||||
|
||||
## 4. Recorrência (séries) — billing
|
||||
|
||||
### Cliniko
|
||||
- **Default:** repeating appointment (daily/weekly/fortnightly/monthly). Cada ocorrência é **appointment independente**; invoice continua sendo manual por ocorrência. Pra pacotes, recomenda usar **patient cases + account credit**: cobra o pacote inteiro upfront, o crédito fica no perfil do paciente e é consumido por cada invoice subsequente.
|
||||
- **Admin pode:** decidir entre invoice-por-sessão (manual ou via pagamento online) ou pacote upfront via account credit.
|
||||
- **Fonte:** [Book repeating appointments](https://help.cliniko.com/en/articles/1777286-book-repeating-appointments), [Tracking packages with patient cases and account credit](https://help.cliniko.com/en/articles/6477363-tracking-packages-with-patient-cases-and-account-credit).
|
||||
|
||||
### SimplePractice
|
||||
- **Default:** série de até 100 ocorrências, recorrência semanal/mensal/anual. Cada ocorrência é independente para billing — invoice é criada na ocorrência conforme regra global daily/monthly. Editar uma ocorrência pergunta "just this one" ou "all in series". Ao deletar série inteira incluindo passado, **passa por cima** de ocorrências sem nota ou invoice anexada; ocorrências com invoice/nota são preservadas.
|
||||
- **Admin pode:** ajustar fee de ocorrência já faturada via **fee adjustment invoice** (novo doc que ajusta o saldo, não toca a invoice original — esse é exatamente o padrão "cobrança emitida imutável" já adotado no projeto).
|
||||
- **Fonte:** [Managing recurring appointments](https://support.simplepractice.com/hc/en-us/articles/41930568779021-Managing-recurring-appointments), [Creating invoices](https://support.simplepractice.com/hc/en-us/articles/207925663-Creating-invoices).
|
||||
|
||||
### TherapyNotes
|
||||
- **Default:** recurring appointments indefinidos ou com data-fim. Cada ocorrência tem nota e billing independentes — billing line item nasce com a assinatura de cada nota individualmente.
|
||||
- **Admin pode:** cancelar "só esta" ou "todas futuras" da série; alertas podem ser anexados à série inteira.
|
||||
- **Fonte:** [Quick Start: Scheduling](https://support.therapynotes.com/hc/en-us/articles/30661279632539-Quick-Start-Scheduling).
|
||||
|
||||
**Convergência:** os 3 tratam ocorrência como unidade de billing. Pacote upfront é exceção (Cliniko via account credit). Nenhum gera "fatura única da série".
|
||||
|
||||
---
|
||||
|
||||
## 5. No-show / cancelamento tardio
|
||||
|
||||
### Cliniko
|
||||
- **Default:** plataforma não impõe fee; fornece ferramenta — terms of use no online booking + janela mínima de cancelamento (lock). Se paciente pagou full upfront online, ele **não consegue** cancelar pelo link; deposit parcial libera cancelamento.
|
||||
- **Admin pode:** configurar minimum notice (várias opções entre "sem restrição" e "vários dias"); redigir política nos terms of use; aplicar fee manualmente via invoice.
|
||||
- **Fonte:** [Restrict when a patient can cancel an appointment](https://help.cliniko.com/en/articles/1150562-restrict-when-a-patient-can-cancel-an-appointment), [Let patients cancel their appointments](https://help.cliniko.com/en/articles/1023945-let-patients-cancel-their-appointments).
|
||||
|
||||
### SimplePractice
|
||||
- **Default:** statuses formais — **No show** e **Late canceled** (ambos billable, ambos geram invoice como qualquer Show quando auto-billing está ativo). Cancelamento dentro da janela permitida vira status não-billable.
|
||||
- **Admin pode:** definir janela (24h ou 48h são presets) em Settings; statuses vão pra Client billing summary; appointments late-canceled aparecem em vermelho no calendário.
|
||||
- **Fonte:** [Setting up your practice's cancellation policy](https://support.simplepractice.com/hc/en-us/articles/360046771271-Setting-up-your-practice-s-cancellation-policy), [Managing appointment statuses and billing](https://support.simplepractice.com/hc/en-us/articles/360018410872-Managing-appointment-statuses-and-billing).
|
||||
|
||||
### TherapyNotes
|
||||
- **Default:** **Missed Appointment Note** dedicada — registra ausência e tem checkbox que automaticamente cria billing line item para fee de cancelamento. TherapyPortal mostra warning ao paciente quando ele tenta cancelar fora da janela.
|
||||
- **Admin pode:** habilitar/desabilitar criação automática de fee; configurar valor; texto da política aparece no portal.
|
||||
- **Fonte:** [Complete a Missed Appointment Note](https://support.therapynotes.com/hc/en-us/articles/30661183276315-Complete-a-Missed-Appointment-Note), [TherapyNotes 4.15 release notes](https://blog.therapynotes.com/version-4-15).
|
||||
|
||||
**Convergência:** todos têm conceito de "cobrar pelo no-show". SimplePractice é o mais automatizado (status billable triggera invoice junto com os outros). TherapyNotes é o mais explícito (note dedicada + checkbox). Cliniko é o mais manual.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reembolso / cancelamento de cobrança emitida
|
||||
|
||||
### Cliniko
|
||||
- **Default:** invoice criada por engano pode ser **arquivada** (Archive button). **Número fiscal não retorna** — invoice 000001 arquivada não pode ser reemitida com o mesmo número. Reembolso real usa botão **Reverse** que cria credit note com itens negativos; usuário escolhe **Create credit & refund** (devolve dinheiro) ou **Create credit** (vira account credit). Para desfazer um refund, arquiva-se a credit note.
|
||||
- **Fonte:** [Archive an invoice](https://help.cliniko.com/en/articles/1359931-archive-an-invoice), [Recording refunds: an overview](https://help.cliniko.com/en/articles/4372587-recording-refunds-an-overview), [Undo a refund](https://help.cliniko.com/en/articles/4521200-undo-a-refund).
|
||||
|
||||
### SimplePractice
|
||||
- **Default:** invoice paga **não deve ser deletada** (deletar quebra alocação de pagamento). Refund full ou parcial é fluxo separado. Pagamentos cash/check/external podem ser deletados se foram erro; pagamento online com cartão não pode ser deletado, só refunded. Para mudar fee de invoice já emitida, usa **fee adjustment invoice** (novo doc com diff).
|
||||
- **Fonte:** [Navigating client payments](https://support.simplepractice.com/hc/en-us/articles/8497757602957-Navigating-client-payments), [Managing unallocated client payments](https://support.simplepractice.com/hc/en-us/articles/42078634883469-Managing-unallocated-client-payments).
|
||||
|
||||
### TherapyNotes
|
||||
- **Default:** **deletar pagamento ≠ refund** — deletar só remove o registro, não devolve dinheiro. Refund usa botão **Enter Refund** no Patient Accounting do tab Billing. Refund de payer (plano) tem opção dedicada que marca valor negativo automaticamente.
|
||||
- **Fonte:** [Edit, Delete and Refund Client Payments](https://support.therapynotes.com/hc/en-us/articles/30661497068443-Edit-Delete-and-Refund-Client-Payments).
|
||||
|
||||
**Convergência:** os 3 distinguem "anular registro" de "estornar dinheiro". Os 3 preservam histórico fiscal (Cliniko via número não-reaproveitável + credit note; SimplePractice via fee adjustment; TherapyNotes via refund line item). Padrão "cobrança imutável" do projeto está alinhado com o estado da arte.
|
||||
|
||||
---
|
||||
|
||||
## Tabela comparativa 3 × 6
|
||||
|
||||
| Etapa | Cliniko | SimplePractice | TherapyNotes |
|
||||
|---|---|---|---|
|
||||
| 1. Compromisso sem paciente | Unavailable block (tipos customizáveis) | Calendar event + OOO block (2 entidades) | Scheduled Event + Unavailable (2 tipos) |
|
||||
| 2. Compromisso com paciente | Quick-create paciente (nome basta) | Lead (Inquiry) → cliente formal | Last name basta; demais campos só pra claim |
|
||||
| 3. Quando gera cobrança | Manual via botão no appointment | Automático overnight (Daily/Monthly/Manual) condicionado a status billable | Quando nota da sessão é assinada |
|
||||
| 4. Recorrência billing | Ocorrência individual ou pacote upfront (account credit) | Série até 100; ocorrência individual; fee adjustment para edit pós-fatura | Ocorrência individual; billing nasce na assinatura de cada nota |
|
||||
| 5. No-show / late cancel | Política em terms of use; lock manual | Statuses billable (No show / Late canceled); janela 24h/48h | Missed Appointment Note com checkbox auto-fee |
|
||||
| 6. Refund / cancel cobrança | Archive + Reverse → credit note | Não deletar invoice paga; fee adjustment + refund | Enter Refund (delete ≠ refund) |
|
||||
|
||||
---
|
||||
|
||||
## Consenso de mercado
|
||||
|
||||
1. **Bloqueio de tempo é entidade própria**, separada de appointment. Nunca um appointment "sem paciente".
|
||||
2. **Cadastro mínimo de paciente** (1 campo) é aceito; campos pesados só ficam obrigatórios na hora de cobrar plano ou ativar portal.
|
||||
3. **Recorrência cria ocorrências independentes** para billing; nenhum gera "fatura única da série".
|
||||
4. **Edit de uma ocorrência pergunta "esta / todas / futuras"** — padrão consagrado.
|
||||
5. **Cobrança nunca é gerada na criação do appointment futuro** — sempre depois (sessão, status, nota, ou trigger manual).
|
||||
6. **Cobrança emitida é imutável**; ajustes vêm via documento novo (credit note, fee adjustment invoice, refund line item). Validação direta do invariante do projeto.
|
||||
7. **Deletar pagamento ≠ reembolsar dinheiro** — distinção explícita nos 3.
|
||||
8. **Janela de cancelamento configurável + política em texto livre** é o mínimo.
|
||||
|
||||
## Divergência
|
||||
|
||||
- **Quem aciona a cobrança:** Cliniko = humano clica. SimplePractice = job overnight via status. TherapyNotes = assinatura de nota clínica. Três paradigmas distintos.
|
||||
- **Lead / prospect:** SimplePractice tem entidade formal (Inquiry). Cliniko e TherapyNotes esperam o paciente já ter perfil mínimo.
|
||||
- **No-show fee:** SimplePractice = mais automatizado (status billable). TherapyNotes = mais auditável (note dedicada). Cliniko = mais manual.
|
||||
- **Pacote upfront:** Cliniko documenta explicitamente via account credit. SimplePractice/TherapyNotes não têm pacote nativo — cobram ocorrência a ocorrência.
|
||||
- **Reaproveitamento de número de invoice arquivada:** Cliniko proíbe (alinhado com fiscal BR via NFS-e). Outros não documentam regra equivalente.
|
||||
|
||||
---
|
||||
|
||||
## Perguntas-chave pro produto decidir
|
||||
|
||||
1. **O que dispara a cobrança no fluxo padrão?**
|
||||
a) Manual (humano clica) — máxima auditabilidade, exige disciplina (Cliniko).
|
||||
b) Job automático com base em status do appointment (SimplePractice) — pouco atrito, dependente de status estar correto.
|
||||
c) Assinatura de nota da sessão (TherapyNotes) — vincula clínica e financeira, atrasa cobrança se nota demora.
|
||||
**Trade-off:** quanto mais automático, menos atrito mas mais risco de cobrança errada; quanto mais manual, mais fricção mas auditoria perfeita.
|
||||
|
||||
2. **Devemos ter conceito formal de "lead/contato" antes de prontuário?**
|
||||
a) Sim — entidade Inquiry separada com pipeline (modelo SimplePractice).
|
||||
b) Não — paciente nasce na quick-create do agendamento com nome só (modelo Cliniko/TherapyNotes).
|
||||
**Trade-off:** Inquiry casa com funil comercial mas duplica entidade; quick-create é simples mas dificulta funil de pré-vendas.
|
||||
|
||||
3. **Recorrência cobra cada ocorrência ou suporta pacote upfront?**
|
||||
a) Só ocorrência individual (SimplePractice/TherapyNotes).
|
||||
b) Suporta também pacote upfront com saldo (Cliniko via patient case + account credit).
|
||||
**Trade-off:** pacote upfront atende prática que vende "10 sessões antecipado"; ocorrência-a-ocorrência casa direto com NFS-e brasileira (1 nota por serviço).
|
||||
|
||||
4. **No-show vira invoice automática ou exige ação manual?**
|
||||
a) Automático — status "No show" / "Late canceled" entram no auto-billing como Show (SimplePractice).
|
||||
b) Semi — note dedicada com checkbox que controla geração (TherapyNotes).
|
||||
c) Manual — admin cria invoice de no-show à mão (Cliniko).
|
||||
**Trade-off:** automático reduz perda mas pode constranger paciente sem revisão; manual exige rotina disciplinada.
|
||||
|
||||
5. **Edição de uma ocorrência de série recorrente: o que faz com cobrança já emitida?**
|
||||
a) Bloqueia edição (invariante atual — alinhado com SimplePractice "fee adjustment invoice" preservando original).
|
||||
b) Permite edição com nova cobrança suplementar (delta).
|
||||
c) Permite edição e refaz a cobrança (cancela + recria).
|
||||
**Trade-off:** opção a é a mais defensável fiscalmente (NFS-e já transmitida não pode ser silenciosamente mutada); b atende UX; c é perigoso mas familiar.
|
||||
|
||||
6. **Janela de cancelamento: presets ou livre?**
|
||||
a) Presets (24h / 48h) com texto da política livre (SimplePractice).
|
||||
b) Configuração granular por appointment type (Cliniko).
|
||||
c) Cliente final só vê warning, sem lock (TherapyNotes).
|
||||
**Trade-off:** presets cobrem 90% dos casos; granular casa com clínica que tem terapia de grupo + casal + individual com janelas diferentes.
|
||||
|
||||
7. **Reembolso preserva o documento fiscal original?**
|
||||
a) Sim, sempre — credit note nova, número fiscal original nunca volta (Cliniko + alinhado com NFS-e brasileira: cancelamento ≠ deletar).
|
||||
b) Sim, mas via fee adjustment que não toca a invoice (SimplePractice).
|
||||
c) Sim, refund é line item separado (TherapyNotes).
|
||||
**Trade-off:** modelo brasileiro de NFS-e exige (a) ou (c); SimplePractice (b) só funciona em mercados sem NF transmitida por API.
|
||||
|
||||
8. **Pagamento via PIX (e cartão online) confirma e marca invoice paga automaticamente?**
|
||||
a) Sim — pagamento confirmado dispara appointment confirmado + invoice paga (Cliniko online payment).
|
||||
b) Pagamento é entidade separada que pode ser alocada/desalocada (SimplePractice).
|
||||
**Trade-off:** auto-confirm é UX premium mas exige tolerância a falhas de webhook do PSP; pagamento desalocado é seguro mas exige conciliação.
|
||||
|
||||
---
|
||||
|
||||
## Implicações imediatas pro projeto
|
||||
|
||||
- O invariante "cobrança emitida é imutável" já implementado é consenso de mercado — manter.
|
||||
- "Compromisso sem paciente" precisa virar entidade própria (block/event), não um appointment com paciente null. Ver [[recorrencia-agenda]] para integração com expansão de série.
|
||||
- Recorrência por ocorrência individual é o caminho seguro (cabe em NFS-e). Pacote upfront fica para fase 2.
|
||||
- Disparo de cobrança: avaliar híbrido SimplePractice (status-driven) + TherapyNotes (note-signed), com fallback manual estilo Cliniko.
|
||||
- Perguntas 1, 4, 5, 7, 8 são pré-requisito pra fechar o gap atual de billing antes de F1 de fiscal.
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: Plano de auditoria fase-a-fase — fluxo de compromisso da agenda
|
||||
date: 2026-05-13
|
||||
status: em-andamento
|
||||
related: [[agenda-billing-pesquisa-mercado]], [[recorrencia-agenda]]
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
Auditoria do ciclo completo de compromisso da agenda, fase-a-fase, validando cada etapa contra a [[agenda-billing-pesquisa-mercado|pesquisa de mercado]] (Cliniko / SimplePractice / TherapyNotes). Cada fase tem 3 entregas: **auditar o que existe**, **decidir o gap**, **codar**.
|
||||
|
||||
## Decisões já tomadas (5 das 8 perguntas)
|
||||
|
||||
| # | Decisão |
|
||||
|---|---|
|
||||
| 1 | Disparo de cobrança: **híbrido configurável** (manual / status-driven / note-signed) |
|
||||
| 4 | No-show: **semi-automático via dialog de confirmação** ao mudar status |
|
||||
| 5 | Edit de cobrada: **bloqueia** (já implementado) |
|
||||
| 7 | Refund: **credit note nova** (alinhado NFS-e) |
|
||||
| 8 | Pagamento: **entidade separada** de financial_records |
|
||||
|
||||
Pendentes: #2 (lead/Inquiry), #3 (pacote upfront), #6 (janela de cancelamento — provavelmente já resolvido por `min_hours_notice` em `financial_exceptions`).
|
||||
|
||||
---
|
||||
|
||||
## Plano de 8 fases
|
||||
|
||||
Ordem por dependência ("o que destrava o quê") e por estado atual.
|
||||
|
||||
### ✅ Fase 1 — Compromisso SEM paciente (bloqueio/feriado/exceção) — **CONCLUÍDA 2026-05-13**
|
||||
|
||||
**Auditoria fez:**
|
||||
- ✅ `agenda_excecoes` é tabela órfã (0 referências em src/) — apesar de schema, policies, trigger e enums existentes
|
||||
- ✅ `agenda_bloqueios` é a entidade canônica usada pelos 3 layouts
|
||||
- ✅ `BloqueioDialog` (4 modos: horário/período/dia/feriados) é compartilhado por Melissa Agenda (via `MelissaLayout.vue:2186`), Rail e Clínica
|
||||
- ✅ `MelissaBloqueios.vue` tem form inline próprio pra **admin/edit** (caso de uso legítimo distinto do dialog de 4 modos)
|
||||
- ✅ Bloqueios não eram renderizados no FullCalendar — apenas impediam criação. UX inconsistente vs pausas/feriados que aparecem como background events
|
||||
- ⚠️ Tipos customizáveis de bloqueio: descartado no MVP (sem cliente real)
|
||||
- ⚠️ Robustez de `marcarSessoesParaRemarcar`: adiado pra Fase 5 (status change)
|
||||
|
||||
**Aplicado:**
|
||||
1. Migration `20260513000001_drop_agenda_excecoes.sql` — dropa tabela + 2 enums + trigger; policies caem com CASCADE
|
||||
2. `agendaMappers.js`: nova função `buildBloqueioBackgroundEvents(bloqueios, rangeStart, rangeEnd)` — renderiza bloqueios como background events cinza (`#6b728033`), suporta dia-inteiro, com hora, e recorrente semanal
|
||||
3. Novo composable `useAgendaBloqueios.js` — load por owner único OU array (multi-owner pra Clínica), `buildEventsForRange` reutilizável
|
||||
4. Wire em `useMelissaAgenda` + `MelissaAgenda.vue` — bloqueios concatenados ao `fcEvents`
|
||||
5. Wire em `AgendaTerapeutaPage` — bloqueios concatenados ao `calendarEvents`
|
||||
6. Wire em `AgendaClinicaPage` — bloqueios consolidados de todos os ownerIds
|
||||
7. Refs stale removidas de `database-novo/docs/schema_map.md` e `database-novo/db.config.json`
|
||||
|
||||
**Verificação:**
|
||||
- ESLint nos arquivos modificados: 0 errors novos (11 pré-existentes em código não-tocado)
|
||||
- Vitest `agendaMappers.spec.js`: 40/40 tests passed
|
||||
- ⚠️ **Falta rodar a migration no banco local** (pendente de execução manual; arquivo SQL pronto)
|
||||
- ⚠️ **Falta validar visualmente** nos 3 layouts (Melissa/Rail/Clínica) — verificar que bloqueios aparecem em cinza após criar pelo BloqueioDialog
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Fase 2 — Compromisso COM paciente
|
||||
**Estado:** dialog refatorado em 11/05 (cards 40px, picker DataTable, 50/50 layout, 3 estados Sessão/Honorários, conceito Pacote, resumo flutuante). Working tree.
|
||||
|
||||
**Auditar:**
|
||||
- Fluxo de cadastro mínimo de paciente in-line (já existe via `PatientCadastroDialog` quick mode?)
|
||||
- Decidir #2 (Inquiry/lead separado ou só quick-create)
|
||||
- Modalidade presencial/online consistente
|
||||
|
||||
**Gap potencial:**
|
||||
- Quick-create exige só nome ou mais campos? (Cliniko: só nome; TherapyNotes: só last name)
|
||||
- Decisão #2 (Inquiry/lead) — adiar pra v2 provável
|
||||
|
||||
**Codar:** ajustes pequenos, principalmente UX. Provavelmente quase nada novo.
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Fase 3 — Recorrência
|
||||
**Estado:** modelo "1 real + N-1 virtual" + `occurrenceMode` no 2º dialog estabilizado em 12/05. Ver [[recorrencia-agenda]].
|
||||
|
||||
**Auditar:**
|
||||
- `occurrenceMode` já replicado em Melissa; falta Rail (`AgendaTerapeutaPage` L1630 + L3080) e Clínica (`AgendaClinicaPage` L1119 + L2398)
|
||||
- Decisão #3 (pacote upfront via account credit) — adiar provável
|
||||
|
||||
**Codar:** replicar `occurrenceMode` em Rail/Clínica. Talvez add de pacote upfront (Cliniko model) numa fase futura.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 Fase 4 — Cobrança: modo de disparo configurável (DECISÃO #1)
|
||||
**Estado:** Fase 1 atual ("Gerar cobrança ao salvar") existe como checkbox em criação avulsa+particular. Não tem setting de modo.
|
||||
|
||||
**Auditar:**
|
||||
- Onde vive a config? Card novo em `/configuracoes/excecoes-financeiras` ou página irmã `/configuracoes/cobranca-defaults`?
|
||||
- Granularidade: por tenant (clínica), por owner (terapeuta), ou ambos com herança?
|
||||
|
||||
**Gap:**
|
||||
- Tabela/coluna nova pra `charge_trigger_mode` enum (`manual` / `status_driven` / `note_signed`)
|
||||
- UI de config
|
||||
- Job overnight pra modo `status_driven` (Supabase edge function + cron)
|
||||
- Trigger no signature de nota pra `note_signed` (depende de modulo de notas; nao temos)
|
||||
- Checkbox atual da agenda passa a fazer sentido **só em modo manual** (ou vira override universal?)
|
||||
|
||||
**Codar:**
|
||||
1. Migration: setting de modo (tenant_billing_settings ou colunas em agenda_configuracoes)
|
||||
2. UI de config
|
||||
3. Job pra modo status_driven (avaliar se entra na v1 ou v2)
|
||||
4. Refator do checkbox atual pra respeitar o modo
|
||||
|
||||
---
|
||||
|
||||
### 🟠 Fase 5 — Status change → cobrança com confirm dialog (DECISÃO #4)
|
||||
**Estado:** lógica automática roda em `useAgendaFinanceiro.handleStatusChange`. Consulta regra em `financial_exceptions`, cria/ajusta/cancela `financial_record` SEM perguntar.
|
||||
|
||||
**Auditar:**
|
||||
- Quais status disparam: hoje só `faltou` e `cancelado` (mapping `STATUS_TO_EXCEPTION`)
|
||||
- `professional_cancellation` na tabela mas não no mapping
|
||||
- Onde `handleStatusChange` é chamado (quais entradas de status change disparam)
|
||||
|
||||
**Gap:**
|
||||
- Confirm dialog ao mudar status pra `faltou` / `cancelado`: *"Aplicar cobrança de R$X conforme regra? [Sim / Não / Editar valor]"*
|
||||
- Adicionar `professional_cancellation` ao mapping (status atual da agenda inclui? checar)
|
||||
- Decidir: dialog aparece **sempre** ou só quando `charge_mode !== 'none'`
|
||||
|
||||
**Codar:**
|
||||
1. Dialog componente novo (`AgendaStatusChargeConfirmDialog.vue`)
|
||||
2. Interceptar `handleStatusChange` antes da aplicação automática
|
||||
3. Adicionar `professional_cancellation` no mapping
|
||||
4. Toast diferenciado pra "aplicado/recusado/editado"
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Fase 6 — Edit de cobrada (DECISÃO #5 — JÁ IMPLEMENTADO)
|
||||
**Estado:** `propagateToSerie` filtra por `financial_records` em status imutável. UI lock em `AgendaEventDialog` via `occFinancialRecord`. Working tree.
|
||||
|
||||
**Auditar:** validar contra cenários reais (testar série com 4 sessões, 2 cobradas, 2 abertas; editar template; verificar que cobranças não mudam).
|
||||
|
||||
**Codar:** zero (talvez add de aviso UX se faltar clareza).
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Fase 7 — Pagamento como entidade separada (DECISÃO #8)
|
||||
**Estado:** hoje `financial_records.paid_at` marca pagamento (acoplado). Não tem entidade `payments` independente.
|
||||
|
||||
**Auditar:**
|
||||
- Como financial_records.paid_at é usado hoje (queries de receita, dashboards, conciliação)
|
||||
- Webhook PSP existente? (provável que PIX e cartão sejam manuais hoje)
|
||||
|
||||
**Gap:**
|
||||
- Migration: tabela `payments` (id, amount, method, paid_at, source, allocated_to_record_id NULL-able)
|
||||
- Alocação manual de pagamento "solto" a um financial_record
|
||||
- Pagamento parcial (1 payment cobre N records ou 1 record recebe N payments?)
|
||||
- Repo + composable + UI
|
||||
|
||||
**Codar:** fase pesada — provavelmente sub-dividir.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Fase 8 — Reembolso / credit note (DECISÃO #7)
|
||||
**Estado:** hoje só tem `financial_records.status='cancelled'`. Não preserva original como doc fiscal.
|
||||
|
||||
**Auditar:** processo fiscal atual (já emite NFS-e? quando? como cancela?)
|
||||
|
||||
**Gap:**
|
||||
- Migration: tabela `credit_notes` (id, original_record_id, amount, reason, issued_at)
|
||||
- Constraint: credit note tem valor ≤ |original|
|
||||
- UI no Financeiro pra "Reembolsar"
|
||||
- Integração com NFS-e (pode ser separada)
|
||||
|
||||
**Codar:** fase pesada — provavelmente sub-dividir.
|
||||
|
||||
---
|
||||
|
||||
### 🟣 Fase 9 — Plano Inicial (entrevista + N sessões regulares)
|
||||
**Estado:** apenas conceito; nada codado.
|
||||
|
||||
**Pedido do user (2026-05-14):** clínica cobra **1 entrevista inicial** (valor X) + **4 sessões regulares** (valor Y cada). É o "plano de entrada" pra novos pacientes. User faz isso manualmente hoje na clínica dele.
|
||||
|
||||
**Conceito:**
|
||||
- Config nas settings da agenda do tenant:
|
||||
- Toggle "Habilitar plano inicial"
|
||||
- Valor entrevista (R$)
|
||||
- Qtd de sessões regulares (default 4)
|
||||
- Valor por sessão regular (R$)
|
||||
- (Opcional) Texto/descrição que aparece no fluxo
|
||||
- Quando user cria 1ª sessão de **paciente novo** (sem histórico):
|
||||
- Sistema oferece: "Aplicar plano inicial? Entrevista R$ X + 4× R$ Y = total R$ Z"
|
||||
- Ao aceitar, materializa 5 sessões com `price` diferenciado: 1ª = X, demais = Y
|
||||
- Pode ser tratado como 1 série recorrente "especial" com 1ª ocorrência destacada
|
||||
- OU como 2 entidades distintas (1 avulsa entrevista + 1 série de 4)
|
||||
|
||||
**Decisões pendentes:**
|
||||
- Estrutura: série única com 1ª diferenciada OU avulsa + série separada?
|
||||
- Onde fica a config: `agenda_configuracoes` (jsonb adicional?) ou tabela nova `intake_plans`?
|
||||
- "Paciente novo" = sem sessões anteriores? Ou marcador manual no cadastro?
|
||||
- Plano único do tenant ou múltiplos planos (avaliação clínica, avaliação neuropsi, etc)?
|
||||
|
||||
**Cabe na Fase 4 (cobrança)?** Não — Fase 4 é só modo de disparo; aqui é estrutura de pacote pré-configurado. Fica como Fase 9 separada.
|
||||
|
||||
---
|
||||
|
||||
## Ordem sugerida de execução
|
||||
|
||||
| Ordem | Fase | Razão |
|
||||
|---|---|---|
|
||||
| 1ª | **Fase 1** | Curta, validação, define se tem cleanup de tabelas necessário |
|
||||
| 2ª | **Fase 5** | Destrava UX urgente (confirm dialog evita cobrar errado) |
|
||||
| 3ª | **Fase 4** | Híbrido configurável — destrava racional do checkbox atual |
|
||||
| 4ª | **Fase 2** | Quase 100% pronta, validar e finalizar |
|
||||
| 5ª | **Fase 3** | Replicar `occurrenceMode` em Rail/Clínica |
|
||||
| 6ª | **Fase 6** | Já feito; só testar |
|
||||
| 7ª | **Fase 7** | Refator estrutural pesado — entra depois das fases UX |
|
||||
| 8ª | **Fase 8** | Depende fiscal NFS-e — pode ir pra v2 |
|
||||
| 9ª | **Fase 9** | Plano Inicial (entrevista + 4 sessões) — pedido do user, conceito pronto, codar pós-7 |
|
||||
|
||||
## Como cada fase termina
|
||||
|
||||
1. Página da fase na wiki é atualizada com o resultado
|
||||
2. Commit dedicado com prefixo `agenda(fase-N): ...`
|
||||
3. Update no [[index]] da wiki
|
||||
4. Entrada no `log.md`
|
||||
@@ -14,6 +14,8 @@ _(people, places, organizations, products — pages that describe a thing)_
|
||||
|
||||
_(ideas, frameworks, patterns, principles — pages that describe a concept)_
|
||||
|
||||
- [[recorrencia-agenda]] — modelo "1 real + N-1 virtual", materialização ao mudar status, view `listAll`, visual de paciente inativo
|
||||
|
||||
## Sources
|
||||
|
||||
_(summaries of specific sources you've ingested)_
|
||||
@@ -22,6 +24,11 @@ _(summaries of specific sources you've ingested)_
|
||||
|
||||
_(synthesized answers to questions you've asked, filed back as pages)_
|
||||
|
||||
- [[agenda-billing-pesquisa-mercado]] — comparativo Cliniko / SimplePractice / TherapyNotes do ciclo compromisso→cobrança (6 etapas), consenso/divergência e 8 perguntas-chave pro produto
|
||||
- [[agenda-compromisso-fluxo]] — plano de auditoria fase-a-fase (8 fases) do ciclo de compromisso da agenda; ordem de execução + decisões já tomadas
|
||||
|
||||
---
|
||||
|
||||
*This index is maintained by Claude via `/wiki-brain`. Do not edit by hand unless you know what you're doing.*
|
||||
- [[Migracao Schema-per-Tenant]] — migração RLS-only → schema físico por tenant (F0 done, aguardando Q1-Q4)
|
||||
- [[Freemium PLG]] — signup self-service + Upgrade PRO; plano gratuito limitado (pacientes); confirmação de e-mail + onboarding; branch feat/freemium-plg
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Recorrência na Agenda
|
||||
|
||||
Como o sistema modela e exibe sessões recorrentes. Decisões arquiteturais importantes que não são óbvias só lendo o código.
|
||||
|
||||
## Modelo de dados — "1 real + N-1 virtual"
|
||||
|
||||
Quando o user cria "4 sessões semanais", o sistema escreve **só 2 rows** no banco:
|
||||
|
||||
1. **1 row em `agenda_eventos`** — a primeira ocorrência, materializada. Tem `recurrence_id` apontando pra regra abaixo.
|
||||
2. **1 row em `recurrence_rules`** — a "semente": `start_date`, `type='weekly'`, `interval=1`, `max_occurrences=4` (ou `open_ended=true`).
|
||||
|
||||
As **sessões 2, 3, 4 NÃO existem no banco**. São geradas em runtime por `useRecurrence.loadAndExpand` — chamado pelas páginas de agenda quando precisam exibir um range. ID virtual: `rec::ruleId::originalDateISO`.
|
||||
|
||||
Trade-off da escolha:
|
||||
- ✅ Cria recorrência infinita (open-ended) sem inflar o DB
|
||||
- ✅ Mudança na regra (preço, modalidade, etc) reflete em todas as ocorrências automaticamente
|
||||
- ❌ Toda exibição precisa chamar `loadAndExpand` no range visível
|
||||
- ❌ Edição de uma ocorrência específica exige **materializar** primeiro (criar a row real com `recurrence_id` + `recurrence_date`)
|
||||
|
||||
## Quem expande virtuais (e quem não)
|
||||
|
||||
**Expande:**
|
||||
- `AgendaTerapeutaPage` (Rail) — `loadAndExpand` no range mensal + na busca
|
||||
- `AgendaClinicaPage` (Clínica) — mesma coisa, com tenant
|
||||
- `useMelissaAgenda._reloadRange` (Melissa FullCalendar) — expande no range visível
|
||||
- `usePatientSessions.load` — range -6mo a +12mo, filtra por `patient_id`
|
||||
- `useMelissaEventos._fetchRange` — expande no range pedido. Cobre widget "Hoje", mini-cal, fallback
|
||||
- `useMelissaTodasSessoesPaciente.fetch` — range -6mo a +12mo, filtra por `patient_id`
|
||||
|
||||
**Antes de 2026-05-11** os 3 últimos NÃO expandiam — uma série semanal de 4 aparecia como 1. Comentário no código admitia "adicionar quando promover Melissa pra produção". Bug resolvido nesta data.
|
||||
|
||||
## Cap do range — `MAX_RANGE_DAYS = 730`
|
||||
|
||||
`useRecurrence.expandRules` loga warning quando o range visível ultrapassa 730 dias (2 anos). É só warning, não bloqueia. A `listAll` view custom do MelissaAgenda usa exatamente `duration: { years: 2 }` pra bater no cap.
|
||||
|
||||
## Materialização — "ao mudar status numa virtual"
|
||||
|
||||
UPDATE direto numa row com `id = "rec::..."` quebra com `invalid input syntax for type uuid`. Pra mudar status (cancelar, marcar realizada, etc) numa ocorrência virtual, é preciso:
|
||||
|
||||
1. Buscar em `agenda_eventos` se já existe row materializada (`recurrence_id` + `recurrence_date`).
|
||||
2. Se sim, UPDATE status nela.
|
||||
3. Se não, **INSERT nova row** copiando campos da virtual + status novo.
|
||||
|
||||
Pattern central: **`useMelissaAgenda.onUpdateSeriesEvent(...)`** (e gêmeas em `AgendaTerapeutaPage` / `AgendaClinicaPage`). Aceita opcional `row` do chamador — quando o user clica direto no evento sem abrir o dialog antes, `dialogEventRow` está vazio e a função precisaria buscar a regra de outro lugar.
|
||||
|
||||
### Caminhos que mudam status (e como chegam à materialização)
|
||||
|
||||
| Onde | Composable/Handler | Comportamento virtual |
|
||||
|---|---|---|
|
||||
| `MelissaEventoPanel` (painel lateral do calendário) | `MelissaLayout.updateEventoStatus` | Detecta `is_occurrence` → delega `M.onUpdateSeriesEvent({ row: ev, ... })` |
|
||||
| `AgendaEventDialog` SelectButton form.status (Cancelado/Remarcar) | `useAgendaEventActions` watcher | `emit('updateSeriesEvent', { row, ... })` em vez de UPDATE direto |
|
||||
| `AgendaEventDialog` pills da série | `useAgendaEventLifecycle.onPillStatusChange` | Já emitia desde sempre |
|
||||
| `MelissaPaciente` Tab Agenda botões diretos | `usePatientSessions.updateStatus(ev, status)` | Aceita row inteira; se virtual, materializa internamente |
|
||||
|
||||
**Guard importante** em `onUpdateSeriesEvent`: se `recurrence_id` resolver pra `null` (callerRow + dialogEventRow ambos sem ele), aborta com toast. Antes criava row órfã com `patient_id: null` aparecendo "Faltou sem nome" no calendário.
|
||||
|
||||
## View `listAll` no MelissaAgenda
|
||||
|
||||
View custom (não built-in do FC) com `duration: { years: 2 }`. `setView('lista')` faz `gotoDate(hoje - 1 ano)` pra centrar passado + presente + futuro. Substituiu `listWeek` que mostrava só 7 dias.
|
||||
|
||||
Banner `showRecurrenceHint` aparece nas outras views (dia/semana/mês) quando há virtuais visíveis — botão "Ver na lista" troca pra `listAll`. Sem o banner, user não percebe que tem ocorrências fora do range.
|
||||
|
||||
## Visual de evento inativo
|
||||
|
||||
`normalizeEvent` (`useMelissaEventos.js` + `useMelissaAgenda.normalizeForMelissa`) carrega `paciente_status` do JOIN. MelissaAgenda aplica `classNames: ['ma-evt--inactive-patient']` quando `'Arquivado'|'Inativo'` — CSS dá borda tracejada + opacidade 0.58 + itálico em list view. Mantém a cor do commitment pra não perder contexto.
|
||||
|
||||
Picker do AgendaEventDialog / V2: mostra TODOS os pacientes (Ativo > Inativo > Arquivado), nao-Ativos com Tag + disabled + tooltip. `selectPaciente` bloqueia non-Ativo como defesa em camadas.
|
||||
|
||||
## Quando algo der errado
|
||||
|
||||
Se aparecer "sessão fantasma sem nome" no calendário, provavelmente é row órfã criada por materialização sem `patient_id`/`recurrence_id`. Query pra detectar:
|
||||
|
||||
```sql
|
||||
SELECT id, inicio_em, status, patient_id, recurrence_id
|
||||
FROM agenda_eventos
|
||||
WHERE patient_id IS NULL
|
||||
AND recurrence_id IS NULL
|
||||
AND tipo = 'sessao'
|
||||
AND created_at > NOW() - INTERVAL '1 day';
|
||||
```
|
||||
|
||||
Causa raiz já corrigida em 2026-05-11 (guard contra `rid` null em `onUpdateSeriesEvent`), mas o pattern de query é útil pra catch futuros.
|
||||
|
||||
## Invariante de cobrança em séries — "cobrança emitida é imutável"
|
||||
|
||||
**Padrão adotado (SimplePractice / TherapyNotes / Cliniko):** `financial_records` em status `pending`/`paid`/`overdue` são **imutáveis pelo dialog da agenda**. Ajustes só via fluxo do Financeiro (cancelar + refaturar). Garante:
|
||||
- Trilha fiscal estável.
|
||||
- Paciente não vê valor "mágico" mudando.
|
||||
- Dashboards de MRR e projeção consistentes.
|
||||
|
||||
### Como o sistema honra a invariante
|
||||
|
||||
**1. Lock no `occurrenceMode`** (`AgendaEventDialog.vue`):
|
||||
- Card "Sessão / Honorários" detecta `occFinancialRecord` via query `financial_records` filtrada por `agenda_evento_id` + status `in ('pending','paid','overdue')`.
|
||||
- Se record existe → renderiza apenas `AgendaEventoFinanceiroPanel` + mensagem de lock + Tag de status. Select de billingType e botão "Editar itens" desaparecem.
|
||||
- Se record não existe (virtual ou materializado sem cobrança) → edição livre, marca `services_customized=true` ao salvar.
|
||||
- Card "Aplicar alterações em" também é ocultado quando há cobrança (mudanças estruturais não se aplicam — usuário só pode mexer em status/horário).
|
||||
|
||||
**2. Filtro em `propagateToSerie`** (`useCommitmentServices.js`):
|
||||
- Após filtrar eventos elegíveis (recurrence_id + opcionalmente fromDate + opcionalmente services_customized=false), faz 1 query batch em `financial_records` pra coletar `agenda_evento_id` lockados.
|
||||
- Remove esses IDs da lista de elegíveis antes de fazer `delete + insert` de `commitment_services`.
|
||||
- Resultado: editar template da regra **nunca toca** ocorrências cobradas, mesmo em escopo `todos`.
|
||||
|
||||
**3. Aviso fixo no dialog pai** (em `isEdit && hasSerie`):
|
||||
- Mensagem inline abaixo do `AgendaEventoFinanceiroPanel`: "Alterações de tipo ou serviços afetam apenas sessões futuras ainda não cobradas. Cobranças já emitidas permanecem inalteradas — para ajustá-las, acesse o Financeiro."
|
||||
|
||||
### Opção `todos_sem_excecao` removida da UI
|
||||
|
||||
- O nome confundia (sugeria "ignorar cobranças") quando na verdade era "ignorar customização operacional" (`services_customized=true`).
|
||||
- Backend mantém o caso pra compat, mas `editScopeOptions` agora só retorna 3 valores: `somente_este`, `este_e_seguintes`, `todos`.
|
||||
- Mercado consolidado (SimplePractice etc) não expõe override de customizações — admin que precisa reseta sessão-a-sessão.
|
||||
|
||||
### Onde está cada peça
|
||||
- `src/features/agenda/composables/useAgendaEventLifecycle.js` — `loadOccFinancialRecord` + `occFinancialRecord` ref
|
||||
- `src/features/agenda/components/AgendaEventDialog.vue` — card lock/unlock + aviso pai
|
||||
- `src/features/agenda/composables/useCommitmentServices.js:162` — `propagateToSerie` com filtro financial_records
|
||||
- `src/features/agenda/composables/useAgendaEventComposer.js:91` — `editScopeOptions` com 3 valores
|
||||
- `src/components/agenda/AgendaEventoFinanceiroPanel.vue` — UI do fluxo Financeiro embarcado
|
||||
|
||||
## 2º dialog empilhado — edição de ocorrência (occurrenceMode)
|
||||
|
||||
Quando o user clica "Editar" em uma pill da lista "Recorrências Aplicadas", abre um **segundo `AgendaEventDialog` empilhado** por cima do principal. Ele compartilha o mesmo componente, mas com a prop `occurrenceMode=true` que muda comportamento:
|
||||
|
||||
- **Título:** `Pacote · X de Y Sessões` (computa `occurrenceIndex` via `currentRecurrenceDate` + `serieEvents`) em vez do padrão `Sessão do Pacote · {nome}`.
|
||||
- **Layout enxuto:** renderiza apenas 4 cards na ordem: (1) Dados da Recorrência (read-only summary), (2) Status, (3) Horário, (4) Aplicar alterações em. Tudo o resto (paciente-hero, fields-grid, serie-panel, sessão/honorários, frequência, extras, resumo mobile) fica oculto via `v-if="!occurrenceMode"`.
|
||||
- **Escopo `Aplicar alterações em`:** migrou do `composer-right` do dialog pai pra dentro do dialog de ocorrência. O pai não mostra mais esse card — pra mudar escopo, o user obrigatoriamente vai pela pill.
|
||||
- **Horário editável:** botão "Ajustar horário" não fica `:disabled="isEdit"` no occurrenceMode (no pai sim — data/horário do pacote inteiro é imutável após criação).
|
||||
|
||||
Stack relevante:
|
||||
- `MelissaLayout.vue:2160` monta o 2º dialog passando `:occurrenceMode="true"` + `eventRow={ ...row, recurrence_date, _is_virtual }` via refs `agendaOccDialog*` (destructurados de `useMelissaAgenda` no setup — refs aninhados não auto-unwrap no template).
|
||||
- `useMelissaAgenda.onEditSeriesOccurrence` popula `occDialogEventRow` + abre `occDialogOpen=true`. Substituiu o pattern antigo de mutar `dialogEventRow` in-place (que trocava silenciosamente os dados do dialog atual).
|
||||
- `useAgendaEventLifecycle.onPillEditClick` emite `editSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual })`.
|
||||
|
||||
**Pendente replicar:** Rail (`AgendaTerapeutaPage`) e Clínica (`AgendaClinicaPage`) ainda têm só o dialog principal — o 2º só existe no Melissa por enquanto.
|
||||
|
||||
## Referências de código
|
||||
|
||||
- `src/features/agenda/composables/useRecurrence.js` — `loadAndExpand`, `expandRules`, `mergeWithStoredSessions`, `buildOccurrence`
|
||||
- `src/layout/melissa/composables/useMelissaAgenda.js:817` — `onEditSeriesOccurrence`
|
||||
- `src/layout/melissa/composables/useMelissaAgenda.js:837` — `onUpdateSeriesEvent`
|
||||
- `src/features/agenda/composables/useAgendaEventActions.js:65` — watcher do form.status
|
||||
- `src/features/patients/composables/usePatientSessions.js:189` — `updateStatus` com materialização
|
||||
- `src/features/agenda/components/AgendaEventDialog.vue` — props `occurrenceMode`, computeds `occurrenceIndex` / `occurrenceTotalSessions` / `headerMainLabel`
|
||||
- `src/layout/melissa/MelissaLayout.vue:655` — `updateEventoStatus` do `MelissaEventoPanel`
|
||||
- `src/layout/melissa/MelissaLayout.vue:2160` — 2º AgendaEventDialog empilhado
|
||||
- `src/layout/melissa/MelissaAgenda.vue:244` — `VIEW_MAP.lista = 'listAll'`
|
||||
@@ -0,0 +1,514 @@
|
||||
# Composable Blueprint
|
||||
|
||||
> **Stack:** Vue 3 Composition API + Pinia (para state global) + Supabase via repository
|
||||
> **Canônicos:** `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
|
||||
> **Aplicável:** todo composable que orquestra estado reativo sobre uma repository
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
Composable é **wrapper fino** sobre a repository. Responsabilidade:
|
||||
- Manter **estado reativo** (data + loading + error)
|
||||
- Chamar a repository (delegação 1:1)
|
||||
- (Opcional) Cachear com stale-while-revalidate
|
||||
- (Opcional) Compor outros composables
|
||||
|
||||
**Não faz:**
|
||||
- Lógica de banco direta (vai no repository)
|
||||
- Lógica de UI (vai no componente)
|
||||
- Manipulação de DOM
|
||||
- I/O direto fora do repository
|
||||
|
||||
> Regra de ouro: **se o composable tem `from('...')` do Supabase, ele virou repository disfarçado — refatorar.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Estrutura de arquivos
|
||||
|
||||
```
|
||||
src/features/<modulo>/composables/
|
||||
├── use<Entity>.js # CRUD básico (thin wrapper)
|
||||
├── use<Entity>Clinic.js # variant clinic-scoped (se aplicável)
|
||||
├── use<Entity>Settings.js # config/preferences (com cache opt-in)
|
||||
├── use<Entity>Lifecycle.js # orquestrador de estados (se domain complexo)
|
||||
└── <entity>Helpers.js # funções puras auxiliares (não-composable)
|
||||
```
|
||||
|
||||
**Convenção de nome:** sempre `use<Entity>...`. Funções helpers de domínio NÃO usam prefixo `use` — não são composables.
|
||||
|
||||
---
|
||||
|
||||
## 3. State shape canônico
|
||||
|
||||
Todo composable expõe **no mínimo** este shape:
|
||||
|
||||
```js
|
||||
const rows = ref([]); // ou single ref dependendo do domínio
|
||||
const loading = ref(false); // boolean
|
||||
const error = ref(''); // string vazia, não null — facilita v-if
|
||||
```
|
||||
|
||||
**Decisões importantes:**
|
||||
|
||||
| Refs | Tipo | Inicial | Por quê |
|
||||
|---|---|---|---|
|
||||
| `loading` | `boolean` | `false` | Padrão V3 — UI binda `:disabled="loading"` direto |
|
||||
| `error` | `string` | `''` (vazio) | `v-if="error"` é falsy-friendly; sem null check |
|
||||
| `rows`/data | `Array` ou objeto | `[]` ou `null` | Reset pra `[]` em erro de load — UI fica previsível |
|
||||
|
||||
**Anti-pattern:** misturar `error = ref(null)` num composable e `error = ref('')` em outro. Canonize `''` no projeto inteiro.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tipos de composable (3 patterns)
|
||||
|
||||
### Tipo A — Thin wrapper (default) · referência: `useAgendaClinicEvents.js`
|
||||
|
||||
CRUD direto, sem cache, com loading/error em TODA operação:
|
||||
|
||||
```js
|
||||
import { ref } from 'vue';
|
||||
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>Repository';
|
||||
|
||||
export function useX() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadRange({ startISO, endISO, ...scope } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listX({ startISO, endISO, ...scope });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload, opts = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await createX(payload, opts);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar.';
|
||||
throw e; // ← re-throw: composable repassa o erro pro componente decidir
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch, opts = {}) { /* idem */ }
|
||||
async function remove(id, opts = {}) { /* idem */ }
|
||||
|
||||
return { rows, loading, error, loadRange, create, update, remove };
|
||||
}
|
||||
```
|
||||
|
||||
**Por que re-throw nas mutações?** Componente precisa saber se o `await` falhou pra:
|
||||
- Mostrar toast
|
||||
- Não fechar modal
|
||||
- Não navegar
|
||||
- Manter form com dados
|
||||
|
||||
`error.value` é só pra estado reativo persistente. Mutação síncrona precisa de throw também.
|
||||
|
||||
### Tipo B — Thin wrapper "extra-leve" · referência: `useAgendaEvents.js`
|
||||
|
||||
Variant aceitável quando mutações **não precisam de loading**:
|
||||
|
||||
```js
|
||||
async function create(payload) {
|
||||
return createX(payload); // ← repassa erro nativamente; componente try/catch
|
||||
}
|
||||
```
|
||||
|
||||
**Quando usar:** UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A.
|
||||
|
||||
### Tipo C — Cache com stale-while-revalidate · referência: `useAgendaSettings.js`
|
||||
|
||||
Para dados raros/pesados (settings, preferences, listas estáveis):
|
||||
|
||||
```js
|
||||
import { ref } from 'vue';
|
||||
import { getX } from '../services/<feature>Repository';
|
||||
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||
|
||||
export function useX(opts = {}) {
|
||||
const useCache = !!opts.cache;
|
||||
const cache = useCache ? useMelissaCacheStore() : null;
|
||||
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function _doFetch() {
|
||||
const result = await getX();
|
||||
data.value = result;
|
||||
if (cache) {
|
||||
const key = result?.owner_id || 'anon';
|
||||
cache.set('xKey', result, key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (cache) {
|
||||
const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey);
|
||||
if (cached) {
|
||||
data.value = cached;
|
||||
_doFetch().catch((e) => console.warn('[useX] revalidate', e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await _doFetch();
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar.';
|
||||
data.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { data, loading, error, load };
|
||||
}
|
||||
```
|
||||
|
||||
**Decisões do Tipo C:**
|
||||
|
||||
- **`opts.cache` default `false`** — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in.
|
||||
- **Cache key inclui scope** (`owner_id`/`tenant_id`) — invalida automaticamente em troca de usuário/tenant.
|
||||
- **TTL constants no store** — `MELISSA_CACHE_TTL.<feature>` (não hardcoded no composable).
|
||||
- **Stale-while-revalidate:** retorna cached SE existe + dispara fetch em background (sem await).
|
||||
- **Revalidate fail é warn**, não error — UI já tem dados válidos do cache.
|
||||
|
||||
---
|
||||
|
||||
## 5. Convenções de nomenclatura
|
||||
|
||||
### Funções
|
||||
|
||||
| Operação | Nome canônico | Variantes aceitas |
|
||||
|---|---|---|
|
||||
| Listar com filtro | `loadRange` / `loadMy<X>` | `load<Scope><Range>` |
|
||||
| Criar | `create` | `create<Scope>` (se houver ambiguidade) |
|
||||
| Atualizar | `update` | `update<Scope>` |
|
||||
| Remover | `remove` | `remove<Scope>` (nunca `delete` — palavra reservada) |
|
||||
| Recarregar | `refresh` | `reload` |
|
||||
| Limpar estado | `reset` / `clear` | — |
|
||||
|
||||
**Scope sufixo** quando o composable serve múltiplos contextos: `loadMyRange` (terapeuta) vs `loadClinicRange` (admin).
|
||||
|
||||
### State refs
|
||||
|
||||
- `rows` — coleção principal (array)
|
||||
- `record` — single (quando faz sentido)
|
||||
- `data` — genérico (settings, config)
|
||||
- `loading` — boolean único; se há múltiplos `loading` (load vs save), nomear: `loadingList`, `saving`
|
||||
- `error` — string única; mesmo princípio: `loadError`, `saveError` se precisar
|
||||
|
||||
---
|
||||
|
||||
## 6. Anatomia padrão de uma operação `load*`
|
||||
|
||||
```js
|
||||
async function loadXxx(args) {
|
||||
// 1. Validação leve (early return, não throw)
|
||||
if (!args?.required) return;
|
||||
|
||||
// 2. State flag
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all)
|
||||
const result = await listX(args);
|
||||
|
||||
// 4. Mutate state
|
||||
rows.value = result;
|
||||
} catch (e) {
|
||||
// 5. Erro humano + reset de data (UI fica previsível)
|
||||
error.value = e?.message || 'Mensagem PT-BR genérica.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
// 6. Sempre limpar loading
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Por que early-return em vez de throw na validação?** Composable é wrapper — chamadas inválidas (ex: `ownerId` ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository.
|
||||
|
||||
---
|
||||
|
||||
## 7. Múltiplos fetches paralelos
|
||||
|
||||
Quando uma operação precisa de N queries:
|
||||
|
||||
```js
|
||||
async function _doFetch() {
|
||||
const [cfg, rules, profile] = await Promise.all([
|
||||
getMyAgendaSettings(),
|
||||
getMyWorkSchedule(),
|
||||
getMyProfile()
|
||||
]);
|
||||
settings.value = cfg;
|
||||
workRules.value = rules;
|
||||
profile.value = profile;
|
||||
}
|
||||
```
|
||||
|
||||
**Regras:**
|
||||
- `Promise.all` (não `Promise.allSettled`) — falha de qualquer query falha a operação inteira
|
||||
- Exception: quando uma query é opcional/best-effort → `Promise.allSettled` + processa por result
|
||||
- **Nunca** sequenciar fetches independentes (await + await + await)
|
||||
|
||||
---
|
||||
|
||||
## 8. Composição de composables
|
||||
|
||||
Composable pode usar outros composables, mas:
|
||||
|
||||
```js
|
||||
// ✅ certo — composição estrutural
|
||||
export function useAgendaEventLifecycle() {
|
||||
const events = useAgendaEvents();
|
||||
const billing = useAgendaFinanceiro();
|
||||
const settings = useAgendaSettings({ cache: true });
|
||||
|
||||
async function realizar(eventId) {
|
||||
// orquestra os 3
|
||||
}
|
||||
|
||||
return { ...events, realizar, ... };
|
||||
}
|
||||
|
||||
// ❌ errado — não compor pra economizar 1 linha
|
||||
export function useOnlyToWrapList() {
|
||||
const { rows, loadMyRange } = useAgendaEvents();
|
||||
return { rows, loadMyRange }; // ← isso é um re-export inútil
|
||||
}
|
||||
```
|
||||
|
||||
**Regra:** compõe quando há **orquestração**. Se é só forward, importa direto.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anti-patterns (NÃO fazer)
|
||||
|
||||
### ❌ Composable que tem `supabase.from('...')` direto
|
||||
|
||||
```js
|
||||
// ❌ — violação de camadas
|
||||
export function useFoo() {
|
||||
async function load() {
|
||||
const { data } = await supabase.from('foo').select('*');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ Move pra repository, composable só delega.
|
||||
|
||||
### ❌ `error` ora `null`, ora `''`, ora `Error`
|
||||
|
||||
Canonize `string` (default `''`). Errors do JS dão `e?.message || 'fallback PT-BR'`.
|
||||
|
||||
### ❌ Não resetar `rows` em erro de load
|
||||
|
||||
```js
|
||||
// ❌
|
||||
async function loadRange() {
|
||||
try { rows.value = await listX(); } catch (e) { error.value = e.message; }
|
||||
// rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro
|
||||
}
|
||||
```
|
||||
|
||||
✅ Reset `rows.value = []` no catch — UI fica determinística.
|
||||
|
||||
### ❌ Não re-throw mutações
|
||||
|
||||
```js
|
||||
// ❌
|
||||
async function create(payload) {
|
||||
try { return await createX(payload); }
|
||||
catch (e) { error.value = e.message; }
|
||||
// componente faz `await create()` e nunca sabe que falhou
|
||||
}
|
||||
```
|
||||
|
||||
✅ Re-throw após setar `error.value`.
|
||||
|
||||
### ❌ `Promise.all` quando uma falha é aceitável
|
||||
|
||||
Quando uma das queries pode falhar sem invalidar as outras, usar `Promise.allSettled`. Comum em listings que enriquece com lookups opcionais.
|
||||
|
||||
### ❌ State global em variável módulo
|
||||
|
||||
```js
|
||||
// ❌ — vaza entre componentes que compartilham o composable
|
||||
const rows = ref([]);
|
||||
export function useFoo() {
|
||||
return { rows };
|
||||
}
|
||||
```
|
||||
|
||||
✅ State sempre DENTRO da `function useFoo()`. Se precisar global, use Pinia store.
|
||||
|
||||
### ❌ Composable que faz `watch` no próprio state pra "side effect"
|
||||
|
||||
```js
|
||||
// ❌
|
||||
const rows = ref([]);
|
||||
watch(rows, () => { /* save algo */ });
|
||||
```
|
||||
|
||||
✅ Mover `watch` pro componente — composable não decide quando salvar.
|
||||
|
||||
**Exceção:** watch pra sincronizar com prop externa do composable (`watchEffect(() => loadRange(props.range))`) é OK.
|
||||
|
||||
### ❌ Composable retornando objeto enorme
|
||||
|
||||
Se o `return` tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cache store (Tipo C complementar)
|
||||
|
||||
Quando criar um composable Tipo C, garantir que existe entry em:
|
||||
|
||||
- `src/stores/melissaCacheStore.js` — `MELISSA_CACHE_TTL.<feature>` constante (TTL em ms)
|
||||
- `.get(key, scope, ttl)` retorna valor ou null
|
||||
- `.set(key, value, scope)` salva com timestamp
|
||||
- Invalidação manual: `.invalidate('<feature>')`
|
||||
|
||||
**TTL guidelines:**
|
||||
|
||||
| Tipo de dado | TTL sugerido |
|
||||
|---|---|
|
||||
| Settings/preferences | 5 min |
|
||||
| Listas estáveis (specialties, plans) | 30 min |
|
||||
| Catálogo (services, pricing) | 10 min |
|
||||
| Multi-tenant lookups | 5 min |
|
||||
| Anything user-edited | NÃO cachear (Tipo A) |
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist de auditoria por módulo
|
||||
|
||||
Quando rodar `/audit-module <nome>`, validar cada composable:
|
||||
|
||||
- [ ] Não tem `supabase.from(...)` direto — só importa da repository
|
||||
- [ ] State shape: `rows`/`data`, `loading: boolean`, `error: string`
|
||||
- [ ] `error` é string, default `''`
|
||||
- [ ] Reset de data em erro de load (`rows.value = []`)
|
||||
- [ ] Mutações re-throw após setar error.value
|
||||
- [ ] Nomenclatura: `loadRange`/`load<Scope>`, `create`, `update`, `remove`
|
||||
- [ ] `remove` não `delete` (palavra reservada)
|
||||
- [ ] Validação leve usa early-return (não throw)
|
||||
- [ ] Múltiplos fetches em `Promise.all` (não sequencial)
|
||||
- [ ] State DENTRO da `function use*()` (não em variável de módulo)
|
||||
- [ ] Sem `watch` em própria state pra side effect (mover pro componente)
|
||||
- [ ] Helpers de domínio em arquivo separado sem prefixo `use`
|
||||
- [ ] Se cacheia (Tipo C): `opts.cache` opt-in, default `false`; TTL em `MELISSA_CACHE_TTL`; cache key inclui scope
|
||||
- [ ] Return statement com chaves explícitas (não `return { ...state, ...actions }` opaco)
|
||||
- [ ] Return ≤ 15 chaves (>15 = composable fazendo coisa demais)
|
||||
|
||||
Divergências viram items em `dev_auditoria_items` com:
|
||||
- `categoria`: `padronizacao`
|
||||
- `tag`: `padronizacao:<modulo>`
|
||||
- `severidade`: alta se camada quebrada (composable com `from()`); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)
|
||||
|
||||
---
|
||||
|
||||
## 12. Exemplo completo (template)
|
||||
|
||||
```js
|
||||
/*
|
||||
| Arquivo: src/features/patients/composables/usePatients.js
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
listPatients,
|
||||
createPatient,
|
||||
updatePatient,
|
||||
deletePatient
|
||||
} from '@/features/patients/services/patientsRepository';
|
||||
|
||||
export function usePatients() {
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
async function loadRange({ search, status, tenantId } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
rows.value = await listPatients({ search, status, tenantId });
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar pacientes.';
|
||||
rows.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await createPatient(payload);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id, patch) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await updatePatient(id, patch);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await deletePatient(id);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover paciente.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadRange, create, update, remove };
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Referências
|
||||
|
||||
- Canônicos: `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
|
||||
- Repository pareado: `blueprints/repository-blueprint.md`
|
||||
- Cache store: `src/stores/melissaCacheStore.js`
|
||||
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
|
||||
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
|
||||
@@ -0,0 +1,431 @@
|
||||
# Quick-Create Overlay Blueprint
|
||||
|
||||
> **Status:** Pattern **universal**. Promovido de agenda-only em 2026-05-20 após audit baseline (`development/02-auditoria/AUDIT_BASELINE.md`) identificar 3 candidates já em produção fora da agenda.
|
||||
> **Stack:** Vue 3 + PrimeVue Dialog
|
||||
> **Canônicos:**
|
||||
> - `src/features/agenda/components/ServiceQuickCreateDialog.vue` (referência completa)
|
||||
> - `src/features/agenda/components/InsurancePlanQuickCreateDialog.vue`
|
||||
> - `src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue`
|
||||
> **Legacy a refatorar (supabase direto, sem repository):**
|
||||
> - `src/components/CadastroRapidoMedico.vue` → migrar pra `features/medicos/components/` (módulo 1 da Fase 1)
|
||||
> - `src/components/CadastroRapidoConvenio.vue` → migrar pra `features/insurance/components/`
|
||||
> - `src/components/ComponentCadastroRapido.vue` → migrar pra path apropriado conforme dono da entidade
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
**Problema:** usuário está num fluxo (ex: agendar uma sessão) e precisa de uma entidade dependente que ainda não existe (serviço, convênio, plano). Navegar pra outra página significa **perder o contexto** do form em progresso.
|
||||
|
||||
**Solução:** mini-dialog **por cima** do dialog/fluxo atual, com **campos mínimos** pra criar a entidade, e ao salvar **pré-seleciona** ela no select que disparou o quick-create.
|
||||
|
||||
**Regra absoluta:** criar dependência faltante em **qualquer fluxo** deve **abrir overlay POR CIMA, nunca navegar pra fora**. Aplicável em todo o sistema desde a promoção do blueprint (2026-05-20). Origem do pattern: agenda (memória `feedback_agenda_inline_quick_create`, agora generalizada).
|
||||
|
||||
---
|
||||
|
||||
## 2. Quando aplicar (vs alternativas)
|
||||
|
||||
| Situação | Solução |
|
||||
|---|---|
|
||||
| Fluxo crítico travado por dependência faltante (form em progresso) | **Quick-create overlay** ✅ |
|
||||
| Cadastro completo, com todos os campos | Página dedicada `/entity/new` ou Dialog full |
|
||||
| Apenas selecionar item existente | Select com busca; sem botão "+" |
|
||||
| Onboarding ou setup wizard | Não — fluxo é a página inteira, não um overlay |
|
||||
|
||||
**Anti-uso:** quick-create NÃO é "shortcut pra criar do menu lateral". É **fallback contextual** quando o form atual depende de algo que falta. O parent **precisa estar pronto pra receber o evento `created`** e usar o ID.
|
||||
|
||||
---
|
||||
|
||||
## 3. Estrutura do componente `<Entity>QuickCreateDialog.vue`
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
// CANÔNICO: importar da repository do feature dono da entidade.
|
||||
// LEGACY: 3 componentes em src/components/ usam supabase direto — refatorar quando módulo dono for tocado na Fase 1.
|
||||
import { createX } from '@/features/<feature>/services/<feature>Repository';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
ownerId: { type: String, default: '' },
|
||||
initialName: { type: String, default: '' } // pré-preenche do search atual do select
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'created']);
|
||||
|
||||
const toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const visible = ref(props.modelValue);
|
||||
watch(() => props.modelValue, (v) => { visible.value = v; });
|
||||
watch(visible, (v) => emit('update:modelValue', v));
|
||||
|
||||
const form = ref({ /* só campos MÍNIMOS obrigatórios + 1-2 opcionais úteis */ });
|
||||
const saving = ref(false);
|
||||
|
||||
// Resetar form toda vez que abre
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) form.value = { /* defaults + initialName */ };
|
||||
});
|
||||
|
||||
const canSave = () => /* validação leve */;
|
||||
|
||||
async function onSave() {
|
||||
if (!canSave()) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// Sanitize (trim + maxlength slice + nullif vazio) ANTES de chamar repository
|
||||
const payload = {
|
||||
name: form.value.name.trim().slice(0, 120),
|
||||
// ...resto sanitizado
|
||||
};
|
||||
|
||||
// Repository injeta owner_id (uid logado) + tenant_id (store) + faz uniqueness check
|
||||
// e throw em erro. Quick-create só decide o que mostrar ao usuário.
|
||||
const data = await createX(payload);
|
||||
|
||||
toast.add({ severity: 'success', summary: '<Entity> criado', life: 2200 });
|
||||
emit('created', data); // ← parent usa data.id pra pré-selecionar
|
||||
visible.value = false;
|
||||
} catch (e) {
|
||||
// Repository pode throw com message conhecido (ex: "Nome em uso") — mostra como warn ou error
|
||||
const isDup = /em uso|já existe|duplicate/i.test(e?.message || '');
|
||||
toast.add({
|
||||
severity: isDup ? 'warn' : 'error',
|
||||
summary: isDup ? 'Nome em uso' : 'Falha ao criar',
|
||||
detail: e?.message || 'Erro inesperado',
|
||||
life: 4000
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
LEGACY-NOTE (2026-05-20): os 3 quick-creates em src/components/ (CadastroRapidoMedico,
|
||||
CadastroRapidoConvenio, ComponentCadastroRapido) ainda usam supabase direto. Padrão acima
|
||||
é o CANÔNICO pós-promoção. Refator vai acontecer no módulo correspondente da Fase 1.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
header="Novo <entity>"
|
||||
class="w-[94vw] max-w-md"
|
||||
>
|
||||
<!-- Campos mínimos: 3-5 inputs, nada mais -->
|
||||
<div class="flex flex-col gap-3 pt-1"> ... </div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="saving" @click="visible = false" />
|
||||
<Button label="Salvar" :loading="saving" :disabled="!canSave()" @click="onSave" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Contrato canônico de props/emits
|
||||
|
||||
### Props (sempre)
|
||||
|
||||
| Prop | Tipo | Default | Função |
|
||||
|---|---|---|---|
|
||||
| `modelValue` | `Boolean` | `false` | Visibilidade do dialog. Two-way via `v-model`. |
|
||||
| `ownerId` | `String` | `''` | Owner_id (terapeuta). Default: usuário logado. |
|
||||
| `initialName` | `String` | `''` | Pré-preenche o campo nome com o search atual do select (UX win). |
|
||||
|
||||
### Props (opcionais por entidade)
|
||||
|
||||
- `parentId` (`String`) — quando a entidade tem hierarquia (ex: `plan_id` em `plan_service`)
|
||||
- `defaultDurationMin` (`Number`) — quando faz sentido herdar valor do contexto
|
||||
- Outras herdadas do contexto, **nunca** mais que 3 props extras (senão vira form pesado, não quick-create)
|
||||
|
||||
### Emits
|
||||
|
||||
| Evento | Payload | Quando |
|
||||
|---|---|---|
|
||||
| `update:modelValue` | `Boolean` | `v-model` two-way |
|
||||
| `created` | `Object` (row inserida completa) | Após insert bem-sucedido |
|
||||
|
||||
**Nunca emitir** `cancelled`, `closed`, `error` — parent não precisa saber dessas distinções; `update:modelValue=false` cobre.
|
||||
|
||||
---
|
||||
|
||||
## 5. Integração no parent
|
||||
|
||||
### Slot do botão `+` ao lado do select
|
||||
|
||||
```vue
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select v-model="selectedServiceId" :options="services" optionLabel="name" optionValue="id" class="flex-1" />
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
v-tooltip.top="'Cadastrar novo serviço'"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click="openServiceQuickCreate"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Lock do dialog parent
|
||||
|
||||
Parent **precisa** travar seu próprio `dismissableMask` e `closeOnEscape` enquanto qualquer quick-create child está aberto, senão clicar fora fecha tudo:
|
||||
|
||||
```vue
|
||||
<Dialog
|
||||
v-model:visible="parentVisible"
|
||||
:dismissableMask="!anyChildDialogOpen"
|
||||
:closeOnEscape="!anyChildDialogOpen"
|
||||
...
|
||||
>
|
||||
```
|
||||
|
||||
```js
|
||||
const serviceQuickCreateOpen = ref(false);
|
||||
const insuranceQuickCreateOpen = ref(false);
|
||||
const anyChildDialogOpen = computed(() =>
|
||||
serviceQuickCreateOpen.value || insuranceQuickCreateOpen.value
|
||||
);
|
||||
```
|
||||
|
||||
### Renderização dos quick-creates DENTRO do parent
|
||||
|
||||
```vue
|
||||
<!-- DENTRO do template do parent dialog, antes do </Dialog> -->
|
||||
<ServiceQuickCreateDialog
|
||||
v-model="serviceQuickCreateOpen"
|
||||
:owner-id="ownerId"
|
||||
:initial-name="serviceSearchText"
|
||||
@created="onServiceCreated"
|
||||
/>
|
||||
```
|
||||
|
||||
### Handler `on<Entity>Created`
|
||||
|
||||
```js
|
||||
function onServiceCreated(row) {
|
||||
// 1. Inserir na lista local (sem re-fetch)
|
||||
services.value = [row, ...services.value];
|
||||
// 2. Pré-selecionar no select
|
||||
selectedServiceId.value = row.id;
|
||||
// 3. (Opcional) Focar o próximo campo
|
||||
nextTick(() => priceInputRef.value?.focus());
|
||||
}
|
||||
```
|
||||
|
||||
### Handler `openXQuickCreate`
|
||||
|
||||
```js
|
||||
function openServiceQuickCreate() {
|
||||
serviceSearchText.value = currentSearchInSelect.value; // capture pra initialName
|
||||
serviceQuickCreateOpen.value = true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Convenções de UX
|
||||
|
||||
### Campos mínimos absolutos
|
||||
|
||||
Quick-create **não é cadastro completo**. Inclui só:
|
||||
- 1 campo obrigatório principal (nome)
|
||||
- 1-2 campos obrigatórios secundários (preço, duração)
|
||||
- 1 campo opcional (descrição)
|
||||
|
||||
Resto (categorias, tags, configurações avançadas) edita depois em `/entity/:id`.
|
||||
|
||||
### Maxlength visível
|
||||
|
||||
```vue
|
||||
<InputText v-model="form.name" maxlength="120" />
|
||||
```
|
||||
|
||||
Slice no save: `.trim().slice(0, 120)` — defesa em profundidade.
|
||||
|
||||
### Botão "+" sempre `size="small"` `severity="secondary"`
|
||||
|
||||
Discrição visual — não compete com CTA do dialog parent.
|
||||
|
||||
### Toast em vez de inline error
|
||||
|
||||
Mini-dialog não tem espaço pra banner de erro. Toast no canto superior direito (padrão PrimeVue) basta.
|
||||
|
||||
### `autofocus` no primeiro input
|
||||
|
||||
```vue
|
||||
<InputText autofocus v-model="form.name" />
|
||||
```
|
||||
|
||||
Usuário já está em modo "digitar" — pular o clique no input.
|
||||
|
||||
### `:loading="saving"` no botão Salvar
|
||||
|
||||
Spinner + disabled simultâneo. PrimeVue já dá o efeito visual.
|
||||
|
||||
---
|
||||
|
||||
## 7. Anti-patterns (NÃO fazer)
|
||||
|
||||
### ❌ Navegar pra rota nova no botão "+"
|
||||
|
||||
```js
|
||||
// ❌ — destrói o form em progresso
|
||||
function openServiceQuickCreate() {
|
||||
router.push('/saas/services/new');
|
||||
}
|
||||
```
|
||||
|
||||
✅ Abre o overlay.
|
||||
|
||||
### ❌ Quick-create que pede 10 campos
|
||||
|
||||
Se a entidade exige cadastro complexo (campos condicionais, validações cruzadas, upload de arquivo), **não cabe num quick-create**. Use página dedicada e aceite que o usuário perde contexto. Ou crie um wizard.
|
||||
|
||||
### ❌ Sem `dups check` antes do insert
|
||||
|
||||
```js
|
||||
// ❌ — usuário clica 2x, cria duplicata silenciosa
|
||||
await supabase.from('services').insert(payload).select().single();
|
||||
```
|
||||
|
||||
✅ `ilike` por `name` antes; aborta com warn toast.
|
||||
|
||||
### ❌ Não emitir o objeto completo no `created`
|
||||
|
||||
```js
|
||||
// ❌
|
||||
emit('created', { id: data.id }); // parent precisa de mais que id
|
||||
|
||||
// ❌ pior ainda
|
||||
emit('created'); // parent não sabe o que foi criado
|
||||
```
|
||||
|
||||
✅ `emit('created', data)` — row completa do banco.
|
||||
|
||||
### ❌ Não capturar `initialName` do search atual
|
||||
|
||||
Quando usuário digita "Sessão 50min" no select e clica "+", o `initialName=` deve já vir preenchido. Senão usuário re-digita.
|
||||
|
||||
### ❌ Parent sem `anyChildDialogOpen` no lock
|
||||
|
||||
Sem o lock, clicar fora do quick-create child fecha o parent inteiro. Bug clássico.
|
||||
|
||||
### ❌ Re-fetch da lista após `created`
|
||||
|
||||
```js
|
||||
// ❌ — round-trip desnecessário; o evento já trouxe o row
|
||||
async function onServiceCreated() {
|
||||
await loadServices();
|
||||
}
|
||||
```
|
||||
|
||||
✅ Inserir o `row` recebido direto na lista local; só re-fetch se houver lógica de ordenação complexa.
|
||||
|
||||
### ❌ Múltiplos quick-creates abertos ao mesmo tempo
|
||||
|
||||
Permitir abrir um quick-create de plano de saúde enquanto outro de serviço está aberto = stack visual confuso. Force fechar o atual antes de abrir o próximo, OU mantenha o lock no `anyChildDialogOpen` que cobre.
|
||||
|
||||
---
|
||||
|
||||
## 8. Sanitização (memória `feedback_sanitizacao`)
|
||||
|
||||
Toda entrada de quick-create:
|
||||
|
||||
```js
|
||||
const name = form.value.name?.trim().slice(0, 120) || null;
|
||||
const description = form.value.description?.trim().slice(0, 500) || null;
|
||||
const price = form.value.price != null ? Number(form.value.price) : null;
|
||||
```
|
||||
|
||||
Padrão: `trim()` → `slice(maxlength)` → `nullif vazio` → cast tipo.
|
||||
|
||||
Pro upload (não comum em quick-create, mas se houver): mime allowlist + size check antes de submitter.
|
||||
|
||||
---
|
||||
|
||||
## 9. Promotion History & Path Convention
|
||||
|
||||
### Histórico
|
||||
|
||||
- **2026-05-04** — Pattern nasceu em `features/agenda/` com 3 quick-creates (Service, InsurancePlan, InsurancePlanService). Documentado como **agenda-only** com promotion criteria explícito.
|
||||
- **2026-05-20** — Audit baseline identificou 3 candidates já em produção fora da agenda: `CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue` (todos `supabase` direto, em `src/components/`). Promotion criteria atingida 3×. **Blueprint promovido pra universal.**
|
||||
|
||||
### Path convention pós-promoção
|
||||
|
||||
| Caso | Path | Exemplo |
|
||||
|---|---|---|
|
||||
| Entidade pertence a 1 feature claro | `src/features/<feature>/components/<Entity>QuickCreateDialog.vue` | `features/medicos/components/MedicoQuickCreateDialog.vue` |
|
||||
| Entidade é cross-feature (raro) | `src/components/quick-create/<Entity>QuickCreateDialog.vue` | (nenhum hoje) |
|
||||
|
||||
**Anti-pattern:** quick-create morando em `src/components/` raiz sem subpasta — perde discoverability e mistura com componentes utilitários.
|
||||
|
||||
### Plano de migração dos 3 legacy
|
||||
|
||||
Cada refator acontece **quando o módulo dono for tocado na Fase 1**:
|
||||
|
||||
| Componente atual | Path destino | Quando | Fix obrigatório |
|
||||
|---|---|---|---|
|
||||
| `src/components/CadastroRapidoMedico.vue` | `src/features/medicos/components/MedicoQuickCreateDialog.vue` | Módulo 1 (Home/Components) — pode criar `features/medicos/` se ainda não existe | Migrar pra repository; usar `_tenantGuards` |
|
||||
| `src/components/CadastroRapidoConvenio.vue` | `src/features/insurance/components/InsurancePlanQuickCreateDialog.vue` (consolidar com o existente na agenda?) | Módulo 1 | Idem; **verificar se duplica `features/agenda/components/InsurancePlanQuickCreateDialog.vue`** |
|
||||
| `src/components/ComponentCadastroRapido.vue` | depende do que cria | Módulo 1 | Idem |
|
||||
|
||||
### Boilerplate DRY (futuro, não-prioritário)
|
||||
|
||||
Quando houver 5+ quick-creates seguindo o pattern, considerar:
|
||||
|
||||
- `useQuickCreateLock()` composable que encapsula `anyChildDialogOpen` (DRY entre parent dialogs com 2+ children)
|
||||
- `<BaseQuickCreateDialog>` wrapper component com slots `#fields`, `#footer-extra` e props padrão
|
||||
|
||||
**Não fazer agora** — 6 instâncias ainda é pouco pra inflar abstração. Pattern atual (cada quick-create standalone) é fácil de entender e copiar.
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist de auditoria
|
||||
|
||||
Aplica-se a **todo quick-create do sistema** pós-promoção (2026-05-20):
|
||||
|
||||
- [ ] Path correto (feature folder se entidade pertence a 1 feature; `src/components/quick-create/` se cross-feature)
|
||||
- [ ] Nome do arquivo: `<Entity>QuickCreateDialog.vue` (PascalCase)
|
||||
- [ ] Props canônicas: `modelValue`, `ownerId`, `initialName`
|
||||
- [ ] Emits canônicos: `update:modelValue`, `created`
|
||||
- [ ] `Dialog` com `modal`, `:draggable="false"`, `:closable="!saving"`
|
||||
- [ ] Form reset quando abre (`watch modelValue`)
|
||||
- [ ] Sanitização: `trim() + slice(maxlength) + nullif` ANTES de chamar repository
|
||||
- [ ] **Insert via repository** (não supabase direto) — repository injeta `owner_id`+`tenant_id` e faz uniqueness check
|
||||
- [ ] Toast feedback em success/warn/error (warn quando erro for "nome em uso", error caso contrário)
|
||||
- [ ] Emit `created` com row completo (não só id)
|
||||
- [ ] Parent: `anyChildDialogOpen` computed lock
|
||||
- [ ] Parent: `dismissableMask` e `closeOnEscape` bindados ao lock
|
||||
- [ ] Parent: handler `on<Entity>Created` insere row na lista local e pré-seleciona
|
||||
- [ ] Parent: `initialName` capturado do search atual do select
|
||||
- [ ] Botão "+": `size="small"` `severity="secondary"` `v-tooltip`
|
||||
- [ ] `autofocus` no primeiro input
|
||||
- [ ] `:loading="saving"` + `:disabled="!canSave()"` no Salvar
|
||||
- [ ] Máximo 3-5 inputs no form (senão não é quick-create — vira página dedicada)
|
||||
|
||||
Divergências viram items em `dev_auditoria_items` com:
|
||||
- `categoria`: `padronizacao`
|
||||
- `tag`: `padronizacao:<modulo>` (módulo dono da entidade)
|
||||
- `severidade`: **alta** se usa supabase direto em vez de repository, ou viola lock (vaza dismiss); **média** se viola contrato (emits/props); **baixa** se cosmético
|
||||
|
||||
---
|
||||
|
||||
## 11. Referências
|
||||
|
||||
- Canônicos: `src/features/agenda/components/ServiceQuickCreateDialog.vue`, `InsurancePlanQuickCreateDialog.vue`, `InsurancePlanServiceQuickCreateDialog.vue`
|
||||
- Parent integrador: `src/features/agenda/components/AgendaEventDialog.vue` (linhas ~3081-3107, ~3170, ~3274, ~3307)
|
||||
- Legacy a refatorar: `src/components/CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue`
|
||||
- Dialog base: `blueprints/dialog-blueprint.md`
|
||||
- Repository pareado: `blueprints/repository-blueprint.md`
|
||||
- Audit baseline: `development/02-auditoria/AUDIT_BASELINE.md` (3 candidates descobertos em 2026-05-20)
|
||||
- Memória: `feedback_agenda_inline_quick_create.md` (superseded — pattern agora universal), `feedback_sanitizacao.md`
|
||||
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
|
||||
@@ -0,0 +1,379 @@
|
||||
# Repository Blueprint
|
||||
|
||||
> **Stack:** Supabase JS client + Vue 3 (Pinia stores)
|
||||
> **Canônico:** `src/features/agenda/services/` (validado em C1-C13 + análise sênior 2026-05-20)
|
||||
> **Aplicável:** todo módulo com acesso a tabela `*` com `tenant_id`
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípio
|
||||
|
||||
Camada **thin** entre Supabase e composables. **Funções puras** + **tenant guards** + **SELECT canônico**. Sem classes, sem state, sem singletons. Idempotente, testável, descartável.
|
||||
|
||||
Composable orquestra estado e cache. **Repository só fala com o banco.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Estrutura de arquivos
|
||||
|
||||
```
|
||||
src/features/<modulo>/services/
|
||||
├── _tenantGuards.js # SHARED entre repositories do feature
|
||||
├── <feature>Selects.js # SELECT canônico + helpers de flatten
|
||||
├── <feature>Repository.js # CRUD escopo terapeuta (owner_id = uid)
|
||||
└── <feature>ClinicRepository.js # CRUD escopo clínica (se aplicável)
|
||||
```
|
||||
|
||||
**Regra do `_tenantGuards.js`:** se o feature tem 2+ repositories (terapeuta + clínica), os guards saem pra arquivo compartilhado. Se só tem 1, pode ficar no topo do próprio repo.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tenant guards canônicos
|
||||
|
||||
Copiar **literal** de `src/features/agenda/services/_tenantGuards.js`:
|
||||
|
||||
```js
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUid() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Usuário não autenticado.');
|
||||
return uid;
|
||||
}
|
||||
|
||||
export function assertIsoRange(startISO, endISO) {
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
|
||||
}
|
||||
|
||||
export function sanitizeOwnerIds(ownerIds) {
|
||||
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
|
||||
}
|
||||
```
|
||||
|
||||
**Por quê string `'null'`/`'undefined'`?** Vindo de URL params/localStorage stringificado, esses casos aparecem como string literal. Defesa em profundidade.
|
||||
|
||||
---
|
||||
|
||||
## 4. SELECT canônico
|
||||
|
||||
**Extrair pra constante exportada.** Inline SELECT em 3 lugares = divergência sutil (FKs explícitas em uns, não em outros) = bug.
|
||||
|
||||
```js
|
||||
/**
|
||||
* Select canônico de <tabela> com joins.
|
||||
*
|
||||
* FKs explícitas (obrigatórias quando há múltiplas colunas apontando pra mesma tabela):
|
||||
* - <tabela>_<col>_fkey
|
||||
*/
|
||||
export const <FEATURE>_SELECT = `
|
||||
id, owner_id, tenant_id, ...,
|
||||
patients!<tabela>_<col>_fkey (
|
||||
id, nome_completo, avatar_url, status
|
||||
)
|
||||
`.trim();
|
||||
```
|
||||
|
||||
E o **flatten helper** ao lado:
|
||||
|
||||
```js
|
||||
/**
|
||||
* Achata o aninhamento de patients dentro da row.
|
||||
* Mantém ambas formas (flat + nested) pra compat com call sites variados.
|
||||
*/
|
||||
export function flatten<Feature>Row(r) {
|
||||
if (!r) return r;
|
||||
const patient = r.patients || null;
|
||||
return {
|
||||
...r,
|
||||
paciente_nome: patient?.nome_completo || r.paciente_nome || '',
|
||||
paciente_avatar: patient?.avatar_url || r.paciente_avatar || '',
|
||||
paciente_status: patient?.status || r.paciente_status || ''
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Convenções de assinatura
|
||||
|
||||
### Funções puras exportadas
|
||||
|
||||
```js
|
||||
// ✅ certo
|
||||
export async function listMyEvents({ startISO, endISO, ownerId, tenantId } = {}) { ... }
|
||||
|
||||
// ❌ errado — classe com state
|
||||
class AgendaRepository {
|
||||
async list() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Args nomeados (destructure)
|
||||
|
||||
Posicionais quebram com refator. Default `= {}` evita TypeError se chamarem sem args.
|
||||
|
||||
### `tenantId` opcional → resolve via store
|
||||
|
||||
Helper local no repository:
|
||||
|
||||
```js
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { assertTenantId } from './_tenantGuards';
|
||||
|
||||
function resolveTenantId(tenantIdArg) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantIdArg || tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
return tenantId;
|
||||
}
|
||||
```
|
||||
|
||||
Por que opcional? Composable pode passar `tenantId` explícito (testes, multi-tenant ops). Default chega via store.
|
||||
|
||||
### Errors throw, nunca silent
|
||||
|
||||
```js
|
||||
const { data, error } = await supabase.from('...').select(...);
|
||||
if (error) throw error; // ✅
|
||||
// ❌ if (error) return null;
|
||||
// ❌ if (error) console.error(error);
|
||||
```
|
||||
|
||||
Composable decide se faz `try/catch` + toast.
|
||||
|
||||
### Ranges half-open
|
||||
|
||||
```js
|
||||
// ✅ certo — half-open
|
||||
.gte('inicio_em', startISO).lt('inicio_em', endISO)
|
||||
|
||||
// ❌ errado — fechado, gera off-by-one no último ms
|
||||
.gte('inicio_em', startISO).lte('inicio_em', endISO)
|
||||
```
|
||||
|
||||
### Strip campos legados antes de insert/update
|
||||
|
||||
```js
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...safePayload } = payload;
|
||||
```
|
||||
|
||||
Quando há migração de coluna em andamento ou campo virtual no UI.
|
||||
|
||||
---
|
||||
|
||||
## 6. Operações CRUD — pattern
|
||||
|
||||
### Create (owner-scoped)
|
||||
|
||||
```js
|
||||
export async function create<Feature>(payload) {
|
||||
if (!payload) throw new Error('Payload vazio.');
|
||||
const uid = await getUid();
|
||||
const tid = resolveTenantId();
|
||||
|
||||
const { paciente_id: _dropped, ...rest } = payload;
|
||||
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid };
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('<tabela>')
|
||||
.insert([insertPayload])
|
||||
.select(<FEATURE>_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return flatten<Feature>Row(data);
|
||||
}
|
||||
```
|
||||
|
||||
**Sempre:**
|
||||
- `tenant_id` injetado do store (não aceita do payload)
|
||||
- `owner_id` injetado do uid logado (ignora do payload — clinic-scoped variant pode aceitar explícito)
|
||||
- `.select(...)` + `.single()` retorna o registro completo
|
||||
|
||||
### Update
|
||||
|
||||
```js
|
||||
export async function update<Feature>(id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
if (!patch) throw new Error('Patch vazio.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { paciente_id: _dropped, ...safePatch } = patch;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('<tabela>')
|
||||
.update(safePatch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tid) // ← defesa em profundidade — RLS reforça no banco
|
||||
.select(<FEATURE>_SELECT)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return flatten<Feature>Row(data);
|
||||
}
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```js
|
||||
export async function delete<Feature>(id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.');
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('<tabela>')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tid);
|
||||
|
||||
if (error) throw error;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### List (range query)
|
||||
|
||||
```js
|
||||
export async function list<Feature>({ startISO, endISO, ownerId, tenantId } = {}) {
|
||||
assertIsoRange(startISO, endISO);
|
||||
const uid = ownerId || (await getUid());
|
||||
const tid = resolveTenantId(tenantId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('<tabela>')
|
||||
.select(<FEATURE>_SELECT)
|
||||
.eq('tenant_id', tid)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).map(flatten<Feature>Row);
|
||||
}
|
||||
```
|
||||
|
||||
### Clinic-scoped variant (admin/secretaria)
|
||||
|
||||
Diferenças em relação ao owner-scoped:
|
||||
- `tenantId` **obrigatório explícito** (sem default via store — admin pode operar em qualquer tenant onde tem permissão)
|
||||
- `ownerIds` é array (multi-terapeuta no mosaico) → `sanitizeOwnerIds` antes do `.in(...)`
|
||||
- Permite definir `owner_id` no create (admin cria pra qualquer terapeuta do tenant)
|
||||
- Sem `excludeMirror` automático — depende do uso
|
||||
|
||||
Referência: `src/features/agenda/services/agendaClinicRepository.js`
|
||||
|
||||
---
|
||||
|
||||
## 7. Anti-patterns (NÃO fazer)
|
||||
|
||||
### ❌ Inline SELECT espalhado
|
||||
|
||||
```js
|
||||
// ❌ em useFoo.js
|
||||
const { data } = await supabase.from('events').select('id, owner_id, patient_id, ...');
|
||||
|
||||
// ❌ em fooRepository.js
|
||||
const { data } = await supabase.from('events').select('id, owner_id, ...'); // ← divergente
|
||||
```
|
||||
|
||||
✅ Extrair pra `<feature>Selects.js`.
|
||||
|
||||
### ❌ `useTenantStore()` em vários arquivos
|
||||
|
||||
```js
|
||||
// ❌ em 5 arquivos diferentes
|
||||
const tenantStore = useTenantStore();
|
||||
const tid = tenantStore.activeTenantId;
|
||||
if (!tid) throw new Error('...');
|
||||
```
|
||||
|
||||
✅ `resolveTenantId(tenantIdArg)` no topo do repo.
|
||||
|
||||
### ❌ Aceitar `owner_id` do payload em create owner-scoped
|
||||
|
||||
```js
|
||||
// ❌ permite usuário criar evento "de outro terapeuta"
|
||||
await supabase.from('events').insert({ ...payload, tenant_id: tid });
|
||||
```
|
||||
|
||||
✅ Sempre injetar `owner_id` do uid logado (sobrescreve qualquer valor do payload).
|
||||
|
||||
### ❌ `delete()` sem `.eq('tenant_id', tid)`
|
||||
|
||||
```js
|
||||
// ❌ RLS deveria pegar, mas defesa em profundidade
|
||||
await supabase.from('events').delete().eq('id', id);
|
||||
```
|
||||
|
||||
✅ Sempre filtra `.eq('tenant_id', tid)` mesmo com RLS ativo.
|
||||
|
||||
### ❌ Return null em erro
|
||||
|
||||
```js
|
||||
// ❌
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
✅ `throw error`. Composable decide o que fazer.
|
||||
|
||||
### ❌ Range fechado
|
||||
|
||||
```js
|
||||
// ❌ — `2026-05-20` no `endISO` faz aparecer o dia inteiro do 20
|
||||
.gte('inicio_em', startISO).lte('inicio_em', endISO)
|
||||
```
|
||||
|
||||
✅ Half-open: `.gte(...).lt(...)`. Caller passa `endISO` como o início do próximo bucket.
|
||||
|
||||
### ❌ `paciente_id` (ou outro campo legado) chegando ao banco
|
||||
|
||||
A migração já dropou colunas legadas. Strip no `safePayload` evita 400 silencioso.
|
||||
|
||||
---
|
||||
|
||||
## 8. Checklist de auditoria por módulo
|
||||
|
||||
Quando rodar `/audit-module <nome>`, validar:
|
||||
|
||||
- [ ] `services/_tenantGuards.js` existe (ou inline se 1 repo só)
|
||||
- [ ] `services/<feature>Selects.js` existe e exporta `<FEATURE>_SELECT`
|
||||
- [ ] `services/<feature>Repository.js` é pure functions (sem classe/state)
|
||||
- [ ] `resolveTenantId(tenantIdArg)` local — não `useTenantStore()` espalhado
|
||||
- [ ] Toda operação injeta `tenant_id` no insert/update
|
||||
- [ ] Create owner-scoped injeta `owner_id` do uid logado (ignora do payload)
|
||||
- [ ] Update/delete filtram `.eq('id').eq('tenant_id', tid)` — defesa em profundidade
|
||||
- [ ] FKs explícitas nos joins (`<tabela>!<fk_name>`)
|
||||
- [ ] Errors `throw`, nunca silent
|
||||
- [ ] Ranges half-open (`gte + lt`)
|
||||
- [ ] Strip de campos legados em insert/update
|
||||
- [ ] Clinic-scoped variant (se existe) sem default via store, tenantId obrigatório
|
||||
- [ ] `flatten<Feature>Row` definido se há joins aninhados
|
||||
|
||||
Divergências viram items em `dev_auditoria_items` com:
|
||||
- `categoria`: `padronizacao`
|
||||
- `tag`: `padronizacao:<modulo>`
|
||||
- `severidade`: alta se viola segurança (tenant leak), média se viola convenção, baixa se cosmético
|
||||
- `arquivo`: path do arquivo
|
||||
- `solucao`: referência ao item do checklist
|
||||
|
||||
---
|
||||
|
||||
## 9. Referências
|
||||
|
||||
- Canônico: `src/features/agenda/services/`
|
||||
- Variant clinic: `src/features/agenda/services/agendaClinicRepository.js`
|
||||
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
|
||||
- Decisões macro: `development/02-auditoria/PADRONIZACAO.md`
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AgenciaPsi DB · 2026-05-04</title>
|
||||
<title>AgenciaPsi DB · 2026-05-11</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}
|
||||
@@ -101,7 +101,7 @@
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<div class="brand">Agência<span>Psi</span> DB</div>
|
||||
<span class="gen">2026-05-04 · 04/05/2026, 14:09:20</span>
|
||||
<span class="gen">2026-05-11 · 11/05/2026, 10:51:25</span>
|
||||
<input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
|
||||
<div class="pills">
|
||||
<div class="pill"><strong>138</strong> tabelas</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ function psqlFile(filePath) {
|
||||
const absPath = path.resolve(filePath);
|
||||
const content = fs.readFileSync(absPath, 'utf8');
|
||||
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
|
||||
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q -v ON_ERROR_STOP=1`;
|
||||
return execSync(cmd, {
|
||||
input: utf8Content,
|
||||
encoding: 'utf8',
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"seed_015_document_templates.sql",
|
||||
"seed_030_dev_phases_items.sql",
|
||||
"seed_031_dev_auditoria.sql",
|
||||
"seed_032_dev_competitors.sql"
|
||||
"seed_032_dev_competitors.sql",
|
||||
"seed_040_clinical_note_templates.sql",
|
||||
"seed_050_specialties.sql",
|
||||
"seed_060_consent_forms_extra.sql"
|
||||
],
|
||||
"test_data": [
|
||||
"seed_020_test_data.sql"
|
||||
@@ -106,7 +109,7 @@
|
||||
"contact_email_types", "contact_emails"
|
||||
],
|
||||
"Agenda / Agendamento": [
|
||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
|
||||
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes",
|
||||
"agenda_online_slots", "agenda_regras_semanais",
|
||||
"agenda_slots_bloqueados_semanais", "agenda_slots_regras",
|
||||
"agendador_configuracoes", "agendador_solicitacoes"
|
||||
|
||||
@@ -43,13 +43,12 @@ Mapa completo do banco de dados PostgreSQL 17, extraído de `schema.sql` (2026-0
|
||||
| `module_features` | Features por módulo |
|
||||
| `tenant_modules` | Módulos ativos por tenant |
|
||||
|
||||
### Agenda (11 tabelas)
|
||||
### Agenda (10 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 |
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- =============================================================================
|
||||
-- F5 (parte supabase_admin) — refresh dinâmico dos schemas expostos no PostgREST
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (postgres NÃO é superuser neste stack e não
|
||||
-- consegue ALTER ROLE authenticator). Mesmo padrão do gotcha de `documents`.
|
||||
--
|
||||
-- docker exec -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres \
|
||||
-- -f /dev/stdin < database-novo/manual/f5_pgrst_refresh_schemas.supabase_admin.sql
|
||||
--
|
||||
-- A config in-database do PostgREST (db-config, ligada por padrão) lê
|
||||
-- pgrst.db_schemas da role `authenticator`. Setar essa GUC + NOTIFY reload
|
||||
-- expõe/retira schemas tenant SEM restart do container. A GUC persiste em
|
||||
-- pg_db_role_setting (sobrevive a supabase stop/start).
|
||||
--
|
||||
-- A lista é derivada SEMPRE de public.tenant_schemas (fonte da verdade dos
|
||||
-- schemas provisionados). Disparada pelo trigger em tenant_schemas (migration
|
||||
-- 20260613000002) a cada clone/drop de tenant.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.refresh_pgrst_schemas()
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER -- roda como o OWNER (supabase_admin/superuser)
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_list text;
|
||||
BEGIN
|
||||
SELECT string_agg(s, ', ' ORDER BY ord, s)
|
||||
INTO v_list
|
||||
FROM (
|
||||
SELECT 'public'::text AS s, 0 AS ord
|
||||
UNION ALL SELECT 'graphql_public', 1
|
||||
UNION ALL SELECT schema_name, 2 FROM public.tenant_schemas
|
||||
) x;
|
||||
|
||||
-- baseline defensivo se a tabela ainda não existir / vazia
|
||||
IF v_list IS NULL OR v_list = '' THEN
|
||||
v_list := 'public, graphql_public';
|
||||
END IF;
|
||||
|
||||
EXECUTE format('ALTER ROLE authenticator SET pgrst.db_schemas = %L', v_list);
|
||||
NOTIFY pgrst, 'reload config'; -- re-lê db_schemas
|
||||
NOTIFY pgrst, 'reload schema'; -- reconstrói o cache de schema
|
||||
RETURN v_list;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Garante owner superuser (caso a função já existisse owned por postgres)
|
||||
ALTER FUNCTION public.refresh_pgrst_schemas() OWNER TO supabase_admin;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.refresh_pgrst_schemas() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.refresh_pgrst_schemas() TO postgres, service_role;
|
||||
|
||||
-- Seta o baseline imediatamente
|
||||
SELECT public.refresh_pgrst_schemas();
|
||||
@@ -0,0 +1,140 @@
|
||||
-- =============================================================================
|
||||
-- F6.1 — Migração de DADOS public -> schemas tenant (cutover)
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (precisa SET session_replication_role=replica
|
||||
-- pra desabilitar checagem de FK durante o bulk insert — postgres não pode).
|
||||
--
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_1_migrate_data.supabase_admin.sql
|
||||
--
|
||||
-- COPIA (não move) os dados de cada tenant pras suas tabelas no schema. Os
|
||||
-- dados continuam em public até o DROP da F6.3. Idempotente via ON CONFLICT
|
||||
-- DO NOTHING (rodar de novo não duplica).
|
||||
--
|
||||
-- * tabelas com tenant_id: INSERT ... SELECT WHERE tenant_id = <id>, sem a
|
||||
-- coluna tenant_id (não existe no schema)
|
||||
-- * 3 filhas sem tenant_id (commitment_services, insurance_plan_services,
|
||||
-- recurrence_rule_services): particionadas via JOIN no pai
|
||||
-- * financial_categories / therapist_payout_records: 0 linhas, ignoradas
|
||||
-- * as 6 tabelas anon-facing (F1b) NÃO existem no schema → naturalmente fora
|
||||
-- * reset de sequences (4 tabelas bigserial) ao final
|
||||
-- =============================================================================
|
||||
|
||||
SET session_replication_role = replica;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t_row record;
|
||||
tab record;
|
||||
v_cols text;
|
||||
v_n bigint;
|
||||
-- filhas sem tenant_id: tabela -> (pai, fk_local, pk_pai)
|
||||
child_joins jsonb := jsonb_build_object(
|
||||
'commitment_services', jsonb_build_object('parent','agenda_eventos','fk','commitment_id'),
|
||||
'insurance_plan_services', jsonb_build_object('parent','insurance_plans','fk','insurance_plan_id'),
|
||||
'recurrence_rule_services', jsonb_build_object('parent','recurrence_rules','fk','rule_id')
|
||||
);
|
||||
cj jsonb;
|
||||
BEGIN
|
||||
FOR t_row IN
|
||||
SELECT t.id AS tenant_id, ts.schema_name
|
||||
FROM public.tenants t
|
||||
JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
|
||||
ORDER BY t.created_at, t.id
|
||||
LOOP
|
||||
FOR tab IN
|
||||
SELECT c.relname AS table_name
|
||||
FROM pg_class c
|
||||
WHERE c.relnamespace = t_row.schema_name::regnamespace
|
||||
AND c.relkind = 'r'
|
||||
AND c.relname NOT LIKE '\_%'
|
||||
ORDER BY c.relname
|
||||
LOOP
|
||||
-- pula se a tabela não existe em public (defensivo)
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema='public' AND table_name=tab.table_name) THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- colunas presentes em AMBOS (schema e public): exclui tenant_id
|
||||
-- (some no schema), singleton (só no schema, fica no default) e
|
||||
-- colunas GENERATED (net_amount, margin_brl — não aceitam INSERT)
|
||||
SELECT string_agg(quote_ident(sc.column_name), ', ' ORDER BY sc.ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns sc
|
||||
WHERE sc.table_schema = t_row.schema_name AND sc.table_name = tab.table_name
|
||||
AND sc.is_generated = 'NEVER'
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns pc
|
||||
WHERE pc.table_schema='public' AND pc.table_name=tab.table_name
|
||||
AND pc.column_name = sc.column_name
|
||||
AND pc.is_generated = 'NEVER');
|
||||
|
||||
IF v_cols IS NULL THEN CONTINUE; END IF;
|
||||
|
||||
cj := child_joins -> tab.table_name;
|
||||
|
||||
IF cj IS NOT NULL THEN
|
||||
-- filha sem tenant_id: particiona via JOIN no pai
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I ch '
|
||||
|| 'JOIN public.%I p ON p.id = ch.%I WHERE p.tenant_id = %L '
|
||||
|| 'ON CONFLICT DO NOTHING',
|
||||
t_row.schema_name, tab.table_name, v_cols,
|
||||
(SELECT string_agg('ch.'||quote_ident(x), ', ' ORDER BY ord)
|
||||
FROM (SELECT trim(both ' ' from unnest(string_to_array(v_cols, ','))) AS x,
|
||||
generate_subscripts(string_to_array(v_cols, ','),1) AS ord) y),
|
||||
tab.table_name,
|
||||
(cj->>'parent'), (cj->>'fk'),
|
||||
t_row.tenant_id
|
||||
);
|
||||
ELSIF EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema='public' AND table_name=tab.table_name AND column_name='tenant_id') THEN
|
||||
-- tabela com tenant_id: filtro direto
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.%I (%s) SELECT %s FROM public.%I WHERE tenant_id = %L ON CONFLICT DO NOTHING',
|
||||
t_row.schema_name, tab.table_name, v_cols, v_cols, tab.table_name, t_row.tenant_id
|
||||
);
|
||||
ELSE
|
||||
-- sem tenant_id e não é filha mapeada (financial_categories etc.):
|
||||
-- só migra se tiver 0 dependência de tenant — pula (vazias hoje)
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT;
|
||||
IF v_n > 0 THEN
|
||||
RAISE NOTICE 'F6.1 %.%: % linhas', t_row.schema_name, tab.table_name, v_n;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Reset de sequences (tabelas bigserial) em cada schema
|
||||
-- ---------------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE
|
||||
t_row record;
|
||||
r record;
|
||||
v_seq text;
|
||||
BEGIN
|
||||
FOR t_row IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
FOR r IN
|
||||
SELECT c.relname AS table_name, a.attname AS column_name
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_class c ON c.oid = d.adrelid
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE c.relnamespace = t_row.schema_name::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(%'
|
||||
LOOP
|
||||
v_seq := pg_get_serial_sequence(format('%I.%I', t_row.schema_name, r.table_name), r.column_name);
|
||||
IF v_seq IS NOT NULL THEN
|
||||
EXECUTE format('SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I.%I), 0) + 1, false)',
|
||||
v_seq, r.column_name, t_row.schema_name, r.table_name);
|
||||
RAISE NOTICE 'F6.1 seq %.% -> %', t_row.schema_name, r.table_name, v_seq;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
SET session_replication_role = origin;
|
||||
@@ -0,0 +1,434 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote B — triggers schema-aware
|
||||
-- ⚠️ APLICAR COMO supabase_admin (trigger functions sao owned por supabase_admin):
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \n-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \n-- < database-novo/manual/f6_2b_schema_aware_triggers.supabase_admin.sql
|
||||
--
|
||||
-- Estratégia hybrid: as funções são reescritas IN PLACE pra operar no schema do
|
||||
-- TG_TABLE_SCHEMA (search_path dinâmico + tenant_id_for_schema). Como ficariam
|
||||
-- erradas nas tabelas de public (TG_TABLE_SCHEMA='public'), DESANEXAMOS dos
|
||||
-- tenant-tables de public e ANEXAMOS só nos schemas. Writes de public via RPCs
|
||||
-- ainda-não-migrados (Lote D) perdem esses side-effects no curto hybrid —
|
||||
-- aceitável (public vai ser dropado na F6.3 e o app lê dos schemas).
|
||||
--
|
||||
-- Exclui os que escrevem em notifications (Lote C, com o split):
|
||||
-- notify_on_session_status, fanout_inbound_message_to_notifications,
|
||||
-- cancel_notifications_on_opt_out/on_session_cancel, fn_notify_agenda_status_change
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 1) Rewrites — tabelas tenant via search_path (unqualified); globais com public.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- audit_logs é GLOBAL → tenant_id vem do schema
|
||||
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
|
||||
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
|
||||
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
|
||||
BEGIN
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
|
||||
ELSE
|
||||
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
|
||||
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;
|
||||
IF v_changed IS NULL THEN RETURN NEW; END IF;
|
||||
IF v_changed <@ v_noise 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 $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||
INSERT INTO patient_status_history (patient_id, status_anterior, status_novo, motivo, encaminhado_para, data_saida, alterado_por, alterado_em)
|
||||
VALUES (NEW.id, CASE WHEN TG_OP='INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.motivo_saida, NEW.encaminhado_para, NEW.data_saida, auth.uid(), now());
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
|
||||
VALUES (NEW.id, 'status_alterado', 'Status alterado para ' || NEW.status,
|
||||
CASE WHEN TG_OP='INSERT' THEN 'Paciente cadastrado'
|
||||
ELSE 'De ' || OLD.status || ' → ' || NEW.status || CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END END,
|
||||
CASE NEW.status WHEN 'Ativo' THEN 'green' WHEN 'Alta' THEN 'blue' WHEN 'Inativo' THEN 'gray' WHEN 'Encaminhado' THEN 'amber' WHEN 'Arquivado' THEN 'gray' ELSE 'gray' END,
|
||||
auth.uid(), now());
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em)
|
||||
VALUES (NEW.id, CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
|
||||
CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
|
||||
NEW.risco_nota, CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END, auth.uid(), now());
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.auto_create_financial_record_from_session()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_price numeric(10,2); v_services_total numeric(10,2); v_already_billed boolean;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF NEW.status::text <> 'realizado' THEN RETURN NEW; END IF;
|
||||
IF OLD.status IS NOT DISTINCT FROM NEW.status THEN RETURN NEW; END IF;
|
||||
IF NEW.tipo::text <> 'sessao' THEN RETURN NEW; END IF;
|
||||
IF NEW.patient_id IS NULL THEN RETURN NEW; END IF;
|
||||
IF NEW.billing_contract_id IS NOT NULL THEN RETURN NEW; END IF;
|
||||
|
||||
SELECT billed INTO v_already_billed FROM agenda_eventos WHERE id = NEW.id;
|
||||
IF v_already_billed = TRUE THEN
|
||||
IF EXISTS (SELECT 1 FROM financial_records WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_price := NULL;
|
||||
IF NEW.recurrence_id IS NOT NULL THEN
|
||||
SELECT COALESCE(SUM(rrs.final_price), 0) INTO v_services_total
|
||||
FROM recurrence_rule_services rrs WHERE rrs.rule_id = NEW.recurrence_id;
|
||||
IF v_services_total > 0 THEN v_price := v_services_total; END IF;
|
||||
IF v_price IS NULL OR v_price = 0 THEN
|
||||
SELECT price INTO v_price FROM recurrence_rules WHERE id = NEW.recurrence_id;
|
||||
END IF;
|
||||
END IF;
|
||||
IF v_price IS NULL OR v_price = 0 THEN v_price := NEW.price; END IF;
|
||||
IF v_price IS NULL OR v_price <= 0 THEN RETURN NEW; END IF;
|
||||
|
||||
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, type, amount, discount_amount, final_amount, clinic_fee_pct, clinic_fee_amount, status, due_date)
|
||||
VALUES (NEW.owner_id, NEW.patient_id, NEW.id, 'receita', v_price, 0, v_price, 0, 0, 'pending', (NEW.inicio_em::date + 7));
|
||||
|
||||
UPDATE agenda_eventos SET billed = TRUE WHERE id = NEW.id;
|
||||
RETURN NEW;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%', NEW.id, SQLERRM;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_sla_resolve_on_outbound()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_thread_key text;
|
||||
BEGIN
|
||||
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
v_thread_key := COALESCE(NEW.patient_id::text, 'anon:' || COALESCE(NEW.to_number, 'unknown'));
|
||||
UPDATE conversation_sla_breaches SET resolved_at = now(), resolved_by_message_id = NEW.id
|
||||
WHERE thread_key = v_thread_key AND resolved_at IS NULL;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE next_version integer; reason text;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1 INTO next_version FROM clinical_note_versions WHERE note_id = NEW.id;
|
||||
IF TG_OP = 'INSERT' THEN reason := 'criacao';
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN reason := 'soft_delete';
|
||||
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN reason := 'restore';
|
||||
ELSE reason := 'edicao'; END IF;
|
||||
ELSE reason := 'desconhecido'; END IF;
|
||||
INSERT INTO clinical_note_versions (note_id, version_number, title, content_text, content_structured, change_reason, created_at, created_by)
|
||||
VALUES (NEW.id, next_version, NEW.title, NEW.content_text, NEW.content_structured, reason, now(), COALESCE(NEW.updated_by, NEW.created_by));
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_patient_id uuid; v_doc_nome text;
|
||||
BEGIN
|
||||
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT d.patient_id, d.nome_original INTO v_patient_id, v_doc_nome FROM documents d WHERE d.id = NEW.documento_id;
|
||||
IF v_patient_id IS NOT NULL THEN
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
|
||||
VALUES (v_patient_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 OR REPLACE FUNCTION public.fn_documents_timeline_insert()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
INSERT INTO patient_timeline (patient_id, evento_tipo, titulo, descricao, icone_cor, link_ref_tipo, link_ref_id, gerado_por, ocorrido_em)
|
||||
VALUES (NEW.patient_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 OR REPLACE FUNCTION public.sync_legacy_email_fields()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
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 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 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 LIMIT 1;
|
||||
IF v_entity_type = 'patient' THEN
|
||||
UPDATE patients SET email_principal = v_primary, email_alternativo = v_secondary WHERE id = v_entity_id;
|
||||
ELSIF v_entity_type = 'medico' THEN
|
||||
UPDATE 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 $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_entity_type text; v_entity_id uuid; v_primary text; v_secondary text;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
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 number INTO v_primary FROM 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;
|
||||
SELECT number INTO v_secondary FROM 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 LIMIT 1;
|
||||
IF v_entity_type = 'patient' THEN
|
||||
UPDATE patients SET telefone = v_primary, telefone_alternativo = v_secondary WHERE id = v_entity_id;
|
||||
ELSIF v_entity_type = 'medico' THEN
|
||||
UPDATE 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 $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_agenda_regras_semanais_no_overlap()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_count int;
|
||||
BEGIN
|
||||
IF new.ativo IS false THEN RETURN new; END IF;
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT count(*) INTO v_count FROM agenda_regras_semanais r
|
||||
WHERE r.owner_id = new.owner_id AND r.dia_semana = new.dia_semana AND r.ativo IS true
|
||||
AND (TG_OP = 'INSERT' OR r.id <> new.id)
|
||||
AND (new.hora_inicio < r.hora_fim AND new.hora_fim > r.hora_inicio);
|
||||
IF v_count > 0 THEN RAISE EXCEPTION 'Janela sobreposta: já existe uma regra ativa nesse intervalo.'; END IF;
|
||||
RETURN new;
|
||||
END $$;
|
||||
|
||||
-- valida member consistency: tenant_id vem do schema; tenant_members é GLOBAL
|
||||
CREATE OR REPLACE FUNCTION public.patients_validate_member_consistency()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_tid uuid; v_tenant_responsible uuid; v_tenant_therapist uuid;
|
||||
BEGIN
|
||||
v_tid := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
SELECT tenant_id INTO v_tenant_responsible FROM public.tenant_members WHERE id = NEW.responsible_member_id;
|
||||
IF v_tenant_responsible IS NULL THEN RAISE EXCEPTION 'Responsible member not found'; END IF;
|
||||
IF v_tid IS NULL THEN RAISE EXCEPTION 'tenant não resolvido para schema %', TG_TABLE_SCHEMA; END IF;
|
||||
IF v_tenant_responsible <> v_tid THEN RAISE EXCEPTION 'Responsible member must belong to the same tenant'; END IF;
|
||||
IF NEW.patient_scope = 'therapist' THEN
|
||||
IF NEW.therapist_member_id IS NULL THEN RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist'; END IF;
|
||||
SELECT tenant_id INTO v_tenant_therapist FROM public.tenant_members WHERE id = NEW.therapist_member_id;
|
||||
IF v_tenant_therapist IS NULL THEN RAISE EXCEPTION 'Therapist member not found'; END IF;
|
||||
IF v_tenant_therapist <> v_tid THEN RAISE EXCEPTION 'Therapist member must belong to the same tenant'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 2) sync_busy_mirror — CROSS-TENANT: evento pessoal espelha "Ocupado" nas
|
||||
-- clínicas onde o owner é therapist. Escreve no schema de OUTROS tenants.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.sync_busy_mirror_agenda_eventos()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_source_tenant uuid;
|
||||
is_personal boolean;
|
||||
should_mirror boolean;
|
||||
v_owner uuid;
|
||||
v_src_id uuid;
|
||||
clinic record;
|
||||
v_cschema text;
|
||||
BEGIN
|
||||
-- anti-recursão: espelho não espelha
|
||||
IF TG_OP <> 'DELETE' THEN
|
||||
IF NEW.mirror_of_event_id IS NOT NULL THEN RETURN NEW; END IF;
|
||||
v_owner := NEW.owner_id; v_src_id := NEW.id;
|
||||
ELSE
|
||||
IF OLD.mirror_of_event_id IS NOT NULL THEN RETURN OLD; END IF;
|
||||
v_owner := OLD.owner_id; v_src_id := OLD.id;
|
||||
END IF;
|
||||
|
||||
v_source_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
is_personal := (v_source_tenant = v_owner); -- convenção: tenant pessoal tem id = owner
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
should_mirror := (OLD.visibility_scope IN ('busy_only','private'));
|
||||
ELSE
|
||||
should_mirror := (NEW.visibility_scope IN ('busy_only','private'));
|
||||
END IF;
|
||||
|
||||
IF NOT is_personal THEN
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- DELETE ou não-deve-espelhar: remove espelhos em todas as clínicas do owner
|
||||
IF TG_OP = 'DELETE' OR NOT should_mirror THEN
|
||||
FOR clinic IN
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
|
||||
LOOP
|
||||
v_cschema := public.tenant_schema_for(clinic.tenant_id);
|
||||
IF v_cschema IS NULL THEN CONTINUE; END IF;
|
||||
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
|
||||
v_cschema, v_src_id, 'personal_busy_mirror');
|
||||
END LOOP;
|
||||
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- INSERT/UPDATE com espelho: upsert "Ocupado" em cada clínica do owner
|
||||
FOR clinic IN
|
||||
SELECT tm.tenant_id FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_owner AND tm.role = 'therapist' AND tm.status = 'active' AND tm.tenant_id <> v_owner
|
||||
LOOP
|
||||
v_cschema := public.tenant_schema_for(clinic.tenant_id);
|
||||
IF v_cschema IS NULL THEN CONTINUE; END IF;
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.agenda_eventos (owner_id, terapeuta_id, patient_id, tipo, status, titulo, observacoes, inicio_em, fim_em, mirror_of_event_id, mirror_source, visibility_scope, created_at, updated_at) '
|
||||
|| 'VALUES ($1,$1,NULL,$2::public.tipo_evento_agenda,$3::public.status_evento_agenda,$4,NULL,$5,$6,$7,$8,$9,now(),now()) '
|
||||
|| 'ON CONFLICT (mirror_of_event_id) WHERE mirror_of_event_id IS NOT NULL '
|
||||
|| 'DO UPDATE SET owner_id=EXCLUDED.owner_id, terapeuta_id=EXCLUDED.terapeuta_id, tipo=EXCLUDED.tipo, status=EXCLUDED.status, titulo=EXCLUDED.titulo, observacoes=EXCLUDED.observacoes, inicio_em=EXCLUDED.inicio_em, fim_em=EXCLUDED.fim_em, updated_at=now()',
|
||||
v_cschema)
|
||||
USING v_owner, 'bloqueio', 'agendado', 'Ocupado', NEW.inicio_em, NEW.fim_em, v_src_id, 'personal_busy_mirror', 'public';
|
||||
END LOOP;
|
||||
|
||||
-- remove espelhos de clínicas onde o vínculo therapist active sumiu
|
||||
FOR clinic IN
|
||||
SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_owner AND tm.role='therapist' AND tm.status='active' AND tm.tenant_id = ts.tenant_id
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('DELETE FROM %I.agenda_eventos WHERE mirror_of_event_id = %L AND mirror_source = %L',
|
||||
clinic.schema_name, v_src_id, 'personal_busy_mirror');
|
||||
END LOOP;
|
||||
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 3) financial_records_inject_tenant — OBSOLETO no schema (sem coluna tenant_id).
|
||||
-- Mantém em public (legacy) mas NÃO anexa nos schemas.
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 4) Detach dos tenant-tables de public + attach nos schemas
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Detacha das tabelas tenant em public os triggers schema-aware (ficariam errados lá)
|
||||
DO $$
|
||||
DECLARE
|
||||
aware text[] := ARRAY[
|
||||
'log_audit_change','trg_fn_patient_status_history','trg_fn_patient_status_timeline',
|
||||
'trg_fn_patient_risco_timeline','auto_create_financial_record_from_session',
|
||||
'fn_sla_resolve_on_outbound','fn_clinical_note_version','fn_document_signature_timeline',
|
||||
'fn_documents_timeline_insert','sync_legacy_email_fields','sync_legacy_phone_fields',
|
||||
'fn_agenda_regras_semanais_no_overlap','patients_validate_member_consistency',
|
||||
'sync_busy_mirror_agenda_eventos'
|
||||
];
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, t.tgname
|
||||
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
||||
JOIN pg_proc p ON p.oid=t.tgfoid
|
||||
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
|
||||
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Attach nos schemas. Specs derivadas dos triggerdefs REAIS de public, com
|
||||
-- tenant_id removido de WHEN/UPDATE OF (não existe no schema). __T__ = schema.tabela.
|
||||
CREATE OR REPLACE FUNCTION public.attach_schema_aware_triggers(p_schema text)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
specs jsonb := jsonb_build_array(
|
||||
jsonb_build_object('tab','patients','name','trg_patient_status_history','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history()'),
|
||||
jsonb_build_object('tab','patients','name','trg_patient_status_timeline','spec','AFTER INSERT OR UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline()'),
|
||||
jsonb_build_object('tab','patients','name','trg_patient_risco_timeline','spec','AFTER UPDATE OF risco_elevado ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline()'),
|
||||
jsonb_build_object('tab','patients','name','trg_audit_patients','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','patients','name','trg_patients_validate_members','spec','BEFORE INSERT OR UPDATE OF responsible_member_id, patient_scope, therapist_member_id ON __T__ FOR EACH ROW EXECUTE FUNCTION public.patients_validate_member_consistency()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_audit_agenda_eventos','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_auto_financial_from_session','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_ins','spec','AFTER INSERT ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND new.visibility_scope = ANY (ARRAY[''busy_only''::text, ''private''::text])) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_upd','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.mirror_of_event_id IS NULL AND (new.visibility_scope IS DISTINCT FROM old.visibility_scope OR new.inicio_em IS DISTINCT FROM old.inicio_em OR new.fim_em IS DISTINCT FROM old.fim_em OR new.owner_id IS DISTINCT FROM old.owner_id)) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_eventos_busy_mirror_del','spec','AFTER DELETE ON __T__ FOR EACH ROW WHEN (old.mirror_of_event_id IS NULL) EXECUTE FUNCTION public.sync_busy_mirror_agenda_eventos()'),
|
||||
jsonb_build_object('tab','financial_records','name','trg_audit_financial_records','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','financial_records','name','trg_financial_records_auto_overdue','spec','BEFORE UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue()'),
|
||||
jsonb_build_object('tab','documents','name','trg_audit_documents','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.log_audit_change()'),
|
||||
jsonb_build_object('tab','documents','name','trg_documents_timeline_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert()'),
|
||||
jsonb_build_object('tab','document_signatures','name','trg_ds_timeline','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_document_signature_timeline()'),
|
||||
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_insert','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_clinical_note_version()'),
|
||||
jsonb_build_object('tab','clinical_notes','name','trg_clinical_notes_version_update','spec','AFTER UPDATE OF content_text, content_structured, title, deleted_at ON __T__ FOR EACH ROW WHEN (old.content_text IS DISTINCT FROM new.content_text OR old.content_structured IS DISTINCT FROM new.content_structured OR old.title IS DISTINCT FROM new.title OR old.deleted_at IS DISTINCT FROM new.deleted_at) EXECUTE FUNCTION public.fn_clinical_note_version()'),
|
||||
jsonb_build_object('tab','conversation_messages','name','trg_sla_resolve_on_outbound','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound()'),
|
||||
jsonb_build_object('tab','contact_emails','name','trg_contact_emails_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields()'),
|
||||
jsonb_build_object('tab','contact_phones','name','trg_contact_phones_sync_legacy','spec','AFTER INSERT OR DELETE OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields()'),
|
||||
jsonb_build_object('tab','agenda_regras_semanais','name','trg_agenda_regras_semanais_no_overlap','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap()'),
|
||||
jsonb_build_object('tab','agenda_configuracoes','name','trg_agenda_cfg_sync','spec','BEFORE INSERT OR UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.agenda_cfg_sync()')
|
||||
);
|
||||
el jsonb; v_count int := 0; v_target text;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
|
||||
v_target := format('%I.%I', p_schema, el->>'tab');
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
|
||||
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec', '__T__', v_target);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE r record; v int;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||
v := public.attach_schema_aware_triggers(r.schema_name);
|
||||
RAISE NOTICE 'F6.2B %: % triggers schema-aware', r.schema_name, v;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,266 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote C — split de notifications (tenant-local vs SaaS cross-tenant)
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (CREATE OR REPLACE de funções owned por
|
||||
-- postgres E supabase_admin; superuser preserva o owner):
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_2c_notifications_split.supabase_admin.sql
|
||||
--
|
||||
-- Neste projeto, TODAS as notificações atuais (inbound_message, session_status,
|
||||
-- system_alert, new_patient) são tenant-LOCAIS (avisos cross-tenant do SaaS
|
||||
-- vivem em global_notices). Então:
|
||||
-- * notifications continua tenant-local → já vive no schema do tenant (F6.1)
|
||||
-- * public.notifications_sistema é criado como o canal SaaS→tenant / dev
|
||||
-- cross-tenant (vazio hoje; pronto pro futuro: suporte, billing, etc.)
|
||||
-- Triggers de notif reescritos schema-aware; os que disparam em tabelas PUBLIC
|
||||
-- (notify_on_intake, notify_on_scheduling) roteiam pro schema via EXECUTE format.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 1) notifications_sistema (GLOBAL, cross-tenant)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.notifications_sistema (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_id uuid NOT NULL, -- destinatário (user do tenant OU dev)
|
||||
tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE, -- contexto (nullable: alerta global)
|
||||
type text NOT NULL,
|
||||
ref_id uuid,
|
||||
ref_table text,
|
||||
payload jsonb,
|
||||
read_at timestamptz,
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS notif_sistema_owner_idx ON public.notifications_sistema (owner_id, created_at DESC) WHERE archived = false;
|
||||
|
||||
ALTER TABLE public.notifications_sistema ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS notif_sistema_owner ON public.notifications_sistema;
|
||||
CREATE POLICY notif_sistema_owner ON public.notifications_sistema
|
||||
FOR ALL TO authenticated USING (owner_id = auth.uid()) WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- realtime
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_publication_tables WHERE pubname='supabase_realtime' AND schemaname='public' AND tablename='notifications_sistema') THEN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications_sistema;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- helper pro futuro: emite notificação cross-tenant (dev/SaaS -> destinatário)
|
||||
CREATE OR REPLACE FUNCTION public.notify_user_sistema(
|
||||
p_owner_id uuid, p_type text, p_payload jsonb,
|
||||
p_tenant_id uuid DEFAULT NULL, p_ref_id uuid DEFAULT NULL, p_ref_table text DEFAULT NULL)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.notifications_sistema (owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||
VALUES (p_owner_id, p_tenant_id, p_type, p_ref_id, p_ref_table, p_payload)
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 2) Rewrites dos triggers de notif (tenant-local) — schema-aware
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_on_session_status()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_nome text;
|
||||
BEGIN
|
||||
IF NEW.status IN ('faltou','cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
SELECT nome_completo INTO v_nome FROM patients WHERE id = NEW.patient_id LIMIT 1;
|
||||
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||
VALUES (NEW.owner_id, 'session_status', NEW.id, 'agenda_eventos',
|
||||
jsonb_build_object(
|
||||
'title', CASE WHEN NEW.status='faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
|
||||
'detail', COALESCE(v_nome,'Paciente') || ' — ' || to_char(NEW.inicio_em,'DD/MM HH24:MI'),
|
||||
'deeplink', '/therapist/agenda',
|
||||
'avatar_initials', upper(left(COALESCE(v_nome,'?'),2))));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '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; v_tenant uuid;
|
||||
BEGIN
|
||||
IF NEW.direction <> 'inbound' THEN RETURN NEW; END IF;
|
||||
v_tenant := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
|
||||
IF NEW.patient_id IS NOT NULL THEN
|
||||
SELECT nome_completo INTO v_patient_name FROM patients WHERE id = NEW.patient_id;
|
||||
END IF;
|
||||
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
|
||||
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
|
||||
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;
|
||||
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);
|
||||
|
||||
-- destinatário: responsável do paciente (tenant_members é GLOBAL)
|
||||
IF NEW.patient_id IS NOT NULL THEN
|
||||
SELECT tm.user_id INTO v_target_user
|
||||
FROM 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 notifications (owner_id, type, ref_id, ref_table, payload)
|
||||
VALUES (v_target_user, 'inbound_message', NULL, 'conversation_messages', v_payload);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
-- fallback: fan-out pros admins/therapists ativos do tenant (global)
|
||||
INSERT INTO notifications (owner_id, type, ref_id, ref_table, payload)
|
||||
SELECT tm.user_id, 'inbound_message', NULL, 'conversation_messages', v_payload
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.tenant_id = v_tenant AND tm.status = 'active'
|
||||
AND tm.role IN ('clinic_admin','tenant_admin','therapist');
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- helper de cancelamento: notification_queue é tenant; herda search_path do trigger chamador
|
||||
CREATE OR REPLACE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL, p_evento_id uuid DEFAULT NULL)
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE v_canceled integer;
|
||||
BEGIN
|
||||
UPDATE notification_queue SET status='cancelado', updated_at=now()
|
||||
WHERE patient_id = p_patient_id AND status IN ('pendente','processando')
|
||||
AND (p_channel IS NULL OR channel = p_channel)
|
||||
AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);
|
||||
GET DIAGNOSTICS v_canceled = ROW_COUNT;
|
||||
RETURN v_canceled;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_opt_out()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'whatsapp');
|
||||
END IF;
|
||||
IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'email');
|
||||
END IF;
|
||||
IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, 'sms');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||
PERFORM set_config('search_path', TG_TABLE_SCHEMA || ',public,pg_temp', true);
|
||||
PERFORM public.cancel_patient_pending_notifications(NEW.patient_id, NULL, NEW.id);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 3) Triggers que disparam em tabelas PUBLIC (intake/scheduling, F1b) —
|
||||
-- roteiam a notificação pro schema do tenant via EXECUTE format
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_on_intake()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF NEW.status = 'new' THEN
|
||||
v_schema := public.tenant_schema_for(NEW.tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN NEW; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
|
||||
USING NEW.owner_id, 'new_patient', NEW.id, 'patient_intake_requests',
|
||||
jsonb_build_object('title','Novo cadastro externo','detail',COALESCE(NEW.nome_completo,'Paciente'),
|
||||
'deeplink','/therapist/patients/cadastro/recebidos','avatar_initials',upper(left(COALESCE(NEW.nome_completo,'?'),2)));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF NEW.status = 'pendente' THEN
|
||||
v_schema := public.tenant_schema_for(NEW.tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN NEW; END IF;
|
||||
EXECUTE format('INSERT INTO %I.notifications (owner_id, type, ref_id, ref_table, payload) VALUES ($1,$2,$3,$4,$5)', v_schema)
|
||||
USING NEW.owner_id, 'new_scheduling', NEW.id, 'agendador_solicitacoes',
|
||||
jsonb_build_object('title','Nova solicitação de agendamento',
|
||||
'detail', COALESCE(NEW.paciente_nome,'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome,'') || ' — ' || COALESCE(NEW.tipo,''),
|
||||
'deeplink','/therapist/agendamentos-recebidos',
|
||||
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome,'?'),1) || left(COALESCE(NEW.paciente_sobrenome,''),1)));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- 4) Detach dos notif-triggers tenant de public + attach nos schemas (estende
|
||||
-- attach_schema_aware_triggers com os 5 triggers de notif tenant)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE
|
||||
aware text[] := ARRAY['notify_on_session_status','fanout_inbound_message_to_notifications',
|
||||
'cancel_notifications_on_opt_out','cancel_notifications_on_session_cancel'];
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, t.tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid=c.relnamespace JOIN pg_proc p ON p.oid=t.tgfoid
|
||||
WHERE n.nspname='public' AND NOT t.tgisinternal AND p.proname = ANY(aware)
|
||||
AND c.relname IN (SELECT table_name FROM information_schema.tables WHERE table_schema='_tenant_template' AND table_type='BASE TABLE')
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', r.tgname, r.tab);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attach_notif_triggers(p_schema text)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
specs jsonb := jsonb_build_array(
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_notify_on_session_status','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_cancel_notifs_on_session_cancel','spec','AFTER UPDATE ON __T__ FOR EACH ROW WHEN (new.status IS DISTINCT FROM old.status) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel()'),
|
||||
jsonb_build_object('tab','agenda_eventos','name','trg_agenda_status_notify','spec','AFTER UPDATE OF status ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change()'),
|
||||
jsonb_build_object('tab','conversation_messages','name','trg_fanout_inbound_to_notifications','spec','AFTER INSERT ON __T__ FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications()'),
|
||||
jsonb_build_object('tab','notification_preferences','name','trg_cancel_notifs_on_opt_out','spec','AFTER UPDATE ON __T__ FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out()')
|
||||
);
|
||||
el jsonb; v_count int := 0; v_target text;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||
FOR el IN SELECT * FROM jsonb_array_elements(specs) LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=p_schema AND table_name=(el->>'tab')) THEN CONTINUE; END IF;
|
||||
v_target := format('%I.%I', p_schema, el->>'tab');
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', el->>'name', v_target);
|
||||
EXECUTE 'CREATE TRIGGER ' || quote_ident(el->>'name') || ' ' || replace(el->>'spec','__T__',v_target);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE r record; v int;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||
v := public.attach_notif_triggers(r.schema_name);
|
||||
RAISE NOTICE 'F6.2C %: % notif triggers', r.schema_name, v;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,412 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote D — RPCs user-facing roteadas pro schema do tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (mix de funções owned postgres/supabase_admin).
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_2d_user_rpcs.supabase_admin.sql
|
||||
--
|
||||
-- Padrão: valida is_tenant_member(p_tenant_id) + set_config search_path pro
|
||||
-- schema do tenant; remove `WHERE tenant_id=` e tenant_id de inserts; unqualify
|
||||
-- tabelas tenant; %ROWTYPE→RECORD; RETURNS <tabela_tenant>→jsonb.
|
||||
-- Tabelas que FICAM em public (audit_logs global; patient_intake_requests,
|
||||
-- document_share_links F1b) seguem com `public.` + filtro tenant_id.
|
||||
--
|
||||
-- list_my_signatures é CROSS-TENANT (assinante em vários tenants) → Lote F.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- helper: valida acesso e RETORNA o schema do tenant. NÃO seta search_path
|
||||
-- (set_config feito dentro de helper com SET search_path próprio seria revertido
|
||||
-- na saída do helper). Cada RPC faz: PERFORM set_config('search_path',
|
||||
-- public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
CREATE OR REPLACE FUNCTION public._tenant_route(p_tenant_id uuid)
|
||||
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
|
||||
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'Sem permissão no tenant %', p_tenant_id USING ERRCODE='42501';
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
|
||||
RETURN v_schema;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- GRUPO 1 — já têm p_tenant_id, RETURNS jsonb/void (CREATE OR REPLACE)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
|
||||
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found'; END IF;
|
||||
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
|
||||
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
|
||||
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
|
||||
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
|
||||
IF v_parent <> 1 THEN RAISE EXCEPTION 'Parent not deleted'; END IF;
|
||||
RETURN jsonb_build_object('ok',true,'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_is_native boolean; v_fields int:=0; v_logs int:=0; v_parent int:=0;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT dc.is_native INTO v_is_native FROM determined_commitments dc WHERE dc.id = p_commitment_id;
|
||||
IF v_is_native IS NULL THEN RAISE EXCEPTION 'Commitment not found for tenant'; END IF;
|
||||
IF v_is_native = true THEN RAISE EXCEPTION 'Cannot delete native commitment'; END IF;
|
||||
DELETE FROM determined_commitment_fields WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_fields = ROW_COUNT;
|
||||
DELETE FROM commitment_time_logs WHERE commitment_id = p_commitment_id; GET DIAGNOSTICS v_logs = ROW_COUNT;
|
||||
DELETE FROM determined_commitments WHERE id = p_commitment_id; GET DIAGNOSTICS v_parent = ROW_COUNT;
|
||||
IF v_parent <> 1 THEN RAISE EXCEPTION 'Delete did not remove the commitment'; END IF;
|
||||
RETURN jsonb_build_object('ok',true,'tenant_id',p_tenant_id,'commitment_id',p_commitment_id,
|
||||
'deleted',jsonb_build_object('fields',v_fields,'logs',v_logs,'commitment',v_parent));
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_owner_id uuid; v_schema text;
|
||||
BEGIN
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF; -- schema ainda não existe (chamado antes do clone): no-op
|
||||
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;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
INSERT INTO patient_groups (owner_id, nome, cor, is_system)
|
||||
VALUES (v_owner_id,'Crianças','#60a5fa',true),
|
||||
(v_owner_id,'Adolescentes','#a78bfa',true),
|
||||
(v_owner_id,'Idosos','#34d399',true)
|
||||
ON CONFLICT (owner_id, nome) DO NOTHING;
|
||||
END $$;
|
||||
|
||||
-- seed_determined_commitments: idêntico em estrutura, sem tenant_id nos inserts.
|
||||
-- Recriado integralmente.
|
||||
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_id uuid; v_schema text;
|
||||
BEGIN
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='session') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'session',true,true,'Sessão','Sessão com paciente');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='reading') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'reading',false,true,'Leitura','Praticar leitura');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='supervision') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'supervision',false,true,'Supervisão','Supervisão');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='class') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'class',false,false,'Aula','Dar aula');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitments WHERE is_native=true AND native_key='analysis') THEN
|
||||
INSERT INTO determined_commitments (is_native, native_key, is_locked, active, name, description)
|
||||
VALUES (true,'analysis',false,true,'Análise Pessoal','Minha análise pessoal');
|
||||
END IF;
|
||||
|
||||
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='session' LIMIT 1;
|
||||
IF v_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order)
|
||||
VALUES (v_id,'notes','Observação','textarea',false,30);
|
||||
END IF;
|
||||
SELECT id INTO v_id FROM determined_commitments WHERE is_native=true AND native_key='reading' LIMIT 1;
|
||||
IF v_id IS NOT NULL THEN
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='book') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'book','Livro','text',false,10);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='author') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'author','Autor','text',false,20);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM determined_commitment_fields WHERE commitment_id=v_id AND key='notes') THEN
|
||||
INSERT INTO determined_commitment_fields (commitment_id, key, label, field_type, required, sort_order) VALUES (v_id,'notes','Observação','textarea',false,30);
|
||||
END IF;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- GRUPO 2 — novo p_tenant_id (1º param), RETURNS scalar/jsonb (DROP+CREATE)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.cancel_recurrence_from(uuid, date);
|
||||
CREATE FUNCTION public.cancel_recurrence_from(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE recurrence_rules
|
||||
SET end_date = p_from_date - INTERVAL '1 day', open_ended = false,
|
||||
status = CASE WHEN p_from_date <= start_date THEN 'cancelado' ELSE status END,
|
||||
updated_at = now()
|
||||
WHERE id = p_recurrence_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.cancelar_eventos_serie(uuid, timestamptz);
|
||||
CREATE FUNCTION public.cancelar_eventos_serie(p_tenant_id uuid, p_serie_id uuid, p_a_partir_de timestamptz DEFAULT now())
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_count integer;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE agenda_eventos SET status='cancelado', updated_at=now()
|
||||
WHERE serie_id = p_serie_id AND inicio_em >= p_a_partir_de AND status NOT IN ('realizado','cancelado');
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.split_recurrence_at(uuid, date);
|
||||
CREATE FUNCTION public.split_recurrence_at(p_tenant_id uuid, p_recurrence_id uuid, p_from_date date)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_old RECORD; v_new_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_old FROM recurrence_rules WHERE id = p_recurrence_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'recurrence_rule % não encontrada', p_recurrence_id; END IF;
|
||||
UPDATE recurrence_rules SET end_date = p_from_date - INTERVAL '1 day', open_ended=false, updated_at=now()
|
||||
WHERE id = p_recurrence_id;
|
||||
INSERT INTO recurrence_rules (owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
|
||||
start_time, end_time, timezone, duration_min, start_date, end_date, max_occurrences, open_ended,
|
||||
modalidade, titulo_custom, observacoes, extra_fields, status)
|
||||
SELECT owner_id, therapist_id, patient_id, determined_commitment_id, type, interval, weekdays,
|
||||
start_time, end_time, timezone, duration_min, p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
|
||||
modalidade, titulo_custom, observacoes, extra_fields, status
|
||||
FROM recurrence_rules WHERE id = p_recurrence_id
|
||||
RETURNING id INTO v_new_id;
|
||||
RETURN v_new_id;
|
||||
END $$;
|
||||
|
||||
-- can_delete_patient: SQL sem SET search_path → herda o do chamador (schema).
|
||||
-- Unqualified pra resolver no schema do tenant ativo.
|
||||
CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1 FROM agenda_eventos WHERE patient_id = p_patient_id
|
||||
UNION ALL
|
||||
SELECT 1 FROM recurrence_rules WHERE patient_id = p_patient_id
|
||||
UNION ALL
|
||||
SELECT 1 FROM billing_contracts WHERE patient_id = p_patient_id
|
||||
);
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.safe_delete_patient(uuid);
|
||||
CREATE FUNCTION public.safe_delete_patient(p_tenant_id uuid, p_patient_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
IF NOT public.can_delete_patient(p_patient_id) THEN
|
||||
RETURN jsonb_build_object('ok',false,'error','has_history',
|
||||
'message','Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.');
|
||||
END IF;
|
||||
-- ownership: owner_id direto ou responsible_member do caller (tenant_members é GLOBAL)
|
||||
IF NOT EXISTS (SELECT 1 FROM patients
|
||||
WHERE id = p_patient_id AND (owner_id = auth.uid()
|
||||
OR responsible_member_id IN (SELECT id FROM public.tenant_members WHERE user_id = auth.uid()))) THEN
|
||||
RETURN jsonb_build_object('ok',false,'error','forbidden','message','Sem permissão para excluir este paciente.');
|
||||
END IF;
|
||||
DELETE FROM patients WHERE id = p_patient_id;
|
||||
RETURN jsonb_build_object('ok',true);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.export_patient_data(uuid);
|
||||
CREATE FUNCTION public.export_patient_data(p_tenant_id uuid, p_patient_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_patient RECORD; v_caller uuid; v_result jsonb;
|
||||
BEGIN
|
||||
v_caller := auth.uid();
|
||||
IF v_caller IS NULL THEN RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE='28000'; END IF;
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_patient FROM patients WHERE id = p_patient_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE='P0002'; END IF;
|
||||
v_result := jsonb_build_object(
|
||||
'export_metadata', jsonb_build_object('generated_at', now(), 'generated_by', v_caller,
|
||||
'tenant_id', p_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 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 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 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 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 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,'status',ae.status,'inicio_em',ae.inicio_em,'fim_em',ae.fim_em) ORDER BY ae.inicio_em) FROM agenda_eventos ae WHERE ae.patient_id = p_patient_id),'[]'::jsonb),
|
||||
'documentos', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',d.id,'nome',d.nome_original,'tipo',d.tipo_documento,'criado_em',d.created_at) ORDER BY d.created_at) FROM documents d WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL),'[]'::jsonb),
|
||||
'financeiro', COALESCE((SELECT jsonb_agg(jsonb_build_object('id',fr.id,'tipo',fr.type,'valor',fr.final_amount,'status',fr.status,'vencimento',fr.due_date) ORDER BY fr.created_at) FROM financial_records fr WHERE fr.patient_id = p_patient_id AND fr.deleted_at IS NULL),'[]'::jsonb)
|
||||
);
|
||||
RETURN v_result;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.search_global(text, text[], integer);
|
||||
CREATE FUNCTION public.search_global(p_tenant_id uuid, p_q text, p_scope text[] DEFAULT NULL, p_limit integer DEFAULT 8)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '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
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
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));
|
||||
|
||||
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
|
||||
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 (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 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) ranked;
|
||||
END IF;
|
||||
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
|
||||
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 (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 agenda_eventos e LEFT JOIN 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) ranked;
|
||||
END IF;
|
||||
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
|
||||
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 (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 documents d LEFT JOIN 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) ranked;
|
||||
END IF;
|
||||
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
|
||||
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 (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 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) ranked;
|
||||
END IF;
|
||||
-- intakes: patient_intake_requests FICA em public (F1b) → qualifica + filtra tenant_id
|
||||
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
|
||||
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 (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.tenant_id = p_tenant_id AND 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) ranked;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('patients',v_patients,'appointments',v_appointments,'documents',v_documents,'services',v_services,'intakes',v_intakes);
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- GRUPO 3 — RETURNS <tabela_tenant> → jsonb (ripple no FE)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.mark_as_paid(uuid, text);
|
||||
CREATE FUNCTION public.mark_as_paid(p_tenant_id uuid, p_financial_record_id uuid, p_payment_method text)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_record RECORD;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_record FROM financial_records WHERE id = p_financial_record_id AND owner_id = auth.uid() AND deleted_at IS NULL;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Registro financeiro não encontrado ou sem permissão.'; END IF;
|
||||
IF v_record.status NOT IN ('pending','overdue') THEN RAISE EXCEPTION 'Apenas cobranças pendentes ou vencidas podem ser marcadas como pagas.'; END IF;
|
||||
UPDATE financial_records SET status='paid', paid_at=now(), payment_method=p_payment_method, updated_at=now()
|
||||
WHERE id = p_financial_record_id RETURNING * INTO v_record;
|
||||
RETURN to_jsonb(v_record);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_financial_record_for_session(uuid, uuid, uuid, uuid, numeric, date);
|
||||
CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_existing RECORD; v_new RECORD;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_existing FROM financial_records WHERE agenda_evento_id = p_agenda_evento_id AND deleted_at IS NULL AND status != 'cancelled' LIMIT 1;
|
||||
IF FOUND THEN RETURN to_jsonb(v_existing); END IF;
|
||||
INSERT INTO financial_records (owner_id, patient_id, agenda_evento_id, amount, discount_amount, final_amount, status, due_date)
|
||||
VALUES (p_owner_id, p_patient_id, p_agenda_evento_id, p_amount, 0, p_amount, 'pending', p_due_date)
|
||||
RETURNING * INTO v_new;
|
||||
UPDATE agenda_eventos SET billed = TRUE WHERE id = p_agenda_evento_id;
|
||||
RETURN to_jsonb(v_new);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.mark_payout_as_paid(uuid);
|
||||
CREATE FUNCTION public.mark_payout_as_paid(p_tenant_id uuid, p_payout_id uuid)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_payout RECORD;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT * INTO v_payout FROM therapist_payouts WHERE id = p_payout_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'Repasse não encontrado: %', p_payout_id; END IF;
|
||||
IF NOT public.is_tenant_admin(p_tenant_id) THEN RAISE EXCEPTION 'Apenas o administrador da clínica pode marcar repasses como pagos.'; END IF;
|
||||
IF v_payout.status <> 'pending' THEN RAISE EXCEPTION 'Repasse já está com status ''%''. Apenas repasses pendentes podem ser pagos.', v_payout.status; END IF;
|
||||
UPDATE therapist_payouts SET status='paid', paid_at=now(), updated_at=now() WHERE id = p_payout_id RETURNING * INTO v_payout;
|
||||
RETURN to_jsonb(v_payout);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.create_therapist_payout(uuid, uuid, date, date);
|
||||
CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_payout RECORD; v_total int; v_gross numeric(10,2); v_clinic_fee numeric(10,2); v_net numeric(10,2);
|
||||
BEGIN
|
||||
IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
|
||||
RAISE EXCEPTION 'Sem permissão para criar repasse para este terapeuta.';
|
||||
END IF;
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
IF EXISTS (SELECT 1 FROM therapist_payouts WHERE owner_id=p_therapist_id AND period_start=p_period_start AND period_end=p_period_end AND status<>'cancelled') THEN
|
||||
RAISE EXCEPTION 'Já existe um repasse ativo para o período % a % deste terapeuta.', p_period_start, p_period_end;
|
||||
END IF;
|
||||
SELECT COUNT(*), COALESCE(SUM(amount),0), COALESCE(SUM(clinic_fee_amount),0), COALESCE(SUM(net_amount),0)
|
||||
INTO v_total, v_gross, v_clinic_fee, v_net
|
||||
FROM financial_records fr
|
||||
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
|
||||
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
|
||||
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
|
||||
IF v_total = 0 THEN RAISE EXCEPTION 'Nenhum registro financeiro elegível encontrado para o período % a %.', p_period_start, p_period_end; END IF;
|
||||
INSERT INTO therapist_payouts (owner_id, period_start, period_end, total_sessions, gross_amount, clinic_fee_total, net_amount, status)
|
||||
VALUES (p_therapist_id, p_period_start, p_period_end, v_total, v_gross, v_clinic_fee, v_net, 'pending')
|
||||
RETURNING * INTO v_payout;
|
||||
INSERT INTO therapist_payout_records (payout_id, financial_record_id)
|
||||
SELECT v_payout.id, fr.id FROM financial_records fr
|
||||
WHERE fr.owner_id=p_therapist_id AND fr.type='receita' AND fr.status='paid' AND fr.deleted_at IS NULL
|
||||
AND fr.paid_at::date BETWEEN p_period_start AND p_period_end
|
||||
AND NOT EXISTS (SELECT 1 FROM therapist_payout_records tpr WHERE tpr.financial_record_id = fr.id);
|
||||
RETURN to_jsonb(v_payout);
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,308 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote E — RPCs de cron/global roteadas/loopadas por tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_2e_cron_rpcs.supabase_admin.sql
|
||||
--
|
||||
-- E1: chamadas per-tenant pelas edge crons → p_tenant_id + set_config search_path
|
||||
-- (helper public._tenant_route do Lote D). Edge ajustada (admin.rpc + p_tenant_id).
|
||||
-- E2: crons sem-arg que varrem TODOS os tenants → loop FROM tenant_schemas.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- helper SEM checagem de auth: resolve schema pra RPCs de SERVIÇO (chamadas por
|
||||
-- service_role/edge, que não é tenant_member). Protegido por REVOKE das RPCs de
|
||||
-- anon/authenticated (só service_role/postgres chamam).
|
||||
CREATE OR REPLACE FUNCTION public._tenant_schema_unchecked(p_tenant_id uuid)
|
||||
RETURNS text LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN RAISE EXCEPTION 'p_tenant_id obrigatório'; END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema não encontrado p/ tenant %', p_tenant_id; END IF;
|
||||
RETURN v_schema;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- E2 — crons globais: varrem todos os schemas
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_notification_queue()
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int; v_total int := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('DELETE FROM %I.notification_queue WHERE status IN (''enviado'',''cancelado'',''ignorado'') AND created_at < now() - interval ''90 days''', t.schema_name);
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||
END LOOP;
|
||||
RETURN v_total;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unstick_notification_queue()
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int; v_total int := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.notification_queue SET status=''pendente'', attempts=attempts+1, last_error=''Timeout: preso em processando por >10min'', next_retry_at=now()+interval ''2 minutes'' WHERE status=''processando'' AND updated_at < now() - interval ''10 minutes''', t.schema_name);
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||
END LOOP;
|
||||
RETURN v_total;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sync_overdue_financial_records()
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int; v_total int := 0;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.financial_records SET status=''overdue'', updated_at=now() WHERE status=''pending'' AND due_date IS NOT NULL AND due_date < CURRENT_DATE AND deleted_at IS NULL', t.schema_name);
|
||||
GET DIAGNOSTICS v_n = ROW_COUNT; v_total := v_total + v_n;
|
||||
END LOOP;
|
||||
RETURN v_total;
|
||||
END $$;
|
||||
|
||||
-- populate: complexo (multi-tabela). set_config search_path por tenant; profiles
|
||||
-- é GLOBAL → qualificado. Remove tenant_id do INSERT e do SELECT.
|
||||
CREATE OR REPLACE FUNCTION public.populate_notification_queue()
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record;
|
||||
BEGIN
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
PERFORM set_config('search_path', t.schema_name || ',public,pg_temp', true);
|
||||
INSERT INTO notification_queue (
|
||||
owner_id, agenda_evento_id, patient_id, channel, template_key, schedule_key,
|
||||
resolved_vars, recipient_address, scheduled_at, idempotency_key)
|
||||
SELECT
|
||||
ae.owner_id, ae.id, ae.patient_id, ch.channel,
|
||||
'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
|
||||
ns.schedule_key,
|
||||
jsonb_build_object('nome_paciente', COALESCE(p.nome_completo,'Paciente'),
|
||||
'data_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','DD/MM/YYYY'),
|
||||
'hora_sessao', TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo','HH24:MI'),
|
||||
'nome_terapeuta', COALESCE(prof.full_name,'Terapeuta'),
|
||||
'modalidade', COALESCE(ae.modalidade,'Presencial'),
|
||||
'titulo', COALESCE(ae.titulo,'Sessão')),
|
||||
CASE ch.channel WHEN 'whatsapp' THEN COALESCE(p.telefone,'') WHEN 'sms' THEN COALESCE(p.telefone,'') WHEN 'email' THEN COALESCE(p.email_principal,'') END,
|
||||
CASE
|
||||
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time < ns.allowed_time_start
|
||||
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
|
||||
WHEN (ae.inicio_em - (ns.offset_minutes||' minutes')::interval)::time > ns.allowed_time_end
|
||||
THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes||' minutes')::interval) + ns.allowed_time_start
|
||||
ELSE ae.inicio_em - (ns.offset_minutes||' minutes')::interval END,
|
||||
ae.id::text||':'||ns.schedule_key||':'||ch.channel||':'||ae.inicio_em::date::text
|
||||
FROM agenda_eventos ae
|
||||
JOIN patients p ON p.id = ae.patient_id
|
||||
LEFT JOIN public.profiles prof ON prof.id = ae.owner_id -- GLOBAL
|
||||
JOIN notification_schedules ns ON ns.owner_id = ae.owner_id AND ns.is_active=true AND ns.deleted_at IS NULL AND ns.trigger_type='before_event' AND ns.event_type='lembrete_sessao'
|
||||
JOIN notification_channels nc ON nc.owner_id = ae.owner_id AND nc.is_active=true AND nc.deleted_at IS NULL
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel='whatsapp'
|
||||
UNION ALL SELECT 'email' WHERE ns.email_enabled AND nc.channel='email'
|
||||
UNION ALL SELECT 'sms' WHERE ns.sms_enabled AND nc.channel='sms') ch
|
||||
LEFT JOIN notification_preferences np ON np.patient_id = ae.patient_id AND np.owner_id = ae.owner_id AND np.deleted_at IS NULL
|
||||
WHERE ae.tipo = 'sessao' AND ae.status NOT IN ('cancelado','realizado') AND ae.inicio_em > now()
|
||||
AND (ae.inicio_em - (ns.offset_minutes||' minutes')::interval) > now()
|
||||
ON CONFLICT (idempotency_key) DO NOTHING;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
-- E1 — chamadas per-tenant (p_tenant_id + route)
|
||||
-- ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamptz, p_threshold_minutes integer)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_existing_id uuid; v_new_id uuid;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN RAISE EXCEPTION 'tenant_and_thread_required'; END IF;
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT id INTO v_existing_id FROM conversation_sla_breaches WHERE thread_key = p_thread_key AND resolved_at IS NULL;
|
||||
IF FOUND THEN
|
||||
UPDATE conversation_sla_breaches SET assigned_to = COALESCE(p_assigned_to, assigned_to), last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at) WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
INSERT INTO conversation_sla_breaches (thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||
VALUES (p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes) RETURNING id INTO v_new_id;
|
||||
RETURN v_new_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid);
|
||||
DROP FUNCTION IF EXISTS public.sla_mark_notified(uuid,uuid);
|
||||
CREATE FUNCTION public.sla_mark_notified(p_tenant_id uuid, p_breach_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE conversation_sla_breaches SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_breach_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, text, text, jsonb);
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_open_incident(uuid, uuid, text, text, jsonb);
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_tenant_id uuid, p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL, p_details jsonb DEFAULT NULL)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_provider text; v_existing_id uuid; v_new_id uuid;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT provider INTO v_provider FROM notification_channels WHERE id = p_channel_id AND deleted_at IS NULL;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'channel_not_found'; END IF;
|
||||
IF p_kind NOT IN ('disconnected','error','qr_pending','connecting','unknown') THEN RAISE EXCEPTION 'invalid_kind: %', p_kind; END IF;
|
||||
SELECT id INTO v_existing_id FROM whatsapp_connection_incidents WHERE channel_id = p_channel_id AND resolved_at IS NULL;
|
||||
IF FOUND THEN
|
||||
UPDATE whatsapp_connection_incidents SET last_state = COALESCE(p_last_state, last_state), details = COALESCE(p_details, details), kind = p_kind WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
INSERT INTO whatsapp_connection_incidents (channel_id, provider, kind, last_state, details)
|
||||
VALUES (p_channel_id, v_provider, p_kind, p_last_state, p_details) RETURNING id INTO v_new_id;
|
||||
RETURN v_new_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid);
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_mark_notified(uuid,uuid);
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_tenant_id uuid, p_incident_id uuid)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE whatsapp_connection_incidents SET notified_at = now(), notification_count = notification_count + 1 WHERE id = p_incident_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid);
|
||||
DROP FUNCTION IF EXISTS public.whatsapp_heartbeat_resolve_open_incidents(uuid,uuid);
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_tenant_id uuid, p_channel_id uuid)
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_count int := 0;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
UPDATE whatsapp_connection_incidents SET resolved_at = now(), duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
|
||||
WHERE channel_id = p_channel_id AND resolved_at IS NULL;
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
-- convert_abandoned_intake_to_lead: resolve o tenant INTERNAMENTE (intake.owner_id
|
||||
-- -> tenant_members). patient_intake_requests FICA em public (F1b). Writes de
|
||||
-- conversation_messages/notes vão pro schema do tenant resolvido.
|
||||
CREATE OR REPLACE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_intake RECORD; v_tenant_id uuid; v_schema text; v_thread_key text; v_phone text;
|
||||
v_note_body text; v_admin_id uuid;
|
||||
BEGIN
|
||||
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::uuid; END IF;
|
||||
SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_intake.owner_id
|
||||
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END LIMIT 1;
|
||||
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'schema_not_found'; END IF;
|
||||
|
||||
v_phone := regexp_replace(COALESCE(v_intake.telefone,''),'\D','','g');
|
||||
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55'||v_phone; END IF;
|
||||
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||
v_thread_key := 'anon:'||v_phone;
|
||||
v_note_body := format('📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||
E'\n',E'\n', COALESCE(v_intake.nome_completo,'—'), E'\n', COALESCE(v_intake.telefone,'—'), E'\n',
|
||||
COALESCE(v_intake.email_principal,'—'), E'\n', COALESCE(v_intake.onde_nos_conheceu,'—'), E'\n',E'\n',
|
||||
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'),
|
||||
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo','DD/MM HH24:MI'));
|
||||
|
||||
SELECT user_id INTO v_admin_id FROM public.tenant_members
|
||||
WHERE tenant_id = v_tenant_id AND role IN ('tenant_admin','clinic_admin') AND status='active' LIMIT 1;
|
||||
IF v_admin_id IS NULL THEN v_admin_id := v_intake.owner_id; END IF;
|
||||
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
INSERT INTO conversation_messages (channel, direction, from_number, to_number, body, provider, provider_raw, kanban_status)
|
||||
VALUES ('whatsapp','inbound', CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, NULL,
|
||||
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.', COALESCE(v_intake.nome_completo,'Visitante')),
|
||||
'system', jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id), 'awaiting_us');
|
||||
INSERT INTO conversation_notes (thread_key, contact_number, body, created_by)
|
||||
VALUES (v_thread_key, CASE WHEN v_phone='unknown' THEN NULL ELSE v_phone END, v_note_body, v_admin_id);
|
||||
|
||||
UPDATE public.patient_intake_requests SET status='abandoned_lead', lead_thread_key=v_thread_key, updated_at=now() WHERE id = p_intake_id;
|
||||
RETURN p_intake_id;
|
||||
END $$;
|
||||
|
||||
-- first_response analytics: routam pelo p_tenant_id (cada função seta o seu próprio
|
||||
-- search_path — _first_response_runs tem SET search_path próprio que resetaria).
|
||||
CREATE OR REPLACE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamptz, p_to timestamptz)
|
||||
RETURNS TABLE(thread_key text, inbound_started_at timestamptz, responded_at timestamptz, response_seconds integer, responder_id uuid)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
WITH base AS (
|
||||
SELECT COALESCE(m.patient_id::text, 'anon:' || COALESCE(CASE WHEN m.direction='inbound' THEN m.from_number ELSE m.to_number END, 'unknown')) AS thread_key,
|
||||
m.direction, m.created_at
|
||||
FROM conversation_messages m
|
||||
WHERE m.created_at >= p_from AND m.created_at < p_to
|
||||
),
|
||||
inbound AS (
|
||||
SELECT b.thread_key AS tk, min(b.created_at) AS inbound_started_at
|
||||
FROM base b WHERE b.direction='inbound' GROUP BY b.thread_key
|
||||
)
|
||||
SELECT i.tk, i.inbound_started_at,
|
||||
(SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) AS responded_at,
|
||||
EXTRACT(EPOCH FROM ((SELECT min(b2.created_at) FROM base b2 WHERE b2.thread_key = i.tk AND b2.direction='outbound' AND b2.created_at >= i.inbound_started_at) - i.inbound_started_at))::int AS response_seconds,
|
||||
a.assigned_to AS responder_id
|
||||
FROM inbound i
|
||||
LEFT JOIN conversation_assignments a ON a.thread_key = i.tk;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamptz DEFAULT (now() - interval '30 days'), p_to timestamptz DEFAULT now(), p_therapist_id uuid DEFAULT NULL)
|
||||
RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_threshold_min integer;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT threshold_minutes INTO v_threshold_min FROM conversation_sla_rules LIMIT 1;
|
||||
v_threshold_min := COALESCE(v_threshold_min, 30);
|
||||
RETURN QUERY
|
||||
WITH runs AS (
|
||||
SELECT r.response_seconds FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE r.responded_at IS NOT NULL AND (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||
)
|
||||
SELECT count(*)::int,
|
||||
COALESCE(avg(response_seconds),0)::int,
|
||||
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY response_seconds),0)::int,
|
||||
COALESCE(min(response_seconds),0)::int,
|
||||
COALESCE(max(response_seconds),0)::int,
|
||||
(v_threshold_min*60)::int,
|
||||
count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)::int,
|
||||
CASE WHEN count(*)=0 THEN 0 ELSE round(100.0*count(*) FILTER (WHERE response_seconds <= v_threshold_min*60)/count(*),1) END
|
||||
FROM runs;
|
||||
END $$;
|
||||
|
||||
-- RPCs de serviço (cron/edge): só service_role/postgres. Sem checagem de membership.
|
||||
DO $g$
|
||||
DECLARE fn text;
|
||||
BEGIN
|
||||
FOREACH fn IN ARRAY ARRAY[
|
||||
'sla_open_breach(uuid,text,uuid,timestamptz,integer)',
|
||||
'sla_mark_notified(uuid,uuid)',
|
||||
'whatsapp_heartbeat_open_incident(uuid,uuid,text,text,jsonb)',
|
||||
'whatsapp_heartbeat_mark_notified(uuid,uuid)',
|
||||
'whatsapp_heartbeat_resolve_open_incidents(uuid,uuid)',
|
||||
'convert_abandoned_intake_to_lead(uuid)',
|
||||
'cleanup_notification_queue()','unstick_notification_queue()',
|
||||
'sync_overdue_financial_records()','populate_notification_queue()'
|
||||
] LOOP
|
||||
EXECUTE format('REVOKE ALL ON FUNCTION public.%s FROM PUBLIC, anon, authenticated', fn);
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO service_role', fn);
|
||||
END LOOP;
|
||||
END $g$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,247 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote F — RPCs anon/token: resolvem tenant por token/slug e roteiam
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- Visitante anon não está logado → cada RPC resolve o tenant a partir do
|
||||
-- token/slug do registro que VIVE em public (F1b: document_share_links,
|
||||
-- agendador_configuracoes — ambos têm tenant_id), depois set_config search_path
|
||||
-- pro schema só pras tabelas tenant (documents, document_signatures,
|
||||
-- document_access_logs, patients, agenda_*, recurrence_*).
|
||||
-- Tabelas que ficam em public seguem qualificadas (document_share_links,
|
||||
-- agendador_configuracoes/solicitacoes).
|
||||
-- %ROWTYPE de tabelas tenant → RECORD; RETURNS document_signatures → jsonb.
|
||||
-- list_my_signatures é cross-tenant (assinante em vários tenants) → fan-out.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Documentos: tenant via document_share_links.tenant_id (public) ──────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.validate_share_token(p_token text)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE sl public.document_share_links%ROWTYPE; v_doc RECORD; v_token text; v_schema 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 public.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;
|
||||
v_schema := public.tenant_schema_for(sl.tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='28000'; END IF;
|
||||
|
||||
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = sl.id;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
BEGIN
|
||||
INSERT INTO document_access_logs (documento_id, action, share_link_id)
|
||||
VALUES (sl.documento_id, 'shared_link_access', sl.id);
|
||||
EXCEPTION WHEN OTHERS THEN NULL; END;
|
||||
SELECT * INTO v_doc FROM documents WHERE id = sl.documento_id;
|
||||
RETURN jsonb_build_object('document_id', sl.documento_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 $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(p_token text)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_link public.document_share_links%ROWTYPE; v_doc RECORD; v_sigs jsonb; v_schema text;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
|
||||
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
|
||||
IF v_link.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid'); END IF;
|
||||
v_schema := public.tenant_schema_for(v_link.tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'tenant_invalid'); END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
SELECT * INTO v_doc FROM documents WHERE id = v_link.documento_id AND deleted_at IS NULL LIMIT 1;
|
||||
IF v_doc.id IS NULL THEN RETURN jsonb_build_object('valid', false, 'error', 'document_not_found'); END IF;
|
||||
SELECT jsonb_agg(jsonb_build_object('id',s.id,'signatario_tipo',s.signatario_tipo,'signatario_nome',s.signatario_nome,
|
||||
'signatario_email',s.signatario_email,'ordem',s.ordem,'status',s.status,'assinado_em',s.assinado_em) ORDER BY s.ordem) INTO v_sigs
|
||||
FROM document_signatures s WHERE s.documento_id = v_doc.id;
|
||||
RETURN jsonb_build_object('valid', true,
|
||||
'document', jsonb_build_object('id',v_doc.id,'nome_original',v_doc.nome_original,'mime_type',v_doc.mime_type,
|
||||
'tamanho_bytes',v_doc.tamanho_bytes,'bucket_path',v_doc.bucket_path,'storage_bucket',v_doc.storage_bucket,'tipo_documento',v_doc.tipo_documento),
|
||||
'signatures', COALESCE(v_sigs,'[]'::jsonb), 'expira_em', v_link.expira_em, 'usos_restantes', v_link.usos_max - v_link.usos);
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.sign_document_by_token(text, uuid, text);
|
||||
CREATE FUNCTION public.sign_document_by_token(p_token text, p_signature_id uuid DEFAULT NULL, p_hash_documento text DEFAULT NULL)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_link public.document_share_links%ROWTYPE; v_sig RECORD; v_ip inet; v_ua text; v_schema text;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE='22023'; END IF;
|
||||
SELECT * INTO v_link FROM public.document_share_links WHERE token = p_token AND ativo=true AND expira_em > now() AND usos < usos_max LIMIT 1;
|
||||
IF v_link.id IS NULL THEN RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE='P0002'; END IF;
|
||||
v_schema := public.tenant_schema_for(v_link.tenant_id);
|
||||
IF v_schema IS NULL THEN RAISE EXCEPTION 'tenant inválido' USING ERRCODE='P0002'; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
IF p_signature_id IS NOT NULL THEN
|
||||
SELECT * INTO v_sig FROM document_signatures WHERE id = p_signature_id AND documento_id = v_link.documento_id AND status IN ('pendente','enviado') LIMIT 1;
|
||||
ELSE
|
||||
SELECT * INTO v_sig FROM document_signatures WHERE documento_id = v_link.documento_id AND status IN ('pendente','enviado') ORDER BY ordem ASC, criado_em ASC LIMIT 1;
|
||||
END IF;
|
||||
IF v_sig.id IS NULL THEN RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE='P0002'; END IF;
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
|
||||
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
|
||||
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
|
||||
WHERE id = v_sig.id RETURNING * INTO v_sig;
|
||||
UPDATE public.document_share_links SET usos = usos + 1 WHERE id = v_link.id;
|
||||
RETURN to_jsonb(v_sig);
|
||||
END $$;
|
||||
|
||||
-- sign_document_by_signature_id: assinante LOGADO (paciente OU therapist) via
|
||||
-- portal. Paciente NÃO é tenant_member → routing UNCHECKED (p_tenant_id vem do
|
||||
-- FE, da própria assinatura listada). Autorização é por LINHA: só assina se for
|
||||
-- o signatário (signatario_id = uid OU email do uid).
|
||||
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, text);
|
||||
DROP FUNCTION IF EXISTS public.sign_document_by_signature_id(uuid, uuid, text);
|
||||
CREATE FUNCTION public.sign_document_by_signature_id(p_tenant_id uuid, p_signature_id uuid, p_hash_documento text DEFAULT NULL)
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_row RECORD; v_ip inet; v_ua text; v_uid uuid; v_email text;
|
||||
BEGIN
|
||||
IF p_signature_id IS NULL THEN RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE='22023'; END IF;
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
|
||||
SELECT email INTO v_email FROM auth.users WHERE id = v_uid;
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN v_ua := current_setting('request.headers', true)::json ->> 'user-agent'; EXCEPTION WHEN OTHERS THEN v_ua := NULL; END;
|
||||
UPDATE document_signatures SET status='assinado', ip=v_ip, user_agent=v_ua, assinado_em=now(),
|
||||
hash_documento=COALESCE(p_hash_documento, hash_documento), atualizado_em=now()
|
||||
WHERE id = p_signature_id AND status IN ('pendente','enviado')
|
||||
AND (signatario_id = v_uid OR signatario_email = v_email
|
||||
OR documento_id IN (SELECT d.id FROM documents d JOIN patients p ON p.id = d.patient_id WHERE p.user_id = v_uid))
|
||||
RETURNING * INTO v_row;
|
||||
IF v_row.id IS NULL THEN RAISE EXCEPTION 'Assinatura não encontrada, já processada, ou sem permissão' USING ERRCODE='P0002'; END IF;
|
||||
RETURN to_jsonb(v_row);
|
||||
END $$;
|
||||
|
||||
-- list_my_signatures: cross-tenant. Fan-out por schema (tenant_id injetado do loop).
|
||||
-- document_share_links é GLOBAL (public). Ordenação global é aproximada (por schema).
|
||||
CREATE OR REPLACE FUNCTION public.list_my_signatures(p_status text[] DEFAULT NULL)
|
||||
RETURNS TABLE(signature_id uuid, documento_id uuid, tenant_id uuid, signatario_tipo text, status text, ordem smallint, assinado_em timestamptz, criado_em timestamptz, nome_original text, tipo_documento text, mime_type text, share_token text, share_expira_em timestamptz)
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_uid uuid; t record;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN RAISE EXCEPTION 'Sessão inválida' USING ERRCODE='28000'; END IF;
|
||||
FOR t IN SELECT ts.tenant_id, ts.schema_name FROM public.tenant_schemas ts LOOP
|
||||
RETURN QUERY EXECUTE format(
|
||||
'SELECT s.id, s.documento_id, $2::uuid, s.signatario_tipo, s.status, s.ordem, s.assinado_em, s.criado_em, '
|
||||
|| 'd.nome_original, d.tipo_documento, d.mime_type, sl.token, sl.expira_em '
|
||||
|| 'FROM %1$I.document_signatures s '
|
||||
|| 'JOIN %1$I.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL '
|
||||
|| 'LEFT JOIN LATERAL (SELECT token, expira_em FROM public.document_share_links WHERE documento_id = d.id AND ativo=true AND expira_em > now() AND usos < usos_max ORDER BY criado_em DESC LIMIT 1) sl ON true '
|
||||
|| 'WHERE (s.signatario_id = $1 OR s.signatario_email = (SELECT email FROM auth.users WHERE id=$1) '
|
||||
|| 'OR d.patient_id IN (SELECT p.id FROM %1$I.patients p WHERE p.user_id = $1)) '
|
||||
|| 'AND ($3::text[] IS NULL OR s.status = ANY($3))',
|
||||
t.schema_name)
|
||||
USING v_uid, t.tenant_id, p_status;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ── match_patient_by_phone: service (edge), p_tenant_id → unchecked ──────────
|
||||
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 TO '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;
|
||||
PERFORM set_config('search_path', public._tenant_schema_unchecked(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT id INTO v_patient_id FROM patients WHERE 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 patients WHERE 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 patients WHERE public.normalize_phone_br(telefone_responsavel) = v_normalized LIMIT 1;
|
||||
RETURN v_patient_id;
|
||||
END $$;
|
||||
|
||||
-- ── Agendador público: tenant via agendador_configuracoes.tenant_id (public) ─
|
||||
CREATE OR REPLACE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer)
|
||||
RETURNS TABLE(data date, tem_slots boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_antecedencia int; v_agora timestamptz;
|
||||
v_data date; v_data_inicio date; v_data_fim date; v_db_dow int; v_tem_slot boolean; v_bloqueado boolean;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.tenant_id, c.antecedencia_minima_horas INTO v_owner_id, v_tenant_id, v_antecedencia
|
||||
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
v_agora := now(); v_data_inicio := make_date(p_ano, p_mes, 1);
|
||||
v_data_fim := (v_data_inicio + interval '1 month' - interval '1 day')::date; v_data := v_data_inicio;
|
||||
WHILE v_data <= v_data_fim LOOP
|
||||
v_db_dow := extract(dow from v_data::timestamp)::int;
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=v_data AND COALESCE(b.data_fim,v_data)>=v_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_bloqueado;
|
||||
IF v_bloqueado THEN v_data := v_data + 1; CONTINUE; END IF;
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true
|
||||
AND (v_data::text||' '||s.time::text)::timestamp AT TIME ZONE 'America/Sao_Paulo' >= v_agora + (v_antecedencia||' hours')::interval) INTO v_tem_slot;
|
||||
IF v_tem_slot THEN data := v_data; tem_slots := true; RETURN NEXT; END IF;
|
||||
v_data := v_data + 1;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date)
|
||||
RETURNS TABLE(hora time without time zone, disponivel boolean) LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_owner_id uuid; v_tenant_id uuid; v_schema text; v_duracao int; v_antecedencia int; v_agora timestamptz;
|
||||
v_db_dow int; v_slot time; v_slot_fim time; v_slot_ts timestamptz; v_ocupado boolean;
|
||||
v_rule RECORD; v_rule_start_dow int; v_first_occ date; v_day_diff int; v_ex_type text;
|
||||
BEGIN
|
||||
SELECT c.owner_id, c.tenant_id, c.duracao_sessao_min, c.antecedencia_minima_horas
|
||||
INTO v_owner_id, v_tenant_id, v_duracao, v_antecedencia
|
||||
FROM public.agendador_configuracoes c WHERE c.link_slug = p_slug AND c.ativo = true LIMIT 1;
|
||||
IF v_owner_id IS NULL THEN RETURN; END IF;
|
||||
v_schema := public.tenant_schema_for(v_tenant_id);
|
||||
IF v_schema IS NULL THEN RETURN; END IF;
|
||||
PERFORM set_config('search_path', v_schema || ',public,pg_temp', true);
|
||||
v_agora := now(); v_db_dow := extract(dow from p_data::timestamp)::int;
|
||||
IF EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NULL AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) THEN RETURN; END IF;
|
||||
FOR v_slot IN SELECT s.time FROM agenda_online_slots s WHERE s.owner_id=v_owner_id AND s.weekday=v_db_dow AND s.enabled=true ORDER BY s.time LOOP
|
||||
v_slot_fim := v_slot + (v_duracao||' minutes')::interval; v_ocupado := false;
|
||||
v_slot_ts := (p_data::text||' '||v_slot::text)::timestamp AT TIME ZONE 'America/Sao_Paulo';
|
||||
IF v_slot_ts < v_agora + (v_antecedencia||' hours')::interval THEN v_ocupado := true; END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_bloqueios b WHERE b.owner_id=v_owner_id AND b.data_inicio<=p_data AND COALESCE(b.data_fim,p_data)>=p_data AND b.hora_inicio IS NOT NULL AND b.hora_inicio<v_slot_fim AND b.hora_fim>v_slot AND ((NOT b.recorrente) OR (b.recorrente AND b.dia_semana=v_db_dow))) INTO v_ocupado;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (SELECT 1 FROM agenda_eventos e WHERE e.owner_id=v_owner_id AND e.status::text NOT IN ('cancelado','faltou') AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date=p_data AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time<v_slot_fim AND (e.fim_em AT TIME ZONE 'America/Sao_Paulo')::time>v_slot) INTO v_ocupado;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
FOR v_rule IN SELECT r.id, r.start_date::date AS start_date, r.end_date::date AS end_date, r.start_time::time AS start_time, r.end_time::time AS end_time, COALESCE(r.interval,1)::int AS interval
|
||||
FROM recurrence_rules r WHERE r.owner_id=v_owner_id AND r.status='ativo' AND p_data>=r.start_date::date AND (r.end_date IS NULL OR p_data<=r.end_date::date) AND v_db_dow=ANY(r.weekdays) AND r.start_time::time<v_slot_fim AND r.end_time::time>v_slot LOOP
|
||||
v_rule_start_dow := extract(dow from v_rule.start_date)::int;
|
||||
v_first_occ := v_rule.start_date + (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
|
||||
v_day_diff := (p_data - v_first_occ)::int;
|
||||
IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
|
||||
v_ex_type := NULL;
|
||||
SELECT ex.type INTO v_ex_type FROM recurrence_exceptions ex WHERE ex.recurrence_id=v_rule.id AND ex.original_date=p_data LIMIT 1;
|
||||
IF v_ex_type IS NULL OR v_ex_type NOT IN ('cancel_session','patient_missed','therapist_canceled','holiday_block','reschedule_session') THEN v_ocupado := true; EXIT; END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
SELECT EXISTS (SELECT 1 FROM recurrence_exceptions ex JOIN recurrence_rules r ON r.id=ex.recurrence_id WHERE r.owner_id=v_owner_id AND r.status='ativo' AND ex.type='reschedule_session' AND ex.new_date=p_data AND COALESCE(ex.new_start_time,r.start_time)::time<v_slot_fim AND COALESCE(ex.new_end_time,r.end_time)::time>v_slot) INTO v_ocupado;
|
||||
END IF;
|
||||
IF NOT v_ocupado THEN
|
||||
-- agendador_solicitacoes FICA em public (F1b)
|
||||
SELECT EXISTS (SELECT 1 FROM public.agendador_solicitacoes sol WHERE sol.owner_id=v_owner_id AND sol.status='pendente' AND sol.data_solicitada=p_data AND sol.hora_solicitada=v_slot AND (sol.reservado_ate IS NULL OR sol.reservado_ate>v_agora)) INTO v_ocupado;
|
||||
END IF;
|
||||
hora := v_slot; disponivel := NOT v_ocupado; RETURN NEXT;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- match_patient_by_phone só pra service_role (edge)
|
||||
REVOKE ALL ON FUNCTION public.match_patient_by_phone(uuid, text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.match_patient_by_phone(uuid, text) TO service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,126 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote G — funções SQL puras → plpgsql + roteamento por tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- SQL puro não permite set_config dinâmico do search_path (limitação 3 do
|
||||
-- blueprint) → converter pra plpgsql. Adicionam p_tenant_id + _tenant_route.
|
||||
-- RETURNS SETOF <tabela_tenant> → jsonb. get_entity_primary_phone (interno,
|
||||
-- 0 callers) herda search_path do chamador (sem SET, unqualified).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, integer, integer);
|
||||
DROP FUNCTION IF EXISTS public.get_financial_summary(uuid, uuid, integer, integer);
|
||||
CREATE FUNCTION public.get_financial_summary(p_tenant_id uuid, p_owner_id uuid, p_year integer, p_month integer)
|
||||
RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0),
|
||||
COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
|
||||
COALESCE(SUM(amount) FILTER (WHERE status IN ('pending','overdue')), 0),
|
||||
COALESCE(SUM(amount) FILTER (WHERE type='receita' AND status='paid'), 0) - COALESCE(SUM(amount) FILTER (WHERE type='despesa' AND status='paid'), 0),
|
||||
COALESCE(SUM(clinic_fee_amount) FILTER (WHERE type='receita' AND status='paid'), 0),
|
||||
COUNT(*) FILTER (WHERE type='receita' AND deleted_at IS NULL),
|
||||
COUNT(*) FILTER (WHERE type='despesa' AND deleted_at IS NULL)
|
||||
FROM financial_records
|
||||
WHERE owner_id = p_owner_id AND deleted_at IS NULL
|
||||
AND EXTRACT(YEAR FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_year
|
||||
AND EXTRACT(MONTH FROM COALESCE(paid_at::date, due_date, created_at::date)) = p_month;
|
||||
END $$;
|
||||
|
||||
-- list_financial_records: RETURNS SETOF financial_records → jsonb (array)
|
||||
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, integer, integer, text, text, uuid, integer, integer);
|
||||
DROP FUNCTION IF EXISTS public.list_financial_records(uuid, uuid, integer, integer, text, text, uuid, integer, integer);
|
||||
CREATE FUNCTION public.list_financial_records(p_tenant_id uuid, p_owner_id uuid, p_year integer DEFAULT NULL, p_month integer DEFAULT NULL, p_type text DEFAULT NULL, p_status text DEFAULT NULL, p_patient_id uuid DEFAULT NULL, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0)
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v_result jsonb;
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
SELECT COALESCE(jsonb_agg(row_json), '[]'::jsonb) INTO v_result FROM (
|
||||
SELECT to_jsonb(fr) AS row_json
|
||||
FROM financial_records fr
|
||||
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
|
||||
AND (p_type IS NULL OR fr.type::text = p_type)
|
||||
AND (p_status IS NULL OR fr.status = p_status)
|
||||
AND (p_patient_id IS NULL OR fr.patient_id = p_patient_id)
|
||||
AND (p_year IS NULL OR EXTRACT(YEAR FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_year)
|
||||
AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date)) = p_month)
|
||||
ORDER BY COALESCE(fr.paid_at, fr.due_date::timestamptz, fr.created_at) DESC
|
||||
LIMIT p_limit OFFSET p_offset
|
||||
) sub;
|
||||
RETURN v_result;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid[]);
|
||||
DROP FUNCTION IF EXISTS public.get_patient_session_counts(uuid, uuid[]);
|
||||
CREATE FUNCTION public.get_patient_session_counts(p_tenant_id uuid, p_patient_ids uuid[])
|
||||
RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamptz)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
SELECT ae.patient_id, COUNT(*)::int, MAX(ae.inicio_em)
|
||||
FROM agenda_eventos ae
|
||||
WHERE ae.patient_id = ANY(p_patient_ids)
|
||||
GROUP BY ae.patient_id;
|
||||
END $$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, date, date, text);
|
||||
DROP FUNCTION IF EXISTS public.get_financial_report(uuid, uuid, date, date, text);
|
||||
CREATE FUNCTION public.get_financial_report(p_tenant_id uuid, p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month')
|
||||
RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM set_config('search_path', public._tenant_route(p_tenant_id) || ',public,pg_temp', true);
|
||||
RETURN QUERY
|
||||
WITH base AS (
|
||||
SELECT fr.type, fr.amount, fr.final_amount, fr.status, fr.deleted_at,
|
||||
CASE p_group_by
|
||||
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
|
||||
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
|
||||
WHEN 'category' THEN COALESCE(fr.category_id::text, fr.category, 'sem_categoria')
|
||||
WHEN 'patient' THEN COALESCE(fr.patient_id::text, 'sem_paciente')
|
||||
ELSE NULL END AS gkey,
|
||||
CASE p_group_by
|
||||
WHEN 'month' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'YYYY-MM')
|
||||
WHEN 'week' THEN TO_CHAR(COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date),'IYYY-"W"IW')
|
||||
WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
|
||||
WHEN 'patient' THEN COALESCE(p.nome_completo, fr.patient_id::text, 'Sem paciente')
|
||||
ELSE NULL END AS glabel
|
||||
FROM financial_records fr
|
||||
LEFT JOIN financial_categories fc ON fc.id = fr.category_id
|
||||
LEFT JOIN patients p ON p.id = fr.patient_id
|
||||
WHERE fr.owner_id = p_owner_id AND fr.deleted_at IS NULL
|
||||
AND COALESCE(fr.paid_at::date, fr.due_date, fr.created_at::date) BETWEEN p_start_date AND p_end_date
|
||||
)
|
||||
SELECT gkey, glabel,
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE type='receita' AND status='paid'),0) - COALESCE(SUM(final_amount) FILTER (WHERE type='despesa' AND status='paid'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE status='pending'),0),
|
||||
COALESCE(SUM(final_amount) FILTER (WHERE status='overdue'),0),
|
||||
COUNT(*)
|
||||
FROM base WHERE gkey IS NOT NULL GROUP BY gkey, glabel ORDER BY gkey ASC;
|
||||
END $$;
|
||||
|
||||
-- get_entity_primary_phone: interno (0 callers). Sem SET search_path → herda do
|
||||
-- chamador (que roteia pro schema). Unqualified. Mantém assinatura.
|
||||
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid)
|
||||
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER
|
||||
AS $$
|
||||
SELECT number FROM 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;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,106 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 (wiring) — tenants NOVOS nascem com todos os triggers de negócio
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- Até aqui os 9 schemas existentes ganharam os triggers via backfills (Lotes
|
||||
-- A/B/C). Um tenant NOVO (clone_tenant_template) só ganhava channel-routing +
|
||||
-- RLS. Este wiring:
|
||||
-- 1. attach_agnostic_triggers → SELF-CONTAINED (dirigido por colunas, não lê
|
||||
-- public; sobrevive ao DROP da F6.3).
|
||||
-- 2. trigger AFTER INSERT em tenant_schemas dispara os 3 attach (agnostic +
|
||||
-- schema_aware + notif) pro schema novo — clone_tenant_template não precisa
|
||||
-- ser tocado (ele insere em tenant_schemas).
|
||||
-- 3. provision_account_tenant: clone ANTES do seed (seed é no-op se o schema
|
||||
-- não existe; precisa do schema criado primeiro).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) attach_agnostic_triggers self-contained ---------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
|
||||
RETURNS int LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE r record; v_count int := 0;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN RAISE EXCEPTION 'schema inválido %', p_schema; END IF;
|
||||
-- set_updated_at em toda tabela do schema que tem coluna updated_at
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab
|
||||
FROM pg_class c JOIN pg_attribute a ON a.attrelid = c.oid
|
||||
WHERE c.relnamespace = p_schema::regnamespace AND c.relkind = 'r'
|
||||
AND a.attname = 'updated_at' AND NOT a.attisdropped AND c.relname NOT LIKE '\_%'
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS set_updated_at ON %I.%I', p_schema, r.tab);
|
||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %I.%I FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()', p_schema, r.tab);
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
-- prevent_* em patient_groups
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = p_schema AND table_name = 'patient_groups') THEN
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS prevent_promoting_to_system ON %I.patient_groups', p_schema);
|
||||
EXECUTE format('CREATE TRIGGER prevent_promoting_to_system BEFORE UPDATE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_promoting_to_system()', p_schema);
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS prevent_system_group_changes ON %I.patient_groups', p_schema);
|
||||
EXECUTE format('CREATE TRIGGER prevent_system_group_changes BEFORE UPDATE OR DELETE ON %I.patient_groups FOR EACH ROW EXECUTE FUNCTION public.prevent_system_group_changes()', p_schema);
|
||||
v_count := v_count + 2;
|
||||
END IF;
|
||||
RETURN v_count;
|
||||
END $$;
|
||||
|
||||
-- 2) trigger de wiring em tenant_schemas -------------------------------------
|
||||
GRANT EXECUTE ON FUNCTION public.attach_agnostic_triggers(text) TO postgres, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.attach_schema_aware_triggers(text) TO postgres, service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.attach_notif_triggers(text) TO postgres, service_role;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_notif_triggers(NEW.schema_name);
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenant_schemas_attach ON public.tenant_schemas;
|
||||
CREATE TRIGGER trg_tenant_schemas_attach
|
||||
AFTER INSERT ON public.tenant_schemas
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_attach_business_triggers();
|
||||
|
||||
-- 3) provision_account_tenant: clone ANTES do seed ---------------------------
|
||||
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||
RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
v_account_type text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = p_user_id AND tm.role = 'tenant_admin' AND tm.status = 'active' AND t.kind = p_kind
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
v_name := COALESCE(NULLIF(TRIM(p_name), ''),
|
||||
(SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||
FROM public.profiles pr JOIN auth.users au ON au.id = pr.id WHERE pr.id = p_user_id),
|
||||
'Conta');
|
||||
|
||||
INSERT INTO public.tenants (name, kind, created_at) VALUES (v_name, p_kind, now()) RETURNING id INTO v_tenant_id;
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||
UPDATE public.profiles SET account_type = v_account_type WHERE id = p_user_id;
|
||||
|
||||
-- F6 wiring: clone PRIMEIRO (cria o schema), seed DEPOIS (escreve no schema)
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
RETURN v_tenant_id;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,99 @@
|
||||
# F6.3 — Rollback da migração schema-per-tenant
|
||||
|
||||
> Como voltar atrás. Lê isto ANTES de aplicar a F6.3 (o DROP). A regra de ouro:
|
||||
> **enquanto a F6.3 NÃO foi aplicada, o rollback é trivial e sem perda.**
|
||||
|
||||
## Princípio: a branch é a rede de segurança
|
||||
|
||||
A migração inteira (F3→F6.4) vive na branch `feat/schema-per-tenant`. A `main`
|
||||
**nunca mudou** o modelo antigo — só recebeu F0/F1/F2, que são **aditivas**
|
||||
(criam `tenants.slug`, o schema `_tenant_template`, helpers e o gatilho de
|
||||
provisionamento; não removem nem alteram nada que o app antigo usa). Ou seja:
|
||||
**`git checkout main` te devolve o app funcionando no modelo public**, desde que
|
||||
o banco também volte (ver abaixo).
|
||||
|
||||
O único passo IRREVERSÍVEL por si só é o **DROP** (F6.3). Tudo antes dele é
|
||||
reversível porque os dados continuam **espelhados em public** (a F6.1 COPIA, não
|
||||
move). Por isso o checkpoint é parar ANTES do DROP.
|
||||
|
||||
---
|
||||
|
||||
## Cenário 1 — ANTES de aplicar a F6.3 (estado atual) — rollback trivial
|
||||
|
||||
Nada destrutivo foi feito. Public tem todas as tabelas e dados originais. Para
|
||||
abandonar a migração:
|
||||
|
||||
```bash
|
||||
# 1. código volta pro modelo antigo
|
||||
git checkout main
|
||||
|
||||
# 2. banco: derruba os schemas tenant + artefatos da migração
|
||||
docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
psql -U supabase_admin -h 127.0.0.1 -d postgres <<'SQL'
|
||||
DO $$ DECLARE r record; BEGIN
|
||||
FOR r IN SELECT nspname FROM pg_namespace WHERE nspname LIKE 'tenant\_%' OR nspname='_tenant_template' LOOP
|
||||
EXECUTE format('DROP SCHEMA %I CASCADE', r.nspname);
|
||||
END LOOP;
|
||||
END $$;
|
||||
-- limpa o registro + config do PostgREST
|
||||
DELETE FROM public.tenant_schemas;
|
||||
ALTER ROLE authenticator SET pgrst.db_schemas = 'public, graphql_public';
|
||||
NOTIFY pgrst, 'reload config';
|
||||
SQL
|
||||
```
|
||||
|
||||
⚠️ As FUNÇÕES em public foram reescritas (F6.2) na branch, mas essas mudanças
|
||||
**não foram aplicadas via migration em `main`** — elas vieram dos arquivos
|
||||
`manual/f6_2*.sql` aplicados como supabase_admin no banco LOCAL. Se você quer o
|
||||
banco local 100% igual ao `main`, restaure as funções originais do backup:
|
||||
|
||||
```bash
|
||||
# restaura o estado public pré-migração (funções, triggers, tudo)
|
||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
|
||||
< database-novo/backups/pre-F6/public.sql # ou o backup mais antigo (pré-F1)
|
||||
```
|
||||
|
||||
Como na prática o banco é LOCAL e descartável, o caminho mais limpo do Cenário 1
|
||||
é: **`git checkout main` + `node db.cjs reset` (reinstala schema+seeds do zero)**.
|
||||
|
||||
---
|
||||
|
||||
## Cenário 2 — DEPOIS de aplicar a F6.3 (DROP já feito) — recuperável, com cuidado
|
||||
|
||||
O DROP removeu as 78 tabelas tenant de public. Os dados VIVOS estão nos schemas
|
||||
`tenant_<slug>`. Há duas situações:
|
||||
|
||||
### 2a) Rollback rápido (logo após o DROP, app quase não rodou nos schemas)
|
||||
Os dados em public estavam atualizados até o **backup pré-F6.3**. Se quase nada
|
||||
foi escrito nos schemas depois do DROP, restaure public do backup e volte o código:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres \
|
||||
< database-novo/backups/pre-F6.3/public.sql # backup TIRADO antes do DROP
|
||||
# + derrubar schemas tenant (bloco SQL do Cenário 1)
|
||||
```
|
||||
PERDE: qualquer escrita feita NOS SCHEMAS entre o DROP e o rollback.
|
||||
|
||||
### 2b) Rollback com dados atualizados (app rodou e acumulou dados nos schemas)
|
||||
Aí os schemas têm a verdade mais nova. Precisa de uma **migração reversa**
|
||||
(schema → public, o inverso da F6.1), re-adicionando `tenant_id`. Roteiro:
|
||||
1. Restaure a ESTRUTURA das 78 tabelas em public (do backup pré-F6.3, sem os
|
||||
dados, ou recriando via o schema dump).
|
||||
2. Para cada tenant, `INSERT INTO public.<tab> (cols + tenant_id) SELECT cols,
|
||||
'<tenant_id>' FROM tenant_<slug>.<tab>` — o inverso exato do
|
||||
`manual/f6_1_migrate_data.supabase_admin.sql` (trocar origem/destino e
|
||||
RE-ADICIONAR a coluna tenant_id com o id do tenant do schema).
|
||||
3. Resetar sequences, recriar as 9 views + 2 FKs, voltar o código (`git checkout main`).
|
||||
|
||||
Esse caminho é trabalhoso — por isso a recomendação forte: **só aplique a F6.3
|
||||
depois de validar o app**, e mantenha o backup pré-F6.3. O DROP é a única coisa
|
||||
que transforma "trivial" em "trabalhoso".
|
||||
|
||||
---
|
||||
|
||||
## Checklist antes de aplicar a F6.3 (resumo)
|
||||
- [ ] App testado no browser (fluxos autenticados sem erro PGRST/4xx).
|
||||
- [ ] Backup FRESCO: `pg_dump --schema=public > backups/pre-F6.3/public.sql`.
|
||||
- [ ] Branch commitada (rollback de código = `git checkout main`).
|
||||
- [ ] Ciente: pós-DROP, public some; a verdade passa a ser os schemas.
|
||||
@@ -0,0 +1,107 @@
|
||||
-- =============================================================================
|
||||
-- F6.3 — DROP das tabelas tenant em public (PONTO DE NÃO-RETORNO)
|
||||
--
|
||||
-- 🛑 NÃO APLICAR AINDA. Este arquivo está PREPARADO para revisão. Aplicar só
|
||||
-- depois de:
|
||||
-- (a) Leonardo testar o app no branch e validar os fluxos;
|
||||
-- (b) os ITENS EM ABERTO abaixo resolvidos;
|
||||
-- (c) backup fresco confirmado.
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (DROP CASCADE; tabelas documents/document_* são
|
||||
-- owned por supabase_admin).
|
||||
-- docker exec -i -e PGPASSWORD=postgres supabase_db_agenciapsi-primesakai \
|
||||
-- psql -U supabase_admin -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 \
|
||||
-- < database-novo/manual/f6_3_drop_public_tenant_tables.supabase_admin.sql
|
||||
--
|
||||
-- BACKUP ANTES (obrigatório):
|
||||
-- docker exec supabase_db_agenciapsi-primesakai pg_dump -U postgres -d postgres \
|
||||
-- --schema=public --no-owner --no-acl > database-novo/backups/pre-F6.3/public.sql
|
||||
--
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- ✅ ITENS EM ABERTO — RESOLVIDOS (F6.4, commit dc2363b):
|
||||
-- 1. v_twilio_whatsapp_overview / getAllChannels → RPC saas_list_all_whatsapp_
|
||||
-- channels (fan-out). ✓
|
||||
-- 2. SaasFeriadosPage / SaasNotificationTemplatesPage / SaasWhatsappPage →
|
||||
-- RPCs saas_*_default + supabase.schema(tenant_<slug>). ✓
|
||||
-- 3. notification-webhook (Meta) → confirmado: usa tdb/schema (F4). ✓
|
||||
-- 4. AgendadorPublicoPage → RPCs roteadas. ✓
|
||||
-- Varredura FE confirma ZERO supabase.from('<tabela_tenant>') público restante.
|
||||
-- PRÉ-REQUISITO FINAL: Leonardo testar o app + backup fresco.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- =============================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
BEGIN;
|
||||
|
||||
-- ── 0) PRÉ-FLIGHT: aborta se algo essencial não bate ────────────────────────
|
||||
DO $$
|
||||
DECLARE v_tenants int; v_schemas int; v_drop int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO v_tenants FROM public.tenants;
|
||||
SELECT count(*) INTO v_schemas FROM public.tenant_schemas;
|
||||
IF v_tenants <> v_schemas THEN
|
||||
RAISE EXCEPTION 'F6.3 ABORT: % tenants mas % schemas provisionados — nem todos migrados', v_tenants, v_schemas;
|
||||
END IF;
|
||||
SELECT count(*) INTO v_drop FROM information_schema.tables
|
||||
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%';
|
||||
IF v_drop <> 78 THEN
|
||||
RAISE EXCEPTION 'F6.3 ABORT: _tenant_template tem % tabelas, esperava 78', v_drop;
|
||||
END IF;
|
||||
-- guarda: nenhuma das 6 anon-facing pode estar na lista de drop
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='_tenant_template'
|
||||
AND table_name IN ('patient_intake_requests','patient_invites','patient_invite_attempts',
|
||||
'document_share_links','agendador_configuracoes','agendador_solicitacoes')) THEN
|
||||
RAISE EXCEPTION 'F6.3 ABORT: tabela anon-facing presente no template (não deveria sair de public)';
|
||||
END IF;
|
||||
RAISE NOTICE 'F6.3 pré-flight OK: % tenants = % schemas, 78 tabelas a dropar', v_tenants, v_schemas;
|
||||
END $$;
|
||||
|
||||
-- ── 1) FKs de tabelas que FICAM → tabelas que SAEM: viram coluna solta ──────
|
||||
-- (validação fica no app/RPC via token; alvo vai pro schema do tenant)
|
||||
ALTER TABLE public.document_share_links DROP CONSTRAINT IF EXISTS document_share_links_documento_id_fkey;
|
||||
ALTER TABLE public.whatsapp_credits_transactions DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_conversation_message_id_fkey;
|
||||
|
||||
-- ── 2) Views public que referenciam tabelas tenant ──────────────────────────
|
||||
-- As 6 do template são recriadas POR SCHEMA (F1). v_patient_engajamento e
|
||||
-- v_patients_risco são dead code (0 uso no FE). v_twilio_whatsapp_overview:
|
||||
-- ⚠️ ver ITEM EM ABERTO #1 — só dropar após reescrever getAllChannels.
|
||||
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
|
||||
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_cashflow_projection CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_commitment_totals CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_patient_groups_with_counts CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_tag_patient_counts CASCADE;
|
||||
DROP VIEW IF EXISTS public.v_patient_engajamento CASCADE; -- dead code
|
||||
DROP VIEW IF EXISTS public.v_patients_risco CASCADE; -- dead code
|
||||
DROP VIEW IF EXISTS public.v_twilio_whatsapp_overview CASCADE; -- ⚠️ item #1
|
||||
|
||||
-- ── 3) DROP das 78 tabelas tenant em public (derivado de _tenant_template) ──
|
||||
-- CASCADE leva junto triggers das tabelas + FKs intra-tenant. Dados já estão
|
||||
-- nos schemas (F6.1) — estas são as cópias velhas.
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema='_tenant_template' AND table_type='BASE TABLE' AND table_name NOT LIKE '\_%'
|
||||
ORDER BY table_name
|
||||
LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=r.table_name) THEN
|
||||
EXECUTE format('DROP TABLE public.%I CASCADE', r.table_name);
|
||||
RAISE NOTICE 'F6.3 dropou public.%', r.table_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- ── 4) Limpeza de funções obsoletas pós-drop ────────────────────────────────
|
||||
-- financial_records_inject_tenant só fazia sentido em public.financial_records
|
||||
-- (já dropada); não está anexado em nenhum schema.
|
||||
DROP FUNCTION IF EXISTS public.financial_records_inject_tenant() CASCADE;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── 5) PÓS-DROP: verificações manuais (rodar separado) ───────────────────────
|
||||
-- SELECT 'tabelas tenant restantes em public: ' || count(*) FROM information_schema.tables
|
||||
-- WHERE table_schema='public' AND table_name IN (SELECT table_name FROM _tenant_template.information... );
|
||||
-- NOTIFY pgrst, 'reload schema';
|
||||
-- Conferir app: nenhuma query 4xx/PGRST200 no console.
|
||||
@@ -0,0 +1,181 @@
|
||||
-- =============================================================================
|
||||
-- F6.4 — RPCs SaaS-admin: defaults do sistema (template + fan-out) e views
|
||||
-- cross-tenant (fan-out). Destrava o F6.3 (páginas SaaS deixam de ler
|
||||
-- public.<tabela_tenant>).
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin.
|
||||
--
|
||||
-- Defaults (feriados nacionais, notification_templates is_default): editados
|
||||
-- pelo SaaS no _tenant_template (fonte da verdade, propaga p/ tenants NOVOS no
|
||||
-- clone) E fan-out pros schemas EXISTENTES. Só toca cópias-default (owner_id
|
||||
-- NULL / is_default), preserva overrides do tenant (owner_id próprio).
|
||||
-- Todas gated por is_saas_admin().
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public._assert_saas_admin()
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$ BEGIN
|
||||
IF NOT public.is_saas_admin() THEN RAISE EXCEPTION 'Apenas SaaS admin' USING ERRCODE='42501'; END IF;
|
||||
END $$;
|
||||
|
||||
-- ── FERIADOS (defaults nacionais) ───────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_default_feriados(p_ano integer)
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v jsonb;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object('id',id,'data',data,'nome',nome,'tipo',tipo,'bloqueia_sessoes',bloqueia_sessoes) ORDER BY data),'[]'::jsonb)
|
||||
INTO v FROM _tenant_template.feriados WHERE EXTRACT(YEAR FROM data) = p_ano;
|
||||
RETURN v;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_add_default_feriado(p_data date, p_nome text, p_tipo text DEFAULT 'municipal')
|
||||
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_owner uuid := auth.uid();
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
INSERT INTO _tenant_template.feriados (owner_id, tipo, nome, data, bloqueia_sessoes)
|
||||
VALUES (v_owner, p_tipo, p_nome, p_data, false) ON CONFLICT (data, nome) DO NOTHING;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('INSERT INTO %I.feriados (owner_id, tipo, nome, data, bloqueia_sessoes) VALUES ($1,$2,$3,$4,false) ON CONFLICT (data, nome) DO NOTHING', t.schema_name)
|
||||
USING v_owner, p_tipo, p_nome, p_data;
|
||||
END LOOP;
|
||||
RETURN jsonb_build_object('data', p_data, 'nome', p_nome, 'tipo', p_tipo);
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_remove_default_feriado(p_data date, p_nome text)
|
||||
RETURNS integer LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int := 0;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
DELETE FROM _tenant_template.feriados WHERE data = p_data AND nome = p_nome;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('DELETE FROM %I.feriados WHERE data = $1 AND nome = $2', t.schema_name) USING p_data, p_nome;
|
||||
v_n := v_n + 1;
|
||||
END LOOP;
|
||||
RETURN v_n;
|
||||
END $$;
|
||||
|
||||
-- ── NOTIFICATION_TEMPLATES (defaults) ───────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_default_notif_templates()
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE v jsonb;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(nt) ORDER BY nt.domain, nt.event_type),'[]'::jsonb)
|
||||
INTO v FROM _tenant_template.notification_templates nt WHERE nt.is_default = true AND nt.deleted_at IS NULL;
|
||||
RETURN v;
|
||||
END $$;
|
||||
|
||||
-- upsert por key (defaults têm owner_id NULL). Cria/atualiza no template + schemas.
|
||||
CREATE OR REPLACE FUNCTION public.saas_upsert_default_notif_template(p_payload jsonb)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_key text := p_payload->>'key'; v_exists boolean;
|
||||
v_domain text := p_payload->>'domain'; v_channel text := p_payload->>'channel';
|
||||
v_event text := p_payload->>'event_type'; v_body text := p_payload->>'body_text';
|
||||
v_vars jsonb := COALESCE(p_payload->'variables','[]'::jsonb);
|
||||
v_active boolean := COALESCE((p_payload->>'is_active')::boolean, true);
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
IF v_key IS NULL THEN RAISE EXCEPTION 'key obrigatório'; END IF;
|
||||
-- template
|
||||
SELECT EXISTS(SELECT 1 FROM _tenant_template.notification_templates WHERE key=v_key AND owner_id IS NULL AND is_default=true) INTO v_exists;
|
||||
IF v_exists THEN
|
||||
UPDATE _tenant_template.notification_templates SET body_text=v_body, domain=v_domain, channel=v_channel,
|
||||
event_type=v_event, variables=v_vars, is_active=v_active WHERE key=v_key AND owner_id IS NULL AND is_default=true;
|
||||
ELSE
|
||||
INSERT INTO _tenant_template.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
|
||||
VALUES (NULL, v_key, v_domain, v_channel, v_event, v_body, v_vars, true, v_active);
|
||||
END IF;
|
||||
-- fan-out schemas (só a cópia-default; preserva overrides do tenant owner_id<>NULL)
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.notification_templates (owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active) '
|
||||
|| 'VALUES (NULL,$1,$2,$3,$4,$5,$6,true,$7) '
|
||||
|| 'ON CONFLICT (owner_id, key, deleted_at) DO UPDATE SET body_text=EXCLUDED.body_text, domain=EXCLUDED.domain, '
|
||||
|| 'channel=EXCLUDED.channel, event_type=EXCLUDED.event_type, variables=EXCLUDED.variables, is_active=EXCLUDED.is_active',
|
||||
t.schema_name) USING v_key, v_domain, v_channel, v_event, v_body, v_vars, v_active;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_set_default_notif_template_active(p_key text, p_active boolean)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
UPDATE _tenant_template.notification_templates SET is_active=p_active WHERE key=p_key AND owner_id IS NULL AND is_default=true;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.notification_templates SET is_active=$1 WHERE key=$2 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_active, p_key;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.saas_delete_default_notif_template(p_key text)
|
||||
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
UPDATE _tenant_template.notification_templates SET deleted_at=now() WHERE key=p_key AND owner_id IS NULL AND is_default=true;
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('UPDATE %I.notification_templates SET deleted_at=now() WHERE key=$1 AND owner_id IS NULL AND is_default=true', t.schema_name) USING p_key;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- quantos tenants têm override (tenant_id<>NULL no modelo antigo = owner_id<>NULL aqui) por key
|
||||
CREATE OR REPLACE FUNCTION public.saas_count_notif_template_overrides(p_key text)
|
||||
RETURNS integer LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_n int := 0; v_has int;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
FOR t IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
EXECUTE format('SELECT count(*) FROM %I.notification_templates WHERE key=$1 AND owner_id IS NOT NULL AND is_active=true AND deleted_at IS NULL', t.schema_name) INTO v_has USING p_key;
|
||||
v_n := v_n + v_has;
|
||||
END LOOP;
|
||||
RETURN v_n;
|
||||
END $$;
|
||||
|
||||
-- ── WHATSAPP admin (cross-tenant) — substitui v_twilio_whatsapp_overview ─────
|
||||
CREATE OR REPLACE FUNCTION public.saas_list_all_whatsapp_channels()
|
||||
RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE t record; v_rows jsonb := '[]'::jsonb; v_part jsonb;
|
||||
BEGIN
|
||||
PERFORM public._assert_saas_admin();
|
||||
FOR t IN SELECT ts.tenant_id, ts.schema_name, tn.name AS tenant_name
|
||||
FROM public.tenant_schemas ts JOIN public.tenants tn ON tn.id = ts.tenant_id LOOP
|
||||
EXECUTE format(
|
||||
'SELECT COALESCE(jsonb_agg(jsonb_build_object('
|
||||
|| '''id'',nc.id, ''tenant_id'',$1::uuid, ''tenant_name'',$2::text, ''owner_id'',nc.owner_id,'
|
||||
|| '''provider'',nc.provider, ''is_active'',nc.is_active, ''connection_status'',nc.connection_status,'
|
||||
|| '''sender_address'',nc.sender_address, ''twilio_phone_number'',nc.twilio_phone_number,'
|
||||
|| '''last_health_check'',nc.last_health_check,'
|
||||
|| '''open_incident'',(SELECT i.kind FROM %1$I.whatsapp_connection_incidents i WHERE i.channel_id=nc.id AND i.resolved_at IS NULL LIMIT 1)'
|
||||
|| ')),''[]''::jsonb) FROM %1$I.notification_channels nc WHERE nc.channel=''whatsapp'' AND nc.deleted_at IS NULL',
|
||||
t.schema_name) INTO v_part USING t.tenant_id, t.tenant_name;
|
||||
v_rows := v_rows || v_part;
|
||||
END LOOP;
|
||||
RETURN v_rows;
|
||||
END $$;
|
||||
|
||||
-- grants: gated por is_saas_admin internamente, mas exposto a authenticated
|
||||
DO $g$ DECLARE fn text; BEGIN
|
||||
FOREACH fn IN ARRAY ARRAY[
|
||||
'saas_list_default_feriados(integer)','saas_add_default_feriado(date,text,text)','saas_remove_default_feriado(date,text)',
|
||||
'saas_list_default_notif_templates()','saas_upsert_default_notif_template(jsonb)',
|
||||
'saas_set_default_notif_template_active(text,boolean)','saas_delete_default_notif_template(text)',
|
||||
'saas_count_notif_template_overrides(text)','saas_list_all_whatsapp_channels()'
|
||||
] LOOP
|
||||
EXECUTE format('GRANT EXECUTE ON FUNCTION public.%s TO authenticated', fn);
|
||||
END LOOP;
|
||||
END $g$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,147 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F1 — Enforcement de limite de plano (pacientes), schema-per-tenant
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (anexa triggers em tabelas tenant + a função de
|
||||
-- wiring trg_attach_business_triggers é owned por supabase_admin).
|
||||
--
|
||||
-- Trigger genérico BEFORE INSERT em <schema>.patients que:
|
||||
-- 1. resolve o tenant pelo NOME DO SCHEMA (TG_TABLE_SCHEMA → tenant_schemas);
|
||||
-- 2. resolve o plano ATIVO do tenant em runtime (clínica via tenant_id;
|
||||
-- pessoal/terapeuta via owner user_id — as 6 subs pessoais têm tenant_id NULL);
|
||||
-- 3. lê o limite max_patients de plan_features.limits EM RUNTIME (mudar o número
|
||||
-- no painel passa a valer sem deploy);
|
||||
-- 4. conta pacientes vivos (status <> 'Arquivado') e dá RAISE parseável
|
||||
-- 'PLAN_LIMIT_REACHED|patients|<limite>' quando já atingiu.
|
||||
--
|
||||
-- Sem plano ativo OU sem limite definido (planos PRO) ⇒ não bloqueia.
|
||||
-- Idempotente (CREATE OR REPLACE + DROP TRIGGER IF EXISTS). Tudo em public
|
||||
-- (subscriptions/plan_features/tenant_schemas são globais) ⇒ sobrevive ao DROP F6.3.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) Resolve o plano ativo de um tenant (clínica OU pessoal) ------------------
|
||||
CREATE OR REPLACE FUNCTION public.tenant_active_plan_id(p_tenant_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
-- clínica: subscription chaveada por tenant_id
|
||||
(SELECT vas.plan_id
|
||||
FROM public.v_tenant_active_subscription vas
|
||||
WHERE vas.tenant_id = p_tenant_id),
|
||||
-- pessoal: subscription chaveada pelo owner (user_id), tenant_id NULL
|
||||
(SELECT s.plan_id
|
||||
FROM public.subscriptions s
|
||||
JOIN public.tenant_members tm
|
||||
ON tm.user_id = s.user_id
|
||||
AND tm.tenant_id = p_tenant_id
|
||||
AND tm.status = 'active'
|
||||
WHERE s.status = 'active'
|
||||
AND s.tenant_id IS NULL
|
||||
AND (s.current_period_end IS NULL OR s.current_period_end > now())
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 1)
|
||||
);
|
||||
$$;
|
||||
ALTER FUNCTION public.tenant_active_plan_id(uuid) OWNER TO supabase_admin;
|
||||
|
||||
-- 2) Lê um limite numérico do plano (busca a chave em qualquer feature) -------
|
||||
-- Ex.: clinic_free guarda max_patients sob clinic_calendar; therapist_free
|
||||
-- sob patients.manage. Retorna o MIN (mais restritivo) se houver mais de um.
|
||||
CREATE OR REPLACE FUNCTION public.plan_feature_limit(p_plan_id uuid, p_limit_key text)
|
||||
RETURNS int
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
SELECT min((pf.limits->>p_limit_key)::int)
|
||||
FROM public.plan_features pf
|
||||
WHERE pf.plan_id = p_plan_id
|
||||
AND pf.enabled
|
||||
AND pf.limits ? p_limit_key
|
||||
AND (pf.limits->>p_limit_key) ~ '^[0-9]+$';
|
||||
$$;
|
||||
ALTER FUNCTION public.plan_feature_limit(uuid, text) OWNER TO supabase_admin;
|
||||
|
||||
-- 3) Trigger function de enforcement -----------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.enforce_patient_plan_limit()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant uuid;
|
||||
v_plan uuid;
|
||||
v_limit int;
|
||||
v_count int;
|
||||
BEGIN
|
||||
SELECT tenant_id INTO v_tenant
|
||||
FROM public.tenant_schemas
|
||||
WHERE schema_name = TG_TABLE_SCHEMA;
|
||||
IF v_tenant IS NULL THEN RETURN NEW; END IF; -- schema não-tenant: ignora
|
||||
|
||||
v_plan := public.tenant_active_plan_id(v_tenant);
|
||||
IF v_plan IS NULL THEN RETURN NEW; END IF; -- sem plano ativo: não bloqueia
|
||||
|
||||
v_limit := public.plan_feature_limit(v_plan, 'max_patients');
|
||||
IF v_limit IS NULL THEN RETURN NEW; END IF; -- plano sem limite (PRO): ilimitado
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT count(*) FROM %I.patients WHERE status IS DISTINCT FROM %L',
|
||||
TG_TABLE_SCHEMA, 'Arquivado'
|
||||
) INTO v_count;
|
||||
|
||||
IF v_count >= v_limit THEN
|
||||
RAISE EXCEPTION 'PLAN_LIMIT_REACHED|patients|%', v_limit USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
ALTER FUNCTION public.enforce_patient_plan_limit() OWNER TO supabase_admin;
|
||||
|
||||
-- 4) Attach helper (pendura o trigger no patients de um schema) ---------------
|
||||
CREATE OR REPLACE FUNCTION public.attach_plan_limit_triggers(p_schema text)
|
||||
RETURNS int
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN
|
||||
RAISE EXCEPTION 'schema inválido %', p_schema;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = p_schema AND table_name = 'patients'
|
||||
) THEN
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS enforce_patient_plan_limit ON %I.patients', p_schema);
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER enforce_patient_plan_limit BEFORE INSERT ON %I.patients '
|
||||
'FOR EACH ROW EXECUTE FUNCTION public.enforce_patient_plan_limit()', p_schema);
|
||||
RETURN 1;
|
||||
END IF;
|
||||
RETURN 0;
|
||||
END $$;
|
||||
ALTER FUNCTION public.attach_plan_limit_triggers(text) OWNER TO supabase_admin;
|
||||
GRANT EXECUTE ON FUNCTION public.attach_plan_limit_triggers(text) TO postgres, service_role;
|
||||
|
||||
-- 5) Wiring: tenants NOVOS ganham o trigger de limite no clone ----------------
|
||||
CREATE OR REPLACE FUNCTION public.trg_attach_business_triggers()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.attach_agnostic_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_schema_aware_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_notif_triggers(NEW.schema_name);
|
||||
PERFORM public.attach_plan_limit_triggers(NEW.schema_name);
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
ALTER FUNCTION public.trg_attach_business_triggers() OWNER TO supabase_admin;
|
||||
|
||||
-- 6) Backfill: os 9 schemas já existentes ganham o trigger agora -------------
|
||||
DO $$
|
||||
DECLARE r record; n int := 0;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas LOOP
|
||||
n := n + public.attach_plan_limit_triggers(r.schema_name);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'enforce_patient_plan_limit anexado em % schemas', n;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,238 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F2 — RPCs idempotentes do self-service (schema-per-tenant)
|
||||
--
|
||||
-- ⚠️ APLICAR COMO supabase_admin (auto_provision insere em tenants/members/
|
||||
-- subscriptions/profiles + roda clone_tenant_template).
|
||||
--
|
||||
-- Com confirmação de e-mail LIGADA, o signup NÃO tem sessão — então nada que
|
||||
-- dependa de auth.uid() roda no signup. A escolha do usuário (nome, slug, plano,
|
||||
-- intervalo, kind) é gravada no raw_user_meta_data do signUp e PROCESSADA aqui,
|
||||
-- no 1º login pós-confirmação:
|
||||
-- • slug_disponivel(p_slug) → {ok, motivo} (chamável por anon no signup)
|
||||
-- • auto_provision_free_tenant(...) → cria tenant + clone + master + sub free
|
||||
-- • processar_pos_signup() → cria a intenção SÓ pro caminho pago
|
||||
--
|
||||
-- Todas idempotentes. Não há tabela de aceite legal neste sistema (pulado).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1) slug_disponivel ---------------------------------------------------------
|
||||
-- Valida formato (mesma regra do generate_tenant_slug), reservados e uso.
|
||||
-- Chamável por ANON (signup acontece antes do login). Não vaza dados de
|
||||
-- tenant além do fato "slug em uso".
|
||||
CREATE OR REPLACE FUNCTION public.slug_disponivel(p_slug text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v text := lower(trim(coalesce(p_slug, '')));
|
||||
v_reservados text[] := ARRAY['public','tenant','admin','www','api','app','auth','supabase','postgres','saas','suporte','support'];
|
||||
BEGIN
|
||||
IF length(v) < 3 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'curto');
|
||||
END IF;
|
||||
IF length(v) > 48 THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'longo');
|
||||
END IF;
|
||||
-- começa com letra, só [a-z0-9_]
|
||||
IF v !~ '^[a-z][a-z0-9_]*$' THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'invalido');
|
||||
END IF;
|
||||
IF v = ANY(v_reservados) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'reservado');
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM public.tenants WHERE slug = v) THEN
|
||||
RETURN jsonb_build_object('ok', false, 'motivo', 'em_uso');
|
||||
END IF;
|
||||
-- (F3) blacklist de slug integra aqui via motivo 'bloqueado'
|
||||
RETURN jsonb_build_object('ok', true, 'motivo', 'disponivel');
|
||||
END $$;
|
||||
ALTER FUNCTION public.slug_disponivel(text) OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.slug_disponivel(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.slug_disponivel(text) TO anon, authenticated, service_role;
|
||||
|
||||
-- 2) auto_provision_free_tenant ----------------------------------------------
|
||||
-- Idempotente: se o usuário já tem tenant ativo, retorna esse. Senão lê o
|
||||
-- raw_user_meta_data, cria o tenant (slug escolhido OU auto), vira master,
|
||||
-- clona o schema e cria a subscription gratuita ativa (XOR conforme target).
|
||||
-- p_slug_override permite a tela /onboarding reescolher o slug se colidiu.
|
||||
CREATE OR REPLACE FUNCTION public.auto_provision_free_tenant(p_slug_override text DEFAULT NULL)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_meta jsonb;
|
||||
v_email text;
|
||||
v_kind text;
|
||||
v_acct text;
|
||||
v_name text;
|
||||
v_slug text;
|
||||
v_display text;
|
||||
v_tenant_id uuid;
|
||||
v_plan_key text;
|
||||
v_plan_id uuid;
|
||||
v_target text;
|
||||
v_existing uuid;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
-- idempotência: já tem tenant ativo?
|
||||
SELECT tm.tenant_id INTO v_existing
|
||||
FROM public.tenant_members tm
|
||||
WHERE tm.user_id = v_uid AND tm.status = 'active'
|
||||
ORDER BY tm.created_at ASC
|
||||
LIMIT 1;
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('status', 'exists', 'tenant_id', v_existing,
|
||||
'slug', (SELECT slug FROM public.tenants WHERE id = v_existing));
|
||||
END IF;
|
||||
|
||||
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
|
||||
FROM auth.users au WHERE au.id = v_uid;
|
||||
v_meta := COALESCE(v_meta, '{}'::jsonb);
|
||||
|
||||
-- kind: do metadata, default therapist (maioria). Valida contra os aceitos.
|
||||
v_kind := lower(coalesce(nullif(trim(v_meta->>'account_kind'), ''), 'therapist'));
|
||||
IF v_kind NOT IN ('therapist','clinic_coworking','clinic_reception','clinic_full') THEN
|
||||
v_kind := 'therapist';
|
||||
END IF;
|
||||
v_acct := CASE WHEN v_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
|
||||
v_display := nullif(trim(v_meta->>'display_name'), '');
|
||||
v_name := coalesce(
|
||||
nullif(trim(v_meta->>'tenant_name'), ''),
|
||||
v_display,
|
||||
split_part(coalesce(v_email, 'conta'), '@', 1),
|
||||
'Conta');
|
||||
|
||||
-- slug: override > metadata > NULL (trigger auto-gera). Valida disponibilidade.
|
||||
v_slug := lower(trim(coalesce(p_slug_override, v_meta->>'tenant_slug', '')));
|
||||
IF v_slug = '' THEN
|
||||
v_slug := NULL;
|
||||
ELSE
|
||||
IF NOT (public.slug_disponivel(v_slug)->>'ok')::boolean THEN
|
||||
RAISE EXCEPTION 'SLUG_TAKEN|%', v_slug USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- cria tenant (trg_tenants_slug respeita slug fornecido; gera se NULL)
|
||||
INSERT INTO public.tenants (name, kind, slug, created_at)
|
||||
VALUES (v_name, v_kind, v_slug, now())
|
||||
RETURNING id, slug INTO v_tenant_id, v_slug;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
UPDATE public.profiles
|
||||
SET account_type = v_acct,
|
||||
full_name = COALESCE(full_name, v_display)
|
||||
WHERE id = v_uid;
|
||||
|
||||
-- provisiona o schema físico + seed
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
-- subscription gratuita ativa (XOR: clinic→tenant_id; therapist→user_id)
|
||||
v_plan_key := CASE WHEN v_acct = 'therapist' THEN 'therapist_free' ELSE 'clinic_free' END;
|
||||
SELECT id, lower(target) INTO v_plan_id, v_target FROM public.plans WHERE key = v_plan_key;
|
||||
|
||||
INSERT INTO public.subscriptions (plan_id, plan_key, status, interval, source,
|
||||
tenant_id, user_id, started_at, activated_at, current_period_start)
|
||||
VALUES (v_plan_id, v_plan_key, 'active', 'month', 'auto_free',
|
||||
CASE WHEN v_target = 'clinic' THEN v_tenant_id ELSE NULL END,
|
||||
CASE WHEN v_target = 'clinic' THEN NULL ELSE v_uid END,
|
||||
now(), now(), now());
|
||||
|
||||
RETURN jsonb_build_object('status', 'provisioned', 'tenant_id', v_tenant_id,
|
||||
'slug', v_slug, 'kind', v_kind, 'plan_key', v_plan_key);
|
||||
END $$;
|
||||
ALTER FUNCTION public.auto_provision_free_tenant(text) OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.auto_provision_free_tenant(text) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.auto_provision_free_tenant(text) TO authenticated, service_role;
|
||||
|
||||
-- 3) processar_pos_signup ----------------------------------------------------
|
||||
-- Caminho PAGO: se o usuário escolheu um plano PRO no signup (metadata),
|
||||
-- registra a intenção (idempotente — uma por usuário+plano 'new'). O caminho
|
||||
-- gratuito não gera intenção. Sem tabela de aceite legal (pulado).
|
||||
CREATE OR REPLACE FUNCTION public.processar_pos_signup()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid := auth.uid();
|
||||
v_meta jsonb;
|
||||
v_email text;
|
||||
v_plan_key text;
|
||||
v_interval text;
|
||||
v_plan record;
|
||||
v_tenant uuid;
|
||||
v_amount int;
|
||||
BEGIN
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'sem sessão' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
SELECT au.raw_user_meta_data, au.email INTO v_meta, v_email
|
||||
FROM auth.users au WHERE au.id = v_uid;
|
||||
v_meta := COALESCE(v_meta, '{}'::jsonb);
|
||||
|
||||
v_plan_key := nullif(trim(v_meta->>'plan_key'), '');
|
||||
v_interval := lower(coalesce(nullif(trim(v_meta->>'billing_interval'), ''), 'month'));
|
||||
IF v_interval NOT IN ('month','year') THEN v_interval := 'month'; END IF;
|
||||
|
||||
-- sem plano escolhido OU plano gratuito → nada a fazer
|
||||
IF v_plan_key IS NULL OR v_plan_key LIKE '%\_free' THEN
|
||||
RETURN jsonb_build_object('status', 'no_intent');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_plan FROM public.plans WHERE key = v_plan_key AND is_active;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('status', 'plan_not_found', 'plan_key', v_plan_key);
|
||||
END IF;
|
||||
|
||||
-- idempotência: já existe intent 'new' desse usuário+plano?
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.subscription_intents
|
||||
WHERE created_by_user_id = v_uid AND plan_key = v_plan_key AND status = 'new'
|
||||
) THEN
|
||||
RETURN jsonb_build_object('status', 'intent_exists', 'plan_key', v_plan_key);
|
||||
END IF;
|
||||
|
||||
SELECT tm.tenant_id INTO v_tenant
|
||||
FROM public.tenant_members tm WHERE tm.user_id = v_uid AND tm.status = 'active'
|
||||
ORDER BY tm.created_at ASC LIMIT 1;
|
||||
|
||||
v_amount := CASE WHEN v_interval = 'year'
|
||||
THEN COALESCE(v_plan.price_cents, 0) * 12
|
||||
ELSE COALESCE(v_plan.price_cents, 0) END;
|
||||
|
||||
-- escreve direto na tabela real (a view subscription_intents tem INSTEAD OF
|
||||
-- trigger que não propaga user_id pra _tenant; o serviço do front também
|
||||
-- escreve nas tabelas reais por target).
|
||||
IF lower(v_plan.target) = 'clinic' THEN
|
||||
INSERT INTO public.subscription_intents_tenant
|
||||
(tenant_id, user_id, created_by_user_id, email, plan_id, plan_key,
|
||||
interval, amount_cents, currency, status, source)
|
||||
VALUES
|
||||
(v_tenant, v_uid, v_uid, v_email, v_plan.id, v_plan_key,
|
||||
v_interval, v_amount, 'BRL', 'new', 'signup');
|
||||
ELSE
|
||||
INSERT INTO public.subscription_intents_personal
|
||||
(user_id, created_by_user_id, email, plan_id, plan_key,
|
||||
interval, amount_cents, currency, status, source)
|
||||
VALUES
|
||||
(v_uid, v_uid, v_email, v_plan.id, v_plan_key,
|
||||
v_interval, v_amount, 'BRL', 'new', 'signup');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('status', 'intent_created', 'plan_key', v_plan_key, 'interval', v_interval);
|
||||
END $$;
|
||||
ALTER FUNCTION public.processar_pos_signup() OWNER TO supabase_admin;
|
||||
REVOKE ALL ON FUNCTION public.processar_pos_signup() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.processar_pos_signup() TO authenticated, service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,209 @@
|
||||
-- ==========================================================================
|
||||
-- Agencia PSI — Migracao: campo "Observacao" nativo no commitment Sessao
|
||||
-- ==========================================================================
|
||||
-- Antes: o commitment determinado 'Sessao' (is_native=true, native_key='session')
|
||||
-- nao tinha campos extras default. Os outros nativos (Leitura, Supervisao, Aula,
|
||||
-- Analise Pessoal) ja vinham com 'notes' (Observacao, textarea) — Sessao era
|
||||
-- a unica excecao.
|
||||
--
|
||||
-- O AgendaEventDialog tinha uma textarea hard-coded "Observacao" no form, fora
|
||||
-- do mecanismo de extra_fields. Pra padronizar (e pra que a Observacao da
|
||||
-- sessao siga o mesmo storage que os outros commitments: agenda_eventos.extra_fields),
|
||||
-- a textarea hardcoded foi removida do .vue e Sessao agora ganha 'notes' como
|
||||
-- campo extra default.
|
||||
--
|
||||
-- Esta migracao:
|
||||
-- 1. Adiciona 'notes' (Observacao, textarea) em TODOS os commitments Sessao
|
||||
-- existentes (idempotente — so insere se ainda nao houver).
|
||||
-- 2. Atualiza a funcao seed_determined_commitments pra que novos tenants criados
|
||||
-- daqui pra frente ja venham com 'notes' no Sessao por padrao.
|
||||
-- ==========================================================================
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Backfill — adiciona 'notes' nos commitments Sessao ja existentes
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
SELECT dc.tenant_id, dc.id, 'notes', 'Observação', 'textarea', false, 30
|
||||
FROM public.determined_commitments dc
|
||||
WHERE dc.is_native = true
|
||||
AND dc.native_key = 'session'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.determined_commitment_fields f
|
||||
WHERE f.commitment_id = dc.id AND f.key = 'notes'
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Forward-fix — funcao seed_determined_commitments inclui 'notes' em Sessao
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
-- Sessão (locked + sempre ativa)
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||
end if;
|
||||
|
||||
-- Supervisão
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||
end if;
|
||||
|
||||
-- Aula
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||
end if;
|
||||
|
||||
-- Análise pessoal
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
) then
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||
end if;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||
-- -------------------------------------------------------
|
||||
|
||||
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'book') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Supervisão
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'supervisor') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Aula
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'theme') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Análise
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'analyst') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- ============================================================================
|
||||
-- Drop agenda_excecoes (tabela órfã) + tipos relacionados
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- A tabela `public.agenda_excecoes` foi criada num design anterior pra
|
||||
-- representar "exceções no horário de trabalho" (almoço extra, atendimento
|
||||
-- fora do padrão, etc) mas nunca foi integrada à UI. Auditoria em
|
||||
-- 2026-05-13 confirmou 0 referências em src/. As funcionalidades equivalentes
|
||||
-- vivem em:
|
||||
-- - public.agenda_bloqueios — bloqueios (período, dia, horário, feriado)
|
||||
-- - public.agenda_configuracoes.pausas_semanais (jsonb) — pausas semanais
|
||||
-- - public.feriados — feriados nacionais/municipais
|
||||
--
|
||||
-- Esta migration:
|
||||
-- 1) Dropa o trigger tg_agenda_excecoes_updated_at
|
||||
-- 2) Dropa a tabela public.agenda_excecoes (CASCADE pra cair policies)
|
||||
-- 3) Dropa os enums tipo_excecao_agenda e status_excecao_agenda
|
||||
-- (verificados: usados APENAS por agenda_excecoes)
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Trigger (idempotente)
|
||||
DROP TRIGGER IF EXISTS tg_agenda_excecoes_updated_at ON public.agenda_excecoes;
|
||||
|
||||
-- 2. Tabela (CASCADE leva policies junto)
|
||||
DROP TABLE IF EXISTS public.agenda_excecoes CASCADE;
|
||||
|
||||
-- 3. Enums órfãos
|
||||
DROP TYPE IF EXISTS public.tipo_excecao_agenda;
|
||||
DROP TYPE IF EXISTS public.status_excecao_agenda;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ============================================================================
|
||||
-- Adiciona coluna payment_link em financial_records
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Quando a cobrança for paga via gateway externo (Asaas, Stripe, Mercado Pago)
|
||||
-- e o terapeuta escolher "Enviar link de pagamento" no AgendaEventDialog, o
|
||||
-- link de cobrança gerado pelo gateway é salvo aqui. UI da lista do Financeiro
|
||||
-- usa esse campo pra exibir ícone clicável (external-link).
|
||||
--
|
||||
-- Campo nullable: registros sem integração de gateway (PIX manual, dinheiro,
|
||||
-- depósito, cartão maquininha) ficam com payment_link = NULL.
|
||||
--
|
||||
-- Preparação pra Fase 7 (Pagamento como entidade separada) — quando a
|
||||
-- integração Asaas estiver completa, o webhook vai preencher esse campo.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.financial_records
|
||||
ADD COLUMN IF NOT EXISTS payment_link text;
|
||||
|
||||
COMMENT ON COLUMN public.financial_records.payment_link IS
|
||||
'URL externa de cobrança (Asaas/Stripe/etc) quando payment_method indica gateway. Null em pagamentos manuais.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- ============================================================================
|
||||
-- Adiciona coluna default_consume_on_miss em financial_exceptions
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Define o comportamento padrão pro saldo de pacote quando o status muda
|
||||
-- pra "faltou" ou "cancelado":
|
||||
-- true → desconta 1 sessão do pacote (sessions_used += 1) por padrão
|
||||
-- false → não consome saldo (sessão fica disponível pra remarcar)
|
||||
--
|
||||
-- O dialog de confirmação que aparece ao mudar status sugere essa decisão
|
||||
-- mas o terapeuta pode override caso a caso. Padrão começa false (mais
|
||||
-- benevolente ao paciente).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.financial_exceptions
|
||||
ADD COLUMN IF NOT EXISTS default_consume_on_miss boolean DEFAULT false NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.financial_exceptions.default_consume_on_miss IS
|
||||
'Default pro toggle "Descontar do saldo" no dialog de status change. false = não consome (paciente pode remarcar); true = consome (sessão perdida).';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================================
|
||||
-- Adiciona coluna charging_style em billing_contracts
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Identifica como o pacote foi cobrado na criação:
|
||||
-- 'upfront' → 1 financial_record total criado na hora; sessões só
|
||||
-- consomem saldo, não geram nova cobrança
|
||||
-- 'saldo' → sem financial_record na criação; cada sessão realizada
|
||||
-- gera 1 cobrança individual e incrementa sessions_used
|
||||
-- 'per_session'→ N financial_records já criados na materialização da série
|
||||
-- (chargeMode='per_session' do AgendaEventDialog)
|
||||
--
|
||||
-- Sem esse campo, o handler de status change não saberia distinguir entre
|
||||
-- "já tudo pago, só atualizar status" vs "criar cobrança nova".
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.billing_contracts
|
||||
ADD COLUMN IF NOT EXISTS charging_style text DEFAULT 'saldo';
|
||||
|
||||
-- Constraint pra restringir aos 3 valores válidos
|
||||
ALTER TABLE public.billing_contracts
|
||||
DROP CONSTRAINT IF EXISTS billing_contracts_charging_style_chk;
|
||||
ALTER TABLE public.billing_contracts
|
||||
ADD CONSTRAINT billing_contracts_charging_style_chk
|
||||
CHECK (charging_style = ANY (ARRAY['upfront'::text, 'saldo'::text, 'per_session'::text]));
|
||||
|
||||
COMMENT ON COLUMN public.billing_contracts.charging_style IS
|
||||
'Estilo de cobrança: upfront (1 record total no início), saldo (cobra por sessão realizada), per_session (N records já criados).';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,86 @@
|
||||
-- ============================================================================
|
||||
-- create_financial_record_for_session: idempotência ignora cancelled
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Bug: a função recusava criar um novo financial_record quando já existia
|
||||
-- um record cancelled pro mesmo agenda_evento_id, porque a checagem de
|
||||
-- idempotência só filtrava `deleted_at IS NULL` (e cancel preserva
|
||||
-- deleted_at = NULL pra manter auditoria).
|
||||
--
|
||||
-- Consequência: user cancelava cobrança sem querer e ficava preso — todo
|
||||
-- "Gerar cobrança" subsequente retornava o registro cancelado sem inserir
|
||||
-- nova linha (frontend recebia data, achava sucesso, mas DB ficava como
|
||||
-- estava).
|
||||
--
|
||||
-- Fix: adiciona `AND status != 'cancelled'` na checagem. Cancelled passa a
|
||||
-- ser tratado como "sem cobrança ativa" pra idempotência. Audit history
|
||||
-- continua preservado (rows cancelled permanecem na tabela).
|
||||
--
|
||||
-- Idempotente: CREATE OR REPLACE substitui a função existente.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_financial_record_for_session(
|
||||
p_tenant_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_patient_id uuid,
|
||||
p_agenda_evento_id uuid,
|
||||
p_amount numeric,
|
||||
p_due_date date
|
||||
) RETURNS SETOF public.financial_records
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_existing public.financial_records%ROWTYPE;
|
||||
v_new public.financial_records%ROWTYPE;
|
||||
BEGIN
|
||||
-- Idempotência: retorna o registro existente se já foi criado.
|
||||
-- Ignora cancelled (treat as "no active record") pra permitir regenerar
|
||||
-- cobrança após cancelamento.
|
||||
SELECT * INTO v_existing
|
||||
FROM public.financial_records
|
||||
WHERE agenda_evento_id = p_agenda_evento_id
|
||||
AND deleted_at IS NULL
|
||||
AND status != 'cancelled'
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
RETURN NEXT v_existing;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Cria o novo registro
|
||||
INSERT INTO public.financial_records (
|
||||
tenant_id,
|
||||
owner_id,
|
||||
patient_id,
|
||||
agenda_evento_id,
|
||||
amount,
|
||||
discount_amount,
|
||||
final_amount,
|
||||
status,
|
||||
due_date
|
||||
) VALUES (
|
||||
p_tenant_id,
|
||||
p_owner_id,
|
||||
p_patient_id,
|
||||
p_agenda_evento_id,
|
||||
p_amount,
|
||||
0,
|
||||
p_amount,
|
||||
'pending',
|
||||
p_due_date
|
||||
)
|
||||
RETURNING * INTO v_new;
|
||||
|
||||
-- Marca o evento da agenda como billed = true
|
||||
UPDATE public.agenda_eventos
|
||||
SET billed = TRUE
|
||||
WHERE id = p_agenda_evento_id;
|
||||
|
||||
RETURN NEXT v_new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,165 @@
|
||||
-- ============================================================================
|
||||
-- Cria tabelas do prontuário clínico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Núcleo do prontuário: notas clínicas (anamnese, evolução, plano), com
|
||||
-- versionamento (audit trail) e templates (SOAP/DAP/BIRP/livre).
|
||||
--
|
||||
-- Decisões (sessão de modelagem 2026-05-20):
|
||||
-- • Tabela única `clinical_notes` discriminada por `note_type` (não 1 tabela
|
||||
-- por tipo). Templates customizáveis exigem flexibilidade.
|
||||
-- • `content_text` (livre) + `content_structured` (jsonb) coexistem na mesma
|
||||
-- row — UI prioriza conforme template; busca/edit rápido sempre tem text.
|
||||
-- • Versionamento via snapshot completo (não diff) em `clinical_note_versions`
|
||||
-- — restore trivial e audit visualization friendly. Trigger de versionamento
|
||||
-- criado em migration separada.
|
||||
-- • Instrumentos de avaliação (GAD-7, PHQ-9, etc) ficam pra Fase 2.
|
||||
-- • RLS: owner-only (terapeuta responsável). Sem clinic-wide read — CFP exige
|
||||
-- sigilo entre profissionais. Policies em migration separada.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. clinical_notes — núcleo do prontuário
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.clinical_notes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
owner_id uuid NOT NULL, -- terapeuta responsável
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE RESTRICT,
|
||||
session_event_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
|
||||
note_type text NOT NULL,
|
||||
template_id uuid, -- FK adicionada após criar templates
|
||||
title text,
|
||||
content_text text,
|
||||
content_structured jsonb,
|
||||
pinned boolean DEFAULT false NOT NULL,
|
||||
is_draft boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid NOT NULL,
|
||||
updated_by uuid,
|
||||
deleted_at timestamp with time zone,
|
||||
deleted_by uuid,
|
||||
CONSTRAINT clinical_notes_note_type_check CHECK (note_type IN (
|
||||
'anamnese',
|
||||
'evolucao_sessao',
|
||||
'plano_terapeutico',
|
||||
'observacao_livre',
|
||||
'resumo_caso'
|
||||
)),
|
||||
CONSTRAINT clinical_notes_content_present_check CHECK (
|
||||
content_text IS NOT NULL OR content_structured IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.clinical_notes IS
|
||||
'Notas clínicas do prontuário (anamnese, evolução de sessão, plano, observações). Owner-only via RLS — CFP exige sigilo.';
|
||||
COMMENT ON COLUMN public.clinical_notes.session_event_id IS
|
||||
'Sessão associada (quando aplicável). Anamnese/plano/resumo podem ter NULL.';
|
||||
COMMENT ON COLUMN public.clinical_notes.content_text IS
|
||||
'Conteúdo em texto livre (sempre disponível pra busca/edit rápido).';
|
||||
COMMENT ON COLUMN public.clinical_notes.content_structured IS
|
||||
'Conteúdo em formato estruturado quando há template ativo (jsonb dos campos preenchidos).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_patient_recent
|
||||
ON public.clinical_notes (tenant_id, patient_id, created_at DESC)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_owner
|
||||
ON public.clinical_notes (owner_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_session
|
||||
ON public.clinical_notes (session_event_id)
|
||||
WHERE session_event_id IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_type
|
||||
ON public.clinical_notes (tenant_id, patient_id, note_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_notes_pinned
|
||||
ON public.clinical_notes (tenant_id, patient_id)
|
||||
WHERE pinned = true AND deleted_at IS NULL;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. clinical_note_versions — audit trail (snapshot completo)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.clinical_note_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
note_id uuid NOT NULL REFERENCES public.clinical_notes(id) ON DELETE CASCADE,
|
||||
tenant_id uuid NOT NULL,
|
||||
version_number integer NOT NULL,
|
||||
title text,
|
||||
content_text text,
|
||||
content_structured jsonb,
|
||||
change_reason text, -- 'criacao' | 'edicao' | livre
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_by uuid NOT NULL,
|
||||
CONSTRAINT clinical_note_versions_unique UNIQUE (note_id, version_number)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.clinical_note_versions IS
|
||||
'Snapshot completo de cada versão de clinical_notes. Criado via trigger AFTER INSERT OR UPDATE.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_recent
|
||||
ON public.clinical_note_versions (note_id, version_number DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_audit
|
||||
ON public.clinical_note_versions (created_by, created_at DESC);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. clinical_note_templates — templates SOAP/DAP/BIRP/anamnese padrão
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.clinical_note_templates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid, -- NULL = template global do sistema
|
||||
owner_id uuid, -- NULL = template do tenant inteiro
|
||||
key text NOT NULL, -- 'soap', 'dap', 'birp', 'anamnese_padrao', ...
|
||||
name text NOT NULL,
|
||||
note_type text NOT NULL,
|
||||
description text,
|
||||
structure jsonb NOT NULL, -- [{key, label, type, required, hint}]
|
||||
is_system boolean DEFAULT false NOT NULL,
|
||||
is_global boolean DEFAULT false NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT clinical_note_templates_note_type_check CHECK (note_type IN (
|
||||
'anamnese',
|
||||
'evolucao_sessao',
|
||||
'plano_terapeutico',
|
||||
'observacao_livre',
|
||||
'resumo_caso'
|
||||
)),
|
||||
CONSTRAINT clinical_note_templates_scope_check CHECK (
|
||||
-- Sistema: ambos NULL e is_system=true
|
||||
-- Tenant-wide: tenant_id presente, owner_id NULL
|
||||
-- Owner: ambos presentes
|
||||
(is_system = true AND tenant_id IS NULL AND owner_id IS NULL)
|
||||
OR (is_system = false AND tenant_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.clinical_note_templates IS
|
||||
'Templates de notas clínicas. Escopo: sistema (is_system, sem tenant), tenant-wide (tenant_id sem owner), owner (ambos).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_active
|
||||
ON public.clinical_note_templates (note_type)
|
||||
WHERE active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_tenant
|
||||
ON public.clinical_note_templates (tenant_id, note_type)
|
||||
WHERE tenant_id IS NOT NULL AND active = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_owner
|
||||
ON public.clinical_note_templates (owner_id, note_type)
|
||||
WHERE owner_id IS NOT NULL AND active = true;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 4. FK de clinical_notes.template_id (criada agora que templates existe)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_notes
|
||||
ADD CONSTRAINT clinical_notes_template_fkey
|
||||
FOREIGN KEY (template_id)
|
||||
REFERENCES public.clinical_note_templates(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,111 @@
|
||||
-- ============================================================================
|
||||
-- RLS policies do prontuário clínico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Padrão MAIS RESTRITIVO que agenda — CFP exige sigilo profissional entre
|
||||
-- terapeutas do mesmo tenant. Default: APENAS o owner (terapeuta responsável)
|
||||
-- lê e escreve. Sem clinic-wide read.
|
||||
--
|
||||
-- Compartilhamento com supervisor / outro terapeuta vai requerer policy
|
||||
-- específica baseada em tabela `clinical_note_shares` (Fase 2).
|
||||
--
|
||||
-- Templates seguem regra mais aberta:
|
||||
-- • Sistema (is_system): todos authenticated leem
|
||||
-- • Tenant-wide (tenant_id): membros do tenant leem; tenant_admin edita
|
||||
-- • Owner: só o owner lê/edita
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- clinical_notes — owner only
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_notes ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY clinical_notes_owner_select
|
||||
ON public.clinical_notes FOR SELECT TO authenticated
|
||||
USING (owner_id = auth.uid() AND deleted_at IS NULL);
|
||||
|
||||
CREATE POLICY clinical_notes_owner_insert
|
||||
ON public.clinical_notes FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
AND public.is_tenant_member(tenant_id)
|
||||
);
|
||||
|
||||
CREATE POLICY clinical_notes_owner_update
|
||||
ON public.clinical_notes FOR UPDATE TO authenticated
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- DELETE só por soft-delete (UPDATE deleted_at). Hard delete bloqueado em RLS.
|
||||
-- Backup/admin pode dropar via psql -U supabase_admin se preciso.
|
||||
CREATE POLICY clinical_notes_no_hard_delete
|
||||
ON public.clinical_notes FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- clinical_note_versions — read-only pelo owner da nota
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_note_versions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY clinical_note_versions_owner_select
|
||||
ON public.clinical_note_versions FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.clinical_notes cn
|
||||
WHERE cn.id = clinical_note_versions.note_id
|
||||
AND cn.owner_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT só via trigger (SECURITY DEFINER). Sem policy de UPDATE/DELETE —
|
||||
-- versões são imutáveis. Trigger usa role bypass.
|
||||
CREATE POLICY clinical_note_versions_no_write
|
||||
ON public.clinical_note_versions FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
CREATE POLICY clinical_note_versions_no_update
|
||||
ON public.clinical_note_versions FOR UPDATE TO authenticated
|
||||
USING (false);
|
||||
CREATE POLICY clinical_note_versions_no_delete
|
||||
ON public.clinical_note_versions FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- clinical_note_templates — escopo escalonado
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.clinical_note_templates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: sistema (qualquer authenticated) + tenant-wide (membros) + owner (próprio)
|
||||
CREATE POLICY clinical_note_templates_select
|
||||
ON public.clinical_note_templates FOR SELECT TO authenticated
|
||||
USING (
|
||||
active = true
|
||||
AND (
|
||||
is_system = true
|
||||
OR (tenant_id IS NOT NULL AND public.is_tenant_member(tenant_id))
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT/UPDATE/DELETE: só owner ou tenant_admin do tenant
|
||||
-- Templates do sistema (is_system) nunca alteráveis via UI — só via seed/migration.
|
||||
CREATE POLICY clinical_note_templates_owner_write
|
||||
ON public.clinical_note_templates TO authenticated
|
||||
USING (
|
||||
is_system = false
|
||||
AND (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
is_system = false
|
||||
AND (
|
||||
owner_id = auth.uid()
|
||||
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
|
||||
)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,117 @@
|
||||
-- ============================================================================
|
||||
-- Trigger de versionamento automático de clinical_notes
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- A cada INSERT ou UPDATE relevante em clinical_notes, cria snapshot completo
|
||||
-- em clinical_note_versions. Função é SECURITY DEFINER pra bypassar a RLS
|
||||
-- (que bloqueia INSERT direto em clinical_note_versions).
|
||||
--
|
||||
-- Versionamento dispara em:
|
||||
-- • INSERT — registra criação (version_number = 1)
|
||||
-- • UPDATE em content_text, content_structured ou title — registra edição
|
||||
--
|
||||
-- Mudanças em pinned/is_draft NÃO disparam versionamento (mudança de UI/state,
|
||||
-- não de conteúdo).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
next_version integer;
|
||||
reason text;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||
INTO next_version
|
||||
FROM public.clinical_note_versions
|
||||
WHERE note_id = NEW.id;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
reason := 'criacao';
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
|
||||
reason := 'soft_delete';
|
||||
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN
|
||||
reason := 'restore';
|
||||
ELSE
|
||||
reason := 'edicao';
|
||||
END IF;
|
||||
ELSE
|
||||
reason := 'desconhecido';
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.clinical_note_versions (
|
||||
note_id,
|
||||
tenant_id,
|
||||
version_number,
|
||||
title,
|
||||
content_text,
|
||||
content_structured,
|
||||
change_reason,
|
||||
created_at,
|
||||
created_by
|
||||
) VALUES (
|
||||
NEW.id,
|
||||
NEW.tenant_id,
|
||||
next_version,
|
||||
NEW.title,
|
||||
NEW.content_text,
|
||||
NEW.content_structured,
|
||||
reason,
|
||||
now(),
|
||||
COALESCE(NEW.updated_by, NEW.created_by)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.fn_clinical_note_version() IS
|
||||
'Snapshot completo de clinical_notes a cada INSERT/UPDATE relevante. SECURITY DEFINER bypassa RLS pra escrever em clinical_note_versions (que bloqueia INSERT direto).';
|
||||
|
||||
CREATE TRIGGER trg_clinical_notes_version_insert
|
||||
AFTER INSERT ON public.clinical_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_clinical_note_version();
|
||||
|
||||
CREATE TRIGGER trg_clinical_notes_version_update
|
||||
AFTER UPDATE OF content_text, content_structured, title, deleted_at
|
||||
ON public.clinical_notes
|
||||
FOR EACH ROW
|
||||
WHEN (
|
||||
OLD.content_text IS DISTINCT FROM NEW.content_text
|
||||
OR OLD.content_structured IS DISTINCT FROM NEW.content_structured
|
||||
OR OLD.title IS DISTINCT FROM NEW.title
|
||||
OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at
|
||||
)
|
||||
EXECUTE FUNCTION public.fn_clinical_note_version();
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- Trigger para updated_at automático
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.fn_clinical_notes_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_clinical_notes_updated_at
|
||||
BEFORE UPDATE ON public.clinical_notes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_clinical_note_templates_updated_at
|
||||
BEFORE UPDATE ON public.clinical_note_templates
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- ============================================================================
|
||||
-- Liga documents a clinical_notes (preenche FK órfã)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- A coluna `documents.session_note_id` existia desde antes apontando pra uma
|
||||
-- tabela `session_notes` que nunca foi criada. Agora que `clinical_notes`
|
||||
-- existe e abrange anamnese/evolução/plano (não só sessão), renomeia pra
|
||||
-- `clinical_note_id` e adiciona FK constraint.
|
||||
--
|
||||
-- PRÉ-CHECK: a query abaixo deve retornar 0 antes de rodar esta migration.
|
||||
-- SELECT count(*) FROM public.documents WHERE session_note_id IS NOT NULL;
|
||||
-- Se houver dados, eles são órfãos (referenciam tabela inexistente) — limpar
|
||||
-- antes de adicionar a FK constraint, ou ela falha.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Limpa eventuais órfãos (FK nunca foi enforced, mas valor pode ter sido
|
||||
-- setado por código no front antes da migration). Defesa em profundidade.
|
||||
UPDATE public.documents
|
||||
SET session_note_id = NULL
|
||||
WHERE session_note_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.clinical_notes cn
|
||||
WHERE cn.id = documents.session_note_id
|
||||
);
|
||||
|
||||
-- 2. Rename
|
||||
ALTER TABLE public.documents
|
||||
RENAME COLUMN session_note_id TO clinical_note_id;
|
||||
|
||||
-- 3. FK constraint
|
||||
ALTER TABLE public.documents
|
||||
ADD CONSTRAINT documents_clinical_note_fkey
|
||||
FOREIGN KEY (clinical_note_id)
|
||||
REFERENCES public.clinical_notes(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
-- 4. Index pra reverse lookup (documentos de uma nota)
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_clinical_note
|
||||
ON public.documents (clinical_note_id)
|
||||
WHERE clinical_note_id IS NOT NULL AND deleted_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN public.documents.clinical_note_id IS
|
||||
'Vínculo opcional a uma nota clínica (anexar PDF a anamnese/evolução). Renomeado de session_note_id em 2026-05-20.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,95 @@
|
||||
-- ============================================================================
|
||||
-- RPC accept_tenant_invite — destrava o fluxo de aceitar convite
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Recebe o token UUID do invite. Em uma transação (SECURITY DEFINER):
|
||||
-- 1. Lê invite ATIVO (não accepted, não revoked, não expired)
|
||||
-- 2. INSERT em tenant_members com role do invite + user_id = auth.uid()
|
||||
-- 3. UPDATE invite com accepted_at + accepted_by
|
||||
--
|
||||
-- Retorna jsonb { ok, tenant_id, role } em sucesso ou throw com mensagem PT-BR.
|
||||
--
|
||||
-- Chamada pelo features/tenantship/services/tenantInvitesRepository.acceptInvite().
|
||||
-- Stub anterior tava jogando erro PT-BR explicando isso. Agora funciona.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.accept_tenant_invite(p_token uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
v_invite record;
|
||||
v_existing_member record;
|
||||
BEGIN
|
||||
-- Quem está aceitando — auth.uid() pega do JWT
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Sessão inválida (sem user autenticado).';
|
||||
END IF;
|
||||
|
||||
-- 1. Lê invite ativo. Lock via FOR UPDATE pra evitar race.
|
||||
SELECT id, tenant_id, email, role, accepted_at, revoked_at, expires_at
|
||||
INTO v_invite
|
||||
FROM public.tenant_invites
|
||||
WHERE token = p_token
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Convite não encontrado. Verifique o link.';
|
||||
END IF;
|
||||
|
||||
IF v_invite.revoked_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Convite revogado pelo administrador.';
|
||||
END IF;
|
||||
|
||||
IF v_invite.accepted_at IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Convite já foi aceito anteriormente.';
|
||||
END IF;
|
||||
|
||||
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
|
||||
RAISE EXCEPTION 'Convite expirado. Peça um novo ao administrador.';
|
||||
END IF;
|
||||
|
||||
-- 2. Idempotência: se já é membro do tenant, só marca invite aceito.
|
||||
SELECT id, role, status
|
||||
INTO v_existing_member
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = v_invite.tenant_id
|
||||
AND user_id = v_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF v_existing_member.id IS NULL THEN
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status)
|
||||
VALUES (v_invite.tenant_id, v_uid, v_invite.role, 'active');
|
||||
ELSIF v_existing_member.status <> 'active' THEN
|
||||
UPDATE public.tenant_members
|
||||
SET status = 'active', role = v_invite.role
|
||||
WHERE id = v_existing_member.id;
|
||||
END IF;
|
||||
-- (se já está ativo, deixa como tá — convite aceito não rebaixa)
|
||||
|
||||
-- 3. Marca invite como aceito
|
||||
UPDATE public.tenant_invites
|
||||
SET accepted_at = now(), accepted_by = v_uid
|
||||
WHERE id = v_invite.id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'tenant_id', v_invite.tenant_id,
|
||||
'role', v_invite.role
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.accept_tenant_invite(uuid) IS
|
||||
'Aceita convite de membership. SECURITY DEFINER pra criar tenant_members em nome do user logado. Lock FOR UPDATE no invite previne race condition.';
|
||||
|
||||
-- Permite que qualquer authenticated chame (precisa do token UUID válido pra entrar).
|
||||
REVOKE ALL ON FUNCTION public.accept_tenant_invite(uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.accept_tenant_invite(uuid) TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,138 @@
|
||||
-- ============================================================================
|
||||
-- Asaas Gateway — Tier 1 (cobrança de paciente) — schema foundation
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Cria 3 tabelas novas + adiciona 4 colunas em payment_settings.
|
||||
-- Schema preparado pra Fase 3 do ROADMAP (gateway de pagamento).
|
||||
--
|
||||
-- ⚠️ Não habilita o gateway sozinho. Requer:
|
||||
-- - Edge Functions deployadas
|
||||
-- - API keys configuradas em payment_settings
|
||||
-- - Webhook setado no dashboard Asaas
|
||||
--
|
||||
-- Ver: development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. asaas_customers — mapping patient ↔ Asaas customer
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.asaas_customers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
|
||||
asaas_customer_id text NOT NULL,
|
||||
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
|
||||
-- dados cacheados (sincronizados quando atualizar patient)
|
||||
name text NOT NULL,
|
||||
email text,
|
||||
cpf_cnpj text,
|
||||
phone text,
|
||||
address jsonb,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT asaas_customers_unique_per_env UNIQUE (tenant_id, patient_id, environment)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.asaas_customers IS
|
||||
'Mapping de pacientes para Asaas customers (1:1 por environment). Cacheado pra evitar re-criação a cada cobrança.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asaas_customers_lookup
|
||||
ON public.asaas_customers (tenant_id, patient_id)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. asaas_payments — 1 row por cobrança gerada
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.asaas_payments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
financial_record_id uuid NOT NULL REFERENCES public.financial_records(id) ON DELETE CASCADE,
|
||||
asaas_customer_id uuid REFERENCES public.asaas_customers(id),
|
||||
asaas_payment_id text NOT NULL,
|
||||
asaas_invoice_id text,
|
||||
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
|
||||
billing_type text NOT NULL CHECK (billing_type IN ('PIX', 'BOLETO', 'CREDIT_CARD', 'UNDEFINED')),
|
||||
status text NOT NULL,
|
||||
value numeric(10, 2) NOT NULL,
|
||||
net_value numeric(10, 2),
|
||||
due_date date NOT NULL,
|
||||
payment_date timestamptz,
|
||||
invoice_url text,
|
||||
payment_url text,
|
||||
bank_slip_url text,
|
||||
pix_qr_code text,
|
||||
pix_copy_paste text,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL,
|
||||
cancelled_at timestamptz,
|
||||
CONSTRAINT asaas_payments_unique_per_env UNIQUE (asaas_payment_id, environment)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.asaas_payments IS
|
||||
'Cobranças geradas no Asaas. Status raw do Asaas; mapeamento pra financial_records.status acontece no JS.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asaas_payments_record
|
||||
ON public.asaas_payments (financial_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_asaas_payments_lookup
|
||||
ON public.asaas_payments (tenant_id, status, due_date)
|
||||
WHERE cancelled_at IS NULL;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. asaas_webhook_events — idempotência + audit
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.asaas_webhook_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id text,
|
||||
event_type text NOT NULL,
|
||||
asaas_payment_id text,
|
||||
payload jsonb NOT NULL,
|
||||
processed_at timestamptz,
|
||||
processing_error text,
|
||||
received_at timestamptz DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.asaas_webhook_events IS
|
||||
'Audit + idempotência de webhooks Asaas. event_id usado pra dedupe (Asaas faz retry).';
|
||||
|
||||
-- event_id UNIQUE quando preenchido (Asaas nem sempre manda)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_asaas_webhook_events_event_id
|
||||
ON public.asaas_webhook_events (event_id)
|
||||
WHERE event_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_payment
|
||||
ON public.asaas_webhook_events (asaas_payment_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_unprocessed
|
||||
ON public.asaas_webhook_events (received_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 4. payment_settings — colunas pra config Asaas por tenant
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- API keys plaintext nesta migration. Em produção, mover pra pgsodium/Vault.
|
||||
|
||||
ALTER TABLE public.payment_settings
|
||||
ADD COLUMN IF NOT EXISTS asaas_api_key_sandbox text,
|
||||
ADD COLUMN IF NOT EXISTS asaas_api_key_prod text,
|
||||
ADD COLUMN IF NOT EXISTS asaas_environment text DEFAULT 'sandbox'
|
||||
CHECK (asaas_environment IN ('sandbox', 'prod')),
|
||||
ADD COLUMN IF NOT EXISTS asaas_webhook_token text,
|
||||
ADD COLUMN IF NOT EXISTS asaas_enabled boolean DEFAULT false NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN public.payment_settings.asaas_api_key_sandbox IS
|
||||
'API key Asaas SANDBOX. PLAINTEXT por enquanto — migrar pra Vault em prod.';
|
||||
COMMENT ON COLUMN public.payment_settings.asaas_api_key_prod IS
|
||||
'API key Asaas PRODUÇÃO. PLAINTEXT por enquanto — migrar pra Vault em prod.';
|
||||
COMMENT ON COLUMN public.payment_settings.asaas_environment IS
|
||||
'Qual key usar: sandbox (testes) ou prod (real). Default sandbox por segurança.';
|
||||
COMMENT ON COLUMN public.payment_settings.asaas_webhook_token IS
|
||||
'Token customizado pra webhook receiver validar. Setar mesmo valor no dashboard Asaas.';
|
||||
COMMENT ON COLUMN public.payment_settings.asaas_enabled IS
|
||||
'Flag que controla se gateway Asaas está habilitado pro tenant. Default false (opt-in).';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- ============================================================================
|
||||
-- Asaas Gateway — RLS policies
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Owner-scoped: cada terapeuta vê só os customers/payments do seu tenant.
|
||||
-- INSERT/UPDATE bloqueado client-side — só Edge Functions (service role)
|
||||
-- podem escrever. Browser só lê (pra exibir QR code, status, etc).
|
||||
--
|
||||
-- API keys em payment_settings: já tem RLS (não duplica).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- asaas_customers
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.asaas_customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY asaas_customers_member_select
|
||||
ON public.asaas_customers FOR SELECT TO authenticated
|
||||
USING (public.is_tenant_member(tenant_id));
|
||||
|
||||
-- INSERT/UPDATE/DELETE bloqueados — Edge Functions usam service_role que bypassa RLS
|
||||
CREATE POLICY asaas_customers_no_client_write
|
||||
ON public.asaas_customers FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
CREATE POLICY asaas_customers_no_client_update
|
||||
ON public.asaas_customers FOR UPDATE TO authenticated
|
||||
USING (false);
|
||||
CREATE POLICY asaas_customers_no_client_delete
|
||||
ON public.asaas_customers FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- asaas_payments
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.asaas_payments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY asaas_payments_member_select
|
||||
ON public.asaas_payments FOR SELECT TO authenticated
|
||||
USING (public.is_tenant_member(tenant_id));
|
||||
|
||||
CREATE POLICY asaas_payments_no_client_write
|
||||
ON public.asaas_payments FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
CREATE POLICY asaas_payments_no_client_update
|
||||
ON public.asaas_payments FOR UPDATE TO authenticated
|
||||
USING (false);
|
||||
CREATE POLICY asaas_payments_no_client_delete
|
||||
ON public.asaas_payments FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- asaas_webhook_events
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- Audit table — saas_admin lê pra debug. Members não veem.
|
||||
ALTER TABLE public.asaas_webhook_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY asaas_webhook_events_saas_admin_select
|
||||
ON public.asaas_webhook_events FOR SELECT TO authenticated
|
||||
USING (public.is_saas_admin());
|
||||
|
||||
CREATE POLICY asaas_webhook_events_no_client_write
|
||||
ON public.asaas_webhook_events FOR INSERT TO authenticated
|
||||
WITH CHECK (false);
|
||||
CREATE POLICY asaas_webhook_events_no_update
|
||||
ON public.asaas_webhook_events FOR UPDATE TO authenticated
|
||||
USING (false);
|
||||
CREATE POLICY asaas_webhook_events_no_delete
|
||||
ON public.asaas_webhook_events FOR DELETE TO authenticated
|
||||
USING (false);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP — Tipo de registro profissional (ROADMAP item #5)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Adiciona campos de registro profissional ao perfil. Necessário pra emissão
|
||||
-- de recibos/laudos válidos (CFP exige tipo, número e UF do conselho).
|
||||
--
|
||||
-- Conselhos comuns no Brasil:
|
||||
-- CRP — Psicólogo
|
||||
-- CRM — Médico
|
||||
-- CRFa — Fonoaudiólogo
|
||||
-- CREFITO — Fisioterapeuta / Terapeuta Ocupacional
|
||||
-- CRESS — Assistente Social
|
||||
-- CRN — Nutricionista
|
||||
-- RMS — Residência Multiprofissional (Saúde)
|
||||
-- outro — Catch-all (campo livre na UI)
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_type text,
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_number text,
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_uf text;
|
||||
|
||||
-- CHECK não pode ser ADD IF NOT EXISTS — guard com DO block
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'profiles_registration_type_check'
|
||||
) THEN
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_registration_type_check CHECK (
|
||||
professional_registration_type IS NULL
|
||||
OR professional_registration_type = ANY (ARRAY[
|
||||
'CRP',
|
||||
'CRM',
|
||||
'CRFa',
|
||||
'CREFITO',
|
||||
'CRESS',
|
||||
'CRN',
|
||||
'RMS',
|
||||
'outro'
|
||||
])
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- UF check (regex pra 2 chars uppercase ou NULL)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'profiles_registration_uf_check'
|
||||
) THEN
|
||||
ALTER TABLE public.profiles
|
||||
ADD CONSTRAINT profiles_registration_uf_check CHECK (
|
||||
professional_registration_uf IS NULL
|
||||
OR professional_registration_uf ~ '^[A-Z]{2}$'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_type IS
|
||||
'Tipo de registro profissional. Obrigatório pra emitir recibos/laudos. ROADMAP item #5.';
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_number IS
|
||||
'Número do registro (ex: 06/12345 ou 123456). Formato livre — UI ajuda com mask se relevante.';
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_uf IS
|
||||
'UF do conselho (2 chars uppercase). Alguns conselhos exigem regionalização (CRP 06/SP, CRP 03/BA).';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,79 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP — Especialidades do profissional (ROADMAP item #9)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Catálogo de especialidades/abordagens + join many-to-many com profiles.
|
||||
-- Profissional pode ter múltiplas especialidades (clínica + jurídica, etc).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.specialties (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key text UNIQUE NOT NULL,
|
||||
name text NOT NULL,
|
||||
category text,
|
||||
is_system boolean DEFAULT false NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.specialties IS
|
||||
'Catálogo global de especialidades/abordagens psicológicas (ROADMAP item #9). is_system=true pra entries seedadas.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_specialties_active ON public.specialties (active, category, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.profile_specialties (
|
||||
profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
specialty_id uuid NOT NULL REFERENCES public.specialties(id) ON DELETE RESTRICT,
|
||||
other_label text,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY (profile_id, specialty_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.profile_specialties IS
|
||||
'M:N entre profile e specialty. other_label preenchido só quando specialty.key=outra (custom user-defined).';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_specialties_profile ON public.profile_specialties (profile_id);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- RLS
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.specialties ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.profile_specialties ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- specialties: read-only pra todos authenticated (catálogo público); só saas_admin escreve
|
||||
CREATE POLICY specialties_authenticated_read
|
||||
ON public.specialties FOR SELECT TO authenticated
|
||||
USING (active = true);
|
||||
|
||||
CREATE POLICY specialties_saas_admin_write
|
||||
ON public.specialties TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- profile_specialties: cada user gerencia o próprio
|
||||
CREATE POLICY profile_specialties_owner_select
|
||||
ON public.profile_specialties FOR SELECT TO authenticated
|
||||
USING (profile_id = auth.uid());
|
||||
|
||||
CREATE POLICY profile_specialties_owner_insert
|
||||
ON public.profile_specialties FOR INSERT TO authenticated
|
||||
WITH CHECK (profile_id = auth.uid());
|
||||
|
||||
CREATE POLICY profile_specialties_owner_delete
|
||||
ON public.profile_specialties FOR DELETE TO authenticated
|
||||
USING (profile_id = auth.uid());
|
||||
|
||||
-- Tenant_admin pode VER specialties dos membros (pra cards públicos / perfil clínica)
|
||||
CREATE POLICY profile_specialties_tenant_admin_read
|
||||
ON public.profile_specialties FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.tenant_members tm
|
||||
WHERE tm.user_id = profile_specialties.profile_id
|
||||
AND public.is_tenant_admin(tm.tenant_id)
|
||||
)
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #6 — Tipos de consent form (LGPD + Gravação)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Estende o CHECK constraint de document_templates.tipo para acomodar dois
|
||||
-- novos tipos de consent form exigidos pela LGPD e pela prática clínica:
|
||||
-- • termo_lgpd — Consentimento de tratamento de dados pessoais
|
||||
-- • autorizacao_gravacao — Autorização de gravação de sessão (áudio/vídeo)
|
||||
--
|
||||
-- ROADMAP item #1.2 #6 (Biblioteca de consent forms editáveis).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.document_templates
|
||||
DROP CONSTRAINT IF EXISTS dt_tipo_check;
|
||||
|
||||
ALTER TABLE public.document_templates
|
||||
ADD 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',
|
||||
'termo_lgpd',
|
||||
'autorizacao_gravacao',
|
||||
'outro'
|
||||
])
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN public.document_templates.tipo IS
|
||||
'Tipo do template. Inclui consent forms (tcle, tcle_online, autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao).';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,251 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #7 — RPCs de assinatura eletrônica
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Cria 2 RPCs que registram assinatura capturando IP server-side (anti-spoof)
|
||||
-- via inet_client_addr() e user-agent via request headers do Supabase.
|
||||
--
|
||||
-- • sign_document_by_signature_id — paciente logado assina via portal
|
||||
-- • sign_document_by_token — terceiro assina via share link público
|
||||
--
|
||||
-- ROADMAP item #1.2 #7 (Assinatura eletrônica pelo paciente no portal,
|
||||
-- simples, com IP+timestamp). Não usa ICP-Brasil — é assinatura simples
|
||||
-- com audit trail (IP, UA, timestamp, hash SHA-256 do documento).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. sign_document_by_signature_id
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Para signatários LOGADOS no portal/sistema. SECURITY INVOKER — a RLS de
|
||||
-- document_signatures continua aplicando (signatario_id = auth.uid() ou
|
||||
-- tenant_members). RPC só serve pra centralizar captura de IP + UA + hash.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.sign_document_by_signature_id(
|
||||
p_signature_id uuid,
|
||||
p_hash_documento text DEFAULT NULL
|
||||
) RETURNS public.document_signatures
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_row public.document_signatures;
|
||||
v_ip inet;
|
||||
v_ua text;
|
||||
BEGIN
|
||||
IF p_signature_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- Captura IP e UA do request (best-effort — pode vir NULL em alguns ambientes)
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN
|
||||
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_ua := NULL;
|
||||
END;
|
||||
|
||||
UPDATE public.document_signatures
|
||||
SET status = 'assinado',
|
||||
ip = v_ip,
|
||||
user_agent = v_ua,
|
||||
assinado_em = now(),
|
||||
hash_documento = COALESCE(p_hash_documento, hash_documento),
|
||||
atualizado_em = now()
|
||||
WHERE id = p_signature_id
|
||||
AND status IN ('pendente', 'enviado')
|
||||
RETURNING * INTO v_row;
|
||||
|
||||
IF v_row.id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura não encontrada ou já processada' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
RETURN v_row;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.sign_document_by_signature_id(uuid, text) IS
|
||||
'Assinatura via portal logado. Captura IP/UA server-side. RLS aplica (SECURITY INVOKER).';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.sign_document_by_signature_id(uuid, text) TO authenticated;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. sign_document_by_token
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Para signatários NÃO LOGADOS via share link público. SECURITY DEFINER —
|
||||
-- bypassa RLS. Valida o share_link (token, ativo, expira_em, usos_max),
|
||||
-- localiza o signatário PENDENTE associado ao documento (signatario_email
|
||||
-- opcional p/ desambiguar quando há múltiplos), assina, incrementa usos.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.sign_document_by_token(
|
||||
p_token text,
|
||||
p_signature_id uuid DEFAULT NULL,
|
||||
p_hash_documento text DEFAULT NULL
|
||||
) RETURNS public.document_signatures
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_link public.document_share_links;
|
||||
v_sig public.document_signatures;
|
||||
v_ip inet;
|
||||
v_ua text;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- Valida share_link
|
||||
SELECT * INTO v_link
|
||||
FROM public.document_share_links
|
||||
WHERE token = p_token
|
||||
AND ativo = true
|
||||
AND expira_em > now()
|
||||
AND usos < usos_max
|
||||
LIMIT 1;
|
||||
|
||||
IF v_link.id IS NULL THEN
|
||||
RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- Localiza a signature pendente do documento. Se p_signature_id veio,
|
||||
-- é desambiguação (multi-signatário); senão pega a primeira pendente
|
||||
-- por ordem.
|
||||
IF p_signature_id IS NOT NULL THEN
|
||||
SELECT * INTO v_sig
|
||||
FROM public.document_signatures
|
||||
WHERE id = p_signature_id
|
||||
AND documento_id = v_link.documento_id
|
||||
AND status IN ('pendente', 'enviado')
|
||||
LIMIT 1;
|
||||
ELSE
|
||||
SELECT * INTO v_sig
|
||||
FROM public.document_signatures
|
||||
WHERE documento_id = v_link.documento_id
|
||||
AND status IN ('pendente', 'enviado')
|
||||
ORDER BY ordem ASC, criado_em ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_sig.id IS NULL THEN
|
||||
RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- Captura IP/UA
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN
|
||||
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_ua := NULL;
|
||||
END;
|
||||
|
||||
-- Assina
|
||||
UPDATE public.document_signatures
|
||||
SET status = 'assinado',
|
||||
ip = v_ip,
|
||||
user_agent = v_ua,
|
||||
assinado_em = now(),
|
||||
hash_documento = COALESCE(p_hash_documento, hash_documento),
|
||||
atualizado_em = now()
|
||||
WHERE id = v_sig.id
|
||||
RETURNING * INTO v_sig;
|
||||
|
||||
-- Incrementa contador de usos do share_link
|
||||
UPDATE public.document_share_links
|
||||
SET usos = usos + 1
|
||||
WHERE id = v_link.id;
|
||||
|
||||
RETURN v_sig;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.sign_document_by_token(text, uuid, text) IS
|
||||
'Assinatura via share link público. SECURITY DEFINER — valida token, captura IP/UA, incrementa usos. p_signature_id é opcional pra desambiguar multi-signatário.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.sign_document_by_token(text, uuid, text) TO anon, authenticated;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. get_signable_document_by_token
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- View helper que retorna info do documento + signatários pendentes via token,
|
||||
-- sem assinar. Permite a página pública renderizar antes do click.
|
||||
-- SECURITY DEFINER porque share_link tem RLS pública mas documents+signatures
|
||||
-- têm RLS por owner/tenant.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(
|
||||
p_token text
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_link public.document_share_links;
|
||||
v_doc public.documents;
|
||||
v_sigs jsonb;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_link
|
||||
FROM public.document_share_links
|
||||
WHERE token = p_token
|
||||
AND ativo = true
|
||||
AND expira_em > now()
|
||||
AND usos < usos_max
|
||||
LIMIT 1;
|
||||
|
||||
IF v_link.id IS NULL THEN
|
||||
RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_doc
|
||||
FROM public.documents
|
||||
WHERE id = v_link.documento_id
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF v_doc.id IS NULL THEN
|
||||
RETURN jsonb_build_object('valid', false, 'error', 'document_not_found');
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', s.id,
|
||||
'signatario_tipo', s.signatario_tipo,
|
||||
'signatario_nome', s.signatario_nome,
|
||||
'signatario_email', s.signatario_email,
|
||||
'ordem', s.ordem,
|
||||
'status', s.status,
|
||||
'assinado_em', s.assinado_em
|
||||
) ORDER BY s.ordem
|
||||
) INTO v_sigs
|
||||
FROM public.document_signatures s
|
||||
WHERE s.documento_id = v_doc.id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'valid', true,
|
||||
'document', jsonb_build_object(
|
||||
'id', v_doc.id,
|
||||
'nome_original', v_doc.nome_original,
|
||||
'mime_type', v_doc.mime_type,
|
||||
'tamanho_bytes', v_doc.tamanho_bytes,
|
||||
'bucket_path', v_doc.bucket_path,
|
||||
'storage_bucket', v_doc.storage_bucket,
|
||||
'tipo_documento', v_doc.tipo_documento
|
||||
),
|
||||
'signatures', COALESCE(v_sigs, '[]'::jsonb),
|
||||
'expira_em', v_link.expira_em,
|
||||
'usos_restantes', v_link.usos_max - v_link.usos
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.get_signable_document_by_token(text) IS
|
||||
'Retorna documento + signatários pendentes via token. Usado pela página pública antes de assinar.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_signable_document_by_token(text) TO anon, authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #7 — RPC list_my_signatures (portal do paciente)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Retorna as solicitações de assinatura do paciente logado (auth.uid()
|
||||
-- associado a patients.user_id). SECURITY DEFINER pra bypassar a RLS de
|
||||
-- document_signatures (que hoje só libera pra tenant_members).
|
||||
--
|
||||
-- Cada item já vem com o share_link.token associado, pra que o portal
|
||||
-- aponte direto pra /shared/document/:token onde o usuário vai assinar.
|
||||
-- O link público é gerado quando o terapeuta solicita a assinatura.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.list_my_signatures(
|
||||
p_status text[] DEFAULT NULL
|
||||
) RETURNS TABLE (
|
||||
signature_id uuid,
|
||||
documento_id uuid,
|
||||
tenant_id uuid,
|
||||
signatario_tipo text,
|
||||
status text,
|
||||
ordem smallint,
|
||||
assinado_em timestamptz,
|
||||
criado_em timestamptz,
|
||||
-- Documento
|
||||
nome_original text,
|
||||
tipo_documento text,
|
||||
mime_type text,
|
||||
-- Share link (primeiro válido encontrado pro doc)
|
||||
share_token text,
|
||||
share_expira_em timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Sessão inválida' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
s.id AS signature_id,
|
||||
s.documento_id AS documento_id,
|
||||
s.tenant_id AS tenant_id,
|
||||
s.signatario_tipo AS signatario_tipo,
|
||||
s.status AS status,
|
||||
s.ordem AS ordem,
|
||||
s.assinado_em AS assinado_em,
|
||||
s.criado_em AS criado_em,
|
||||
d.nome_original AS nome_original,
|
||||
d.tipo_documento AS tipo_documento,
|
||||
d.mime_type AS mime_type,
|
||||
sl.token AS share_token,
|
||||
sl.expira_em AS share_expira_em
|
||||
FROM public.document_signatures s
|
||||
JOIN public.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT token, expira_em
|
||||
FROM public.document_share_links
|
||||
WHERE documento_id = d.id
|
||||
AND ativo = true
|
||||
AND expira_em > now()
|
||||
AND usos < usos_max
|
||||
ORDER BY criado_em DESC
|
||||
LIMIT 1
|
||||
) sl ON true
|
||||
WHERE (
|
||||
-- signatario_id direto (quando registrado)
|
||||
s.signatario_id = v_uid
|
||||
OR
|
||||
-- Fallback: paciente pelo email (quando signatario_id veio NULL)
|
||||
s.signatario_email = (SELECT email FROM auth.users WHERE id = v_uid)
|
||||
OR
|
||||
-- Fallback: paciente pelo patient_id (documents.patient_id -> patients.user_id)
|
||||
d.patient_id IN (SELECT p.id FROM public.patients p WHERE p.user_id = v_uid)
|
||||
)
|
||||
AND (p_status IS NULL OR s.status = ANY (p_status))
|
||||
ORDER BY
|
||||
CASE s.status
|
||||
WHEN 'pendente' THEN 0
|
||||
WHEN 'enviado' THEN 1
|
||||
WHEN 'assinado' THEN 2
|
||||
WHEN 'recusado' THEN 3
|
||||
WHEN 'expirado' THEN 4
|
||||
ELSE 99
|
||||
END,
|
||||
s.criado_em DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.list_my_signatures(text[]) IS
|
||||
'Lista signatures do paciente logado (auth.uid()) cruzando por signatario_id, email ou patient.user_id. Inclui share_token pra link de assinatura.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.list_my_signatures(text[]) TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ============================================================================
|
||||
-- ROADMAP #1.4 #14 — Recibo profissional usa terapeuta_registro genérico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- O template recibo_pagamento (seed_015) usa "Psicólogo(a) — CRP {{terapeuta_crp}}".
|
||||
-- Como agora suportamos múltiplos conselhos (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS)
|
||||
-- via #5 (migration 20260521000003), o recibo precisa ser CFP-agnóstico.
|
||||
--
|
||||
-- Esta migration substitui no recibo_pagamento:
|
||||
-- "Psicólogo(a) — CRP {{terapeuta_crp}}" → "{{terapeuta_registro}}"
|
||||
-- e atualiza variaveis[] removendo terapeuta_crp + adicionando terapeuta_registro.
|
||||
--
|
||||
-- {{terapeuta_registro}} é auto-formatado server-side como "CRP 12345/SP",
|
||||
-- "CRM 12345/SP" etc, então não precisa de "Psicólogo(a) —" hardcoded.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.document_templates
|
||||
SET corpo_html = REPLACE(
|
||||
corpo_html,
|
||||
'Psicólogo(a) — CRP {{terapeuta_crp}}',
|
||||
'{{terapeuta_registro}}'
|
||||
),
|
||||
variaveis = ARRAY(
|
||||
SELECT DISTINCT v FROM (
|
||||
SELECT unnest(variaveis) v
|
||||
UNION ALL
|
||||
SELECT 'terapeuta_registro'
|
||||
) sub
|
||||
WHERE v <> 'terapeuta_crp'
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE tipo = 'recibo_pagamento' AND is_global = true;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
|
||||
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
|
||||
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
|
||||
-- (ex: associações privadas, conselhos não-listados).
|
||||
--
|
||||
-- Esta migration adiciona professional_registration_type_other (text livre),
|
||||
-- que só é preenchido quando type = 'outro'.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
|
||||
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
|
||||
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,87 @@
|
||||
-- =============================================================================
|
||||
-- F1.1 — Schema-per-tenant: coluna tenants.slug
|
||||
-- Plano: novo-rumo.txt + docs/F0_categorizacao.md (decisão Q1)
|
||||
-- Slug é a base do nome do schema físico: tenant_<slug>. Unico e imutável.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.tenants ADD COLUMN IF NOT EXISTS slug text;
|
||||
|
||||
-- Geração de slug a partir do nome (sanitizado pra identificador Postgres)
|
||||
CREATE OR REPLACE FUNCTION public.generate_tenant_slug(p_name text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
base text;
|
||||
cand text;
|
||||
n int := 1;
|
||||
BEGIN
|
||||
base := lower(coalesce(nullif(trim(p_name), ''), 'tenant'));
|
||||
base := translate(base,
|
||||
'áàâãäåéèêëíìîïóòôõöúùûüçñýÿ',
|
||||
'aaaaaaeeeeiiiiooooouuuucnyy');
|
||||
base := regexp_replace(base, '[^a-z0-9]+', '_', 'g');
|
||||
base := regexp_replace(base, '^_+|_+$', '', 'g');
|
||||
base := left(base, 48);
|
||||
IF base = '' OR base !~ '^[a-z]' THEN
|
||||
base := 't_' || base;
|
||||
base := left(base, 48);
|
||||
END IF;
|
||||
cand := base;
|
||||
WHILE EXISTS (SELECT 1 FROM public.tenants WHERE slug = cand) LOOP
|
||||
n := n + 1;
|
||||
cand := left(base, 44) || '_' || n;
|
||||
END LOOP;
|
||||
RETURN cand;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Backfill dos tenants existentes
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN SELECT id, name FROM public.tenants WHERE slug IS NULL ORDER BY created_at, id LOOP
|
||||
UPDATE public.tenants SET slug = public.generate_tenant_slug(r.name) WHERE id = r.id;
|
||||
RAISE NOTICE 'tenant % -> slug %', r.id, (SELECT slug FROM public.tenants WHERE id = r.id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.tenants ALTER COLUMN slug SET NOT NULL;
|
||||
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_key UNIQUE (slug);
|
||||
ALTER TABLE public.tenants ADD CONSTRAINT tenants_slug_format CHECK (slug ~ '^[a-z][a-z0-9_]{1,47}$');
|
||||
|
||||
-- Auto-gera no INSERT (provisionamento atual não conhece slug); imutável no UPDATE
|
||||
CREATE OR REPLACE FUNCTION public.trg_tenants_slug()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.slug IS NULL OR trim(NEW.slug) = '' THEN
|
||||
NEW.slug := public.generate_tenant_slug(NEW.name);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.slug IS DISTINCT FROM OLD.slug THEN
|
||||
RAISE EXCEPTION 'tenants.slug é imutável (tenant %, % -> %)', OLD.id, OLD.slug, NEW.slug;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenants_slug_ins ON public.tenants;
|
||||
CREATE TRIGGER trg_tenants_slug_ins BEFORE INSERT ON public.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenants_slug_upd ON public.tenants;
|
||||
CREATE TRIGGER trg_tenants_slug_upd BEFORE UPDATE OF slug ON public.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.trg_tenants_slug();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- =============================================================================
|
||||
-- F1.2 — Schema-per-tenant: helpers de resolução de schema
|
||||
-- Adaptação ao modelo multi-membership deste projeto (docs/F0_categorizacao.md D2):
|
||||
-- profiles.tenant_id é NULL; membership vive em tenant_members (multi-tenant).
|
||||
-- Logo NÃO existe current_tenant_schema() — RPCs recebem p_tenant_id explícito
|
||||
-- e validam via tenant_schema_checked(p_tenant_id).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- slug -> nome de schema (validado). Retorna NULL se slug inválido.
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_name(p_slug text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE
|
||||
WHEN p_slug ~ '^[a-z][a-z0-9_]{1,47}$' THEN 'tenant_' || p_slug
|
||||
ELSE NULL
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- tenant_id -> nome de schema
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_for(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
SELECT public.tenant_schema_name(t.slug) FROM public.tenants t WHERE t.id = p_tenant_id;
|
||||
$$;
|
||||
|
||||
-- nome de schema -> tenant_id (CRÍTICO pra triggers: a coluna tenant_id não
|
||||
-- existe mais nas tabelas tenant; o schema é a identidade)
|
||||
CREATE OR REPLACE FUNCTION public.tenant_id_for_schema(p_schema text)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
SELECT t.id FROM public.tenants t WHERE public.tenant_schema_name(t.slug) = p_schema;
|
||||
$$;
|
||||
|
||||
-- Resolve schema de um tenant COM validação de acesso do usuário logado.
|
||||
-- Substitui o current_tenant_schema() do blueprint (que assumia 1 tenant/usuário).
|
||||
CREATE OR REPLACE FUNCTION public.tenant_schema_checked(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schema text;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_schema_checked: p_tenant_id obrigatório';
|
||||
END IF;
|
||||
IF NOT public.is_tenant_member(p_tenant_id) AND NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'acesso negado ao tenant %', p_tenant_id
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
IF v_schema IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant % não encontrado ou slug inválido', p_tenant_id;
|
||||
END IF;
|
||||
RETURN v_schema;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,513 @@
|
||||
-- =============================================================================
|
||||
-- F1.3 — Schema-per-tenant: construção do schema _tenant_template
|
||||
--
|
||||
-- Clona a ESTRUTURA das 84 tabelas tenant-scoped (docs/F0_categorizacao.md §1)
|
||||
-- a partir de public, SEM a coluna tenant_id:
|
||||
-- * PK/UNIQUE compostos perdem tenant_id; PK/UNIQUE que eram SÓ (tenant_id)
|
||||
-- viram coluna `singleton boolean` (tabela de config 1-linha-por-tenant)
|
||||
-- * índices parciais WHERE tenant_id IS [NOT] NULL são fundidos/deduplicados
|
||||
-- * sequences bigserial são localizadas no template (não compartilham public)
|
||||
-- * FKs locais apontam pro template; FKs pra tabelas globais ficam em public/auth
|
||||
-- * linhas-default do sistema (tenant_id IS NULL) viram SEED do template e
|
||||
-- são copiadas pra cada tenant no clone
|
||||
-- * 6 views adaptadas ficam registradas em _tenant_template._views com
|
||||
-- placeholders __SCHEMA__ / __TENANT_ID__ (instanciadas no clone)
|
||||
--
|
||||
-- O template NUNCA é exposto no PostgREST nem recebe dados de tenant.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP SCHEMA IF EXISTS _tenant_template CASCADE;
|
||||
CREATE SCHEMA _tenant_template;
|
||||
|
||||
-- Helper interno: remove tenant_id de uma definição de índice e simplifica
|
||||
-- predicados parciais que testavam tenant_id.
|
||||
CREATE FUNCTION _tenant_template._adapt_indexdef(p_def text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE d text := p_def;
|
||||
BEGIN
|
||||
-- coluna no início/meio/fim da lista
|
||||
d := regexp_replace(d, '\(tenant_id,\s*', '(', 'g');
|
||||
d := regexp_replace(d, ',\s*tenant_id\)', ')', 'g');
|
||||
d := regexp_replace(d, ',\s*tenant_id,', ',', 'g');
|
||||
-- predicados parciais
|
||||
d := replace(d, '(tenant_id IS NOT NULL) AND ', '');
|
||||
d := replace(d, ' AND (tenant_id IS NOT NULL)', '');
|
||||
d := replace(d, ' WHERE ((tenant_id IS NOT NULL))', '');
|
||||
d := replace(d, ' WHERE (tenant_id IS NOT NULL)', '');
|
||||
d := replace(d, '(tenant_id IS NULL) AND ', '');
|
||||
d := replace(d, ' AND (tenant_id IS NULL)', '');
|
||||
d := replace(d, ' WHERE ((tenant_id IS NULL))', '');
|
||||
d := replace(d, ' WHERE (tenant_id IS NULL)', '');
|
||||
RETURN d;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
tabs text[] := ARRAY[
|
||||
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||
'agendador_configuracoes','agendador_solicitacoes',
|
||||
'asaas_customers','asaas_payments',
|
||||
'billing_contracts',
|
||||
'clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||
'commitment_services','commitment_time_logs',
|
||||
'company_profiles',
|
||||
'contact_email_types','contact_emails','contact_phones','contact_types',
|
||||
'conversation_assignments','conversation_autoreply_log','conversation_autoreply_settings',
|
||||
'conversation_bot_sessions','conversation_bots','conversation_messages','conversation_notes',
|
||||
'conversation_optout_keywords','conversation_optouts','conversation_sla_breaches',
|
||||
'conversation_sla_rules','conversation_tags','conversation_thread_tags',
|
||||
'determined_commitment_fields','determined_commitments',
|
||||
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||
'document_templates','documents',
|
||||
'email_layout_config','email_templates_tenant',
|
||||
'feriados',
|
||||
'financial_categories','financial_exceptions','financial_records',
|
||||
'insurance_plan_services','insurance_plans',
|
||||
'medicos',
|
||||
'notification_channels','notification_logs','notification_preferences','notification_queue',
|
||||
'notification_schedules','notification_templates','notifications',
|
||||
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||
'payment_settings','professional_pricing',
|
||||
'recurrence_exceptions','recurrence_rule_services','recurrence_rules',
|
||||
'services',
|
||||
'session_reminder_logs','session_reminder_settings',
|
||||
'therapist_payout_records','therapist_payouts',
|
||||
'twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||
];
|
||||
t text;
|
||||
r record;
|
||||
r2 record;
|
||||
v_def text;
|
||||
v_sig text;
|
||||
v_seq text;
|
||||
v_cols text;
|
||||
v_remaining text;
|
||||
v_n int;
|
||||
seen_sigs text[];
|
||||
pending text[] := ARRAY[]::text[];
|
||||
failed text[];
|
||||
BEGIN
|
||||
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||
|
||||
IF array_length(tabs, 1) <> 84 THEN
|
||||
RAISE EXCEPTION 'lista de tabelas tenant deveria ter 84, tem %', array_length(tabs, 1);
|
||||
END IF;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 1: clonar estrutura
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = t AND table_type = 'BASE TABLE') THEN
|
||||
RAISE EXCEPTION 'tabela public.% não existe — lista F0 desatualizada', t;
|
||||
END IF;
|
||||
EXECUTE format('CREATE TABLE _tenant_template.%I (LIKE public.%I INCLUDING ALL)', t, t);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'PASS 1 ok: % tabelas clonadas', array_length(tabs, 1);
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 2: localizar sequences (defaults nextval apontando pra public)
|
||||
---------------------------------------------------------------------------
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, a.attname AS col,
|
||||
pg_get_expr(d.adbin, d.adrelid) AS def
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_class c ON c.oid = d.adrelid
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE c.relnamespace = '_tenant_template'::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''public.%'
|
||||
LOOP
|
||||
v_seq := r.tab || '_' || r.col || '_seq';
|
||||
EXECUTE format('CREATE SEQUENCE _tenant_template.%I', v_seq);
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||
r.tab, r.col, '_tenant_template.' || v_seq);
|
||||
EXECUTE format('ALTER SEQUENCE _tenant_template.%I OWNED BY _tenant_template.%I.%I',
|
||||
v_seq, r.tab, r.col);
|
||||
RAISE NOTICE 'PASS 2: sequence local _tenant_template.% (%.%)', v_seq, r.tab, r.col;
|
||||
END LOOP;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 3: drop tenant_id + recriar constraints/índices sem a coluna
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY tabs LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_attribute
|
||||
WHERE attrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND attname = 'tenant_id' AND NOT attisdropped) THEN
|
||||
CONTINUE; -- joins/children sem tenant_id (commitment_services etc.)
|
||||
END IF;
|
||||
|
||||
-- 3a. capturar PK/UNIQUE que contêm tenant_id (no template)
|
||||
CREATE TEMP TABLE IF NOT EXISTS _f1_cons (tab text, conname text, contype char, remaining text) ON COMMIT DROP;
|
||||
DELETE FROM _f1_cons WHERE tab = t;
|
||||
INSERT INTO _f1_cons
|
||||
SELECT t, con.conname, con.contype,
|
||||
(SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY k.ord)
|
||||
FROM unnest(con.conkey) WITH ORDINALITY k(attnum, ord)
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
||||
WHERE a.attname <> 'tenant_id')
|
||||
FROM pg_constraint con
|
||||
WHERE con.conrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND con.contype IN ('p', 'u')
|
||||
AND EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||
WHERE a.attname = 'tenant_id');
|
||||
|
||||
-- 3b. capturar índices "soltos" (não-constraint) que usam tenant_id
|
||||
CREATE TEMP TABLE IF NOT EXISTS _f1_idx (tab text, idxname text, def text) ON COMMIT DROP;
|
||||
DELETE FROM _f1_idx WHERE tab = t;
|
||||
INSERT INTO _f1_idx
|
||||
SELECT t, c2.relname, pg_get_indexdef(i.indexrelid)
|
||||
FROM pg_index i
|
||||
JOIN pg_class c2 ON c2.oid = i.indexrelid
|
||||
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_constraint cc WHERE cc.conindid = i.indexrelid)
|
||||
AND pg_get_indexdef(i.indexrelid) ~ '\mtenant_id\M';
|
||||
|
||||
-- 3c. drop da coluna (leva junto constraints/índices que a usam)
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I DROP COLUMN tenant_id CASCADE', t);
|
||||
|
||||
-- 3d. recriar PK/UNIQUE
|
||||
FOR r IN SELECT * FROM _f1_cons WHERE tab = t LOOP
|
||||
IF r.remaining IS NULL OR r.remaining = '' THEN
|
||||
-- era PK/UNIQUE exatamente (tenant_id): tabela 1-linha-por-tenant
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD COLUMN singleton boolean NOT NULL DEFAULT true', t);
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I CHECK (singleton = true)',
|
||||
t, t || '_singleton_chk');
|
||||
IF r.contype = 'p' THEN
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I PRIMARY KEY (singleton)',
|
||||
t, r.conname);
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I UNIQUE (singleton)',
|
||||
t, r.conname);
|
||||
END IF;
|
||||
RAISE NOTICE 'PASS 3: %.% era (tenant_id) -> singleton (%)', t, r.conname,
|
||||
CASE r.contype WHEN 'p' THEN 'PK' ELSE 'UNIQUE' END;
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s (%s)',
|
||||
t, r.conname,
|
||||
CASE r.contype WHEN 'p' THEN 'PRIMARY KEY' ELSE 'UNIQUE' END,
|
||||
r.remaining);
|
||||
RAISE NOTICE 'PASS 3: %.% recriado sem tenant_id -> (%)', t, r.conname, r.remaining;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- 3e. recriar índices soltos transformados (com dedupe)
|
||||
seen_sigs := ARRAY[]::text[];
|
||||
-- assinaturas dos índices que já existem na tabela (pós-recriação de constraints)
|
||||
FOR r2 IN
|
||||
SELECT regexp_replace(pg_get_indexdef(i.indexrelid),
|
||||
'^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '') AS sig
|
||||
FROM pg_index i
|
||||
WHERE i.indrelid = ('_tenant_template.' || quote_ident(t))::regclass
|
||||
LOOP
|
||||
seen_sigs := seen_sigs || r2.sig;
|
||||
END LOOP;
|
||||
|
||||
FOR r IN SELECT * FROM _f1_idx WHERE tab = t LOOP
|
||||
-- índice cuja ÚNICA coluna era tenant_id: descartar
|
||||
IF r.def ~ '\(tenant_id\)( WHERE .*)?$' THEN
|
||||
RAISE NOTICE 'PASS 3: índice % descartado (era só tenant_id)', r.idxname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
v_def := _tenant_template._adapt_indexdef(r.def);
|
||||
v_sig := regexp_replace(v_def, '^CREATE (UNIQUE )?INDEX [^ ]+ ON [^ ]+ ', '');
|
||||
IF v_sig = ANY (seen_sigs) THEN
|
||||
RAISE NOTICE 'PASS 3: índice % deduplicado', r.idxname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
EXECUTE v_def;
|
||||
seen_sigs := seen_sigs || v_sig;
|
||||
RAISE NOTICE 'PASS 3: índice % recriado: %', r.idxname, v_sig;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 4: FKs (a partir das FKs reais de public)
|
||||
---------------------------------------------------------------------------
|
||||
FOR r IN
|
||||
SELECT con.conname,
|
||||
cl.relname AS tab,
|
||||
ns2.nspname AS fschema,
|
||||
cl2.relname AS ftab,
|
||||
pg_get_constraintdef(con.oid) AS def,
|
||||
EXISTS (SELECT 1 FROM unnest(con.conkey) k
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k
|
||||
WHERE a.attname = 'tenant_id') AS uses_tenant_id
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||
JOIN pg_class cl2 ON cl2.oid = con.confrelid
|
||||
JOIN pg_namespace ns2 ON ns2.oid = cl2.relnamespace
|
||||
WHERE con.contype = 'f'
|
||||
AND cl.relnamespace = 'public'::regnamespace
|
||||
AND cl.relname = ANY (tabs)
|
||||
ORDER BY cl.relname, con.conname
|
||||
LOOP
|
||||
IF r.uses_tenant_id THEN
|
||||
RAISE NOTICE 'PASS 4: FK %.% descartada (coluna tenant_id removida)', r.tab, r.conname;
|
||||
CONTINUE;
|
||||
END IF;
|
||||
v_def := r.def;
|
||||
IF r.fschema = 'public' AND r.ftab = ANY (tabs) THEN
|
||||
-- alvo também é tenant-scoped -> referência intra-template
|
||||
v_def := regexp_replace(v_def,
|
||||
' REFERENCES (public\.)?' || r.ftab || '\(',
|
||||
' REFERENCES _tenant_template.' || r.ftab || '(');
|
||||
END IF;
|
||||
EXECUTE format('ALTER TABLE _tenant_template.%I ADD CONSTRAINT %I %s', r.tab, r.conname, v_def);
|
||||
END LOOP;
|
||||
RAISE NOTICE 'PASS 4 ok: FKs recriadas';
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- PASS 5: seeds — linhas-default do sistema (tenant_id IS NULL em public)
|
||||
-- APENAS tabelas de lookup/template (whitelist): linhas operacionais órfãs
|
||||
-- com tenant_id NULL (intakes, convites, notifs) NÃO são defaults.
|
||||
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||
-- resolve ordem de FK por tentativa-e-repetição em rounds.
|
||||
---------------------------------------------------------------------------
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'clinical_note_templates','contact_email_types','contact_types',
|
||||
'conversation_optout_keywords','conversation_tags','document_templates',
|
||||
'notification_templates','feriados'
|
||||
] LOOP
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = t AND column_name = 'tenant_id') THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
EXECUTE format('SELECT count(*) FROM public.%I WHERE tenant_id IS NULL', t) INTO v_n;
|
||||
IF v_n = 0 THEN CONTINUE; END IF;
|
||||
pending := pending || t;
|
||||
END LOOP;
|
||||
|
||||
WHILE coalesce(array_length(pending, 1), 0) > 0 LOOP
|
||||
failed := ARRAY[]::text[];
|
||||
FOREACH t IN ARRAY pending LOOP
|
||||
SELECT string_agg(quote_ident(c.column_name), ', ' ORDER BY c.ordinal_position)
|
||||
INTO v_cols
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = '_tenant_template' AND c.table_name = t
|
||||
AND c.column_name <> 'singleton'
|
||||
AND EXISTS (SELECT 1 FROM information_schema.columns p
|
||||
WHERE p.table_schema = 'public' AND p.table_name = t
|
||||
AND p.column_name = c.column_name);
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO _tenant_template.%I (%s) SELECT %s FROM public.%I WHERE tenant_id IS NULL',
|
||||
t, v_cols, v_cols, t);
|
||||
RAISE NOTICE 'PASS 5: linhas-default semeadas em _tenant_template.%', t;
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
failed := failed || t;
|
||||
END;
|
||||
END LOOP;
|
||||
IF array_length(failed, 1) = array_length(pending, 1) THEN
|
||||
RAISE EXCEPTION 'PASS 5: dependência circular/externa nos seeds: %', failed;
|
||||
END IF;
|
||||
pending := failed;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Metadados do template
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE _tenant_template._meta (key text PRIMARY KEY, value jsonb NOT NULL);
|
||||
INSERT INTO _tenant_template._meta VALUES
|
||||
('template_version', '1'::jsonb),
|
||||
('built_from', '"docs/F0_categorizacao.md"'::jsonb),
|
||||
('triggers_pending', 'true'::jsonb); -- triggers de negócio só na F6
|
||||
|
||||
-- Tabelas que entram na publication supabase_realtime a cada clone
|
||||
-- (espelha o estado atual da publication em public: conversation_messages, notifications)
|
||||
CREATE TABLE _tenant_template._realtime_tables (table_name text PRIMARY KEY);
|
||||
INSERT INTO _tenant_template._realtime_tables
|
||||
SELECT tablename FROM pg_publication_tables
|
||||
WHERE pubname = 'supabase_realtime' AND schemaname = 'public'
|
||||
AND tablename IN (
|
||||
'agenda_bloqueios','agenda_configuracoes','agenda_eventos','agenda_online_slots',
|
||||
'agenda_regras_semanais','agenda_slots_bloqueados_semanais','agenda_slots_regras',
|
||||
'agendador_configuracoes','agendador_solicitacoes','asaas_customers','asaas_payments',
|
||||
'billing_contracts','clinical_note_templates','clinical_note_versions','clinical_notes',
|
||||
'commitment_services','commitment_time_logs','company_profiles','contact_email_types',
|
||||
'contact_emails','contact_phones','contact_types','conversation_assignments',
|
||||
'conversation_autoreply_log','conversation_autoreply_settings','conversation_bot_sessions',
|
||||
'conversation_bots','conversation_messages','conversation_notes','conversation_optout_keywords',
|
||||
'conversation_optouts','conversation_sla_breaches','conversation_sla_rules','conversation_tags',
|
||||
'conversation_thread_tags','determined_commitment_fields','determined_commitments',
|
||||
'document_access_logs','document_generated','document_share_links','document_signatures',
|
||||
'document_templates','documents','email_layout_config','email_templates_tenant','feriados',
|
||||
'financial_categories','financial_exceptions','financial_records','insurance_plan_services',
|
||||
'insurance_plans','medicos','notification_channels','notification_logs','notification_preferences',
|
||||
'notification_queue','notification_schedules','notification_templates','notifications',
|
||||
'patient_contacts','patient_discounts','patient_group_patient','patient_groups',
|
||||
'patient_intake_requests','patient_invite_attempts','patient_invites','patient_patient_tag',
|
||||
'patient_status_history','patient_support_contacts','patient_tags','patient_timeline','patients',
|
||||
'payment_settings','professional_pricing','recurrence_exceptions','recurrence_rule_services',
|
||||
'recurrence_rules','services','session_reminder_logs','session_reminder_settings',
|
||||
'therapist_payout_records','therapist_payouts','twilio_subaccount_usage','whatsapp_connection_incidents'
|
||||
);
|
||||
|
||||
-- Views adaptadas (instanciadas pelo clone com __SCHEMA__ / __TENANT_ID__)
|
||||
CREATE TABLE _tenant_template._views (
|
||||
view_name text PRIMARY KEY,
|
||||
position int NOT NULL,
|
||||
definition text NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO _tenant_template._views VALUES
|
||||
('conversation_threads', 1, $vw$
|
||||
CREATE VIEW __SCHEMA__.conversation_threads WITH (security_invoker = true) AS
|
||||
WITH base AS (
|
||||
SELECT cm.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 __SCHEMA__.conversation_messages cm
|
||||
), latest AS (
|
||||
SELECT DISTINCT ON (base.thread_key)
|
||||
base.thread_key, base.patient_id, base.channel, base.contact_number,
|
||||
base.body AS last_message_body, base.direction AS last_message_direction,
|
||||
base.kanban_status, base.created_at AS last_message_at
|
||||
FROM base
|
||||
ORDER BY base.thread_key, base.created_at DESC
|
||||
), counts AS (
|
||||
SELECT base.thread_key, count(*) AS message_count,
|
||||
count(*) FILTER (WHERE base.direction = 'inbound' AND base.read_at IS NULL) AS unread_count
|
||||
FROM base
|
||||
GROUP BY base.thread_key
|
||||
)
|
||||
SELECT '__TENANT_ID__'::uuid AS 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.thread_key = l.thread_key
|
||||
LEFT JOIN __SCHEMA__.patients p ON p.id = l.patient_id
|
||||
LEFT JOIN __SCHEMA__.conversation_assignments ca ON ca.thread_key = l.thread_key
|
||||
$vw$),
|
||||
('audit_log_unified', 2, $vw$
|
||||
CREATE VIEW __SCHEMA__.audit_log_unified WITH (security_invoker = true) AS
|
||||
SELECT 'audit:' || al.id::text AS uid, al.tenant_id, al.user_id, al.entity_type, al.entity_id, al.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
|
||||
WHERE al.tenant_id = '__TENANT_ID__'::uuid
|
||||
UNION ALL
|
||||
SELECT 'doc_access:' || dal.id::text, '__TENANT_ID__'::uuid, dal.user_id, 'document', dal.documento_id::text, dal.acao,
|
||||
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,
|
||||
dal.acessado_em, 'document_access_logs',
|
||||
jsonb_build_object('ip', dal.ip::text, 'user_agent', dal.user_agent)
|
||||
FROM __SCHEMA__.document_access_logs dal
|
||||
UNION ALL
|
||||
SELECT 'psh:' || psh.id::text, '__TENANT_ID__'::uuid, psh.alterado_por, 'patient_status', psh.patient_id::text, 'status_change',
|
||||
((('Status do paciente: ' || COALESCE(psh.status_anterior, '—')) || ' → ') || psh.status_novo) || COALESCE((' (' || psh.motivo) || ')', ''),
|
||||
psh.alterado_em, 'patient_status_history',
|
||||
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)
|
||||
FROM __SCHEMA__.patient_status_history psh
|
||||
UNION ALL
|
||||
SELECT 'notif:' || nl.id::text, '__TENANT_ID__'::uuid, nl.owner_id, 'notification', nl.patient_id::text, nl.status,
|
||||
((('Notificação ' || nl.channel) || ' ') || nl.status) || COALESCE(' para ' || nl.recipient_address, ''),
|
||||
nl.created_at, 'notification_logs',
|
||||
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status,
|
||||
'provider', nl.provider, 'failure_reason', nl.failure_reason)
|
||||
FROM __SCHEMA__.notification_logs nl
|
||||
UNION ALL
|
||||
SELECT 'addon:' || at.id::text, at.tenant_id, at.admin_user_id, 'addon_transaction', at.id::text, at.type,
|
||||
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,
|
||||
at.created_at, 'addon_transactions',
|
||||
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)
|
||||
FROM public.addon_transactions at
|
||||
WHERE at.tenant_id = '__TENANT_ID__'::uuid
|
||||
$vw$),
|
||||
('v_cashflow_projection', 3, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_cashflow_projection WITH (security_invoker = true) AS
|
||||
SELECT gs.mes,
|
||||
to_char(gs.mes, 'YYYY-MM') AS mes_label,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS receitas_projetadas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS despesas_projetadas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'pending'), 0) AS receitas_pendentes,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND fr.status = 'overdue'), 0) AS receitas_vencidas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'pending'), 0) AS despesas_pendentes,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND fr.status = 'overdue'), 0) AS despesas_vencidas,
|
||||
COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'receita'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0)
|
||||
- COALESCE(sum(fr.final_amount) FILTER (WHERE fr.type = 'despesa'::financial_record_type AND (fr.status = ANY (ARRAY['pending', 'overdue']))), 0) AS saldo_projetado,
|
||||
count(fr.id) FILTER (WHERE fr.status = ANY (ARRAY['pending', 'overdue'])) AS count_registros
|
||||
FROM generate_series(date_trunc('month', CURRENT_DATE::timestamp with time zone)::date::timestamp with time zone,
|
||||
(date_trunc('month', CURRENT_DATE::timestamp with time zone) + '5 mons'::interval)::date::timestamp with time zone,
|
||||
'1 mon'::interval) gs(mes)
|
||||
LEFT JOIN __SCHEMA__.financial_records fr
|
||||
ON fr.deleted_at IS NULL
|
||||
AND (fr.status = ANY (ARRAY['pending', 'overdue']))
|
||||
AND date_trunc('month', fr.due_date::timestamp with time zone)::date = gs.mes
|
||||
GROUP BY gs.mes
|
||||
ORDER BY gs.mes
|
||||
$vw$),
|
||||
('v_commitment_totals', 4, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_commitment_totals WITH (security_invoker = true) AS
|
||||
SELECT '__TENANT_ID__'::uuid AS tenant_id,
|
||||
c.id AS commitment_id,
|
||||
COALESCE(sum(l.minutes), 0)::integer AS total_minutes
|
||||
FROM __SCHEMA__.determined_commitments c
|
||||
LEFT JOIN __SCHEMA__.commitment_time_logs l ON l.commitment_id = c.id
|
||||
GROUP BY c.id
|
||||
$vw$),
|
||||
('v_patient_groups_with_counts', 5, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_patient_groups_with_counts WITH (security_invoker = true) AS
|
||||
SELECT pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at,
|
||||
COALESCE(count(pgp.patient_id), 0)::integer AS patients_count
|
||||
FROM __SCHEMA__.patient_groups pg
|
||||
LEFT JOIN __SCHEMA__.patient_group_patient pgp ON pgp.patient_group_id = pg.id
|
||||
GROUP BY pg.id, pg.nome, pg.cor, pg.owner_id, pg.is_system, pg.is_active, pg.created_at, pg.updated_at
|
||||
$vw$),
|
||||
('v_tag_patient_counts', 6, $vw$
|
||||
CREATE VIEW __SCHEMA__.v_tag_patient_counts WITH (security_invoker = true) AS
|
||||
SELECT t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at,
|
||||
COALESCE(count(ppt.patient_id), 0)::integer AS pacientes_count,
|
||||
COALESCE(count(ppt.patient_id), 0)::integer AS patient_count
|
||||
FROM __SCHEMA__.patient_tags t
|
||||
LEFT JOIN __SCHEMA__.patient_patient_tag ppt ON ppt.tag_id = t.id AND ppt.owner_id = t.owner_id
|
||||
GROUP BY t.id, t.owner_id, t.nome, t.cor, t.is_padrao, t.created_at, t.updated_at
|
||||
$vw$);
|
||||
|
||||
-- Valida as views instanciando no próprio template (tenant nulo)
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
|
||||
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
|
||||
EXECUTE replace(replace(r.definition, '__SCHEMA__', '_tenant_template'),
|
||||
'__TENANT_ID__', '00000000-0000-0000-0000-000000000000');
|
||||
RAISE NOTICE 'view _tenant_template.% validada', r.view_name;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,301 @@
|
||||
-- =============================================================================
|
||||
-- F1.4 — Schema-per-tenant: clone/drop + registro + roteamento de canais
|
||||
--
|
||||
-- * public.tenant_schemas — registro dos schemas provisionados (alimenta o
|
||||
-- gerador do config.toml na F5)
|
||||
-- * public.channel_routing — índice global de roteamento: webhooks inbound
|
||||
-- (Twilio/Evolution) precisam descobrir o tenant do canal ANTES de saber o
|
||||
-- schema (decisão Q3: notification_channels mora no schema do tenant).
|
||||
-- Mantido por trigger em cada tenant_<slug>.notification_channels.
|
||||
-- * clone_tenant_template(tenant_id) — instancia tenant_<slug> a partir do
|
||||
-- _tenant_template: tabelas + sequences locais + FKs + seeds + views + RLS
|
||||
-- (policies com tenant_id EMBUTIDO — modelo multi-membership) + realtime +
|
||||
-- grants + trigger de roteamento.
|
||||
-- * drop_tenant_schema(tenant_id) — protegido (assert tenant_%).
|
||||
--
|
||||
-- NOTA: clones criados na F1/F2 ainda NÃO têm triggers de negócio (F6) e não
|
||||
-- estão expostos no PostgREST (F5). _meta.triggers_pending registra isso.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Registro de schemas provisionados
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.tenant_schemas (
|
||||
tenant_id uuid PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
schema_name text NOT NULL UNIQUE,
|
||||
template_version int NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.tenant_schemas ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_schemas_select ON public.tenant_schemas;
|
||||
CREATE POLICY tenant_schemas_select ON public.tenant_schemas
|
||||
FOR SELECT TO authenticated
|
||||
USING (public.is_tenant_member(tenant_id) OR public.is_saas_admin());
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Índice global de roteamento de canais (webhook inbound -> tenant)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.channel_routing (
|
||||
channel_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||||
channel text NOT NULL,
|
||||
provider text,
|
||||
sender_address text,
|
||||
twilio_subaccount_sid text,
|
||||
twilio_phone_number text,
|
||||
metadata jsonb,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_tenant_idx ON public.channel_routing (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_sender_idx ON public.channel_routing (sender_address) WHERE sender_address IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_twilio_phone_idx ON public.channel_routing (twilio_phone_number) WHERE twilio_phone_number IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS channel_routing_twilio_sid_idx ON public.channel_routing (twilio_subaccount_sid) WHERE twilio_subaccount_sid IS NOT NULL;
|
||||
|
||||
-- Tabela de infra: só service_role (edge functions) e saas admin enxergam
|
||||
ALTER TABLE public.channel_routing ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS channel_routing_saas_admin ON public.channel_routing;
|
||||
CREATE POLICY channel_routing_saas_admin ON public.channel_routing
|
||||
FOR ALL TO authenticated
|
||||
USING (public.is_saas_admin())
|
||||
WITH CHECK (public.is_saas_admin());
|
||||
|
||||
-- Trigger anexado a cada tenant_<slug>.notification_channels pelo clone
|
||||
CREATE OR REPLACE FUNCTION public.trg_sync_channel_routing()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
BEGIN
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
IF v_tenant_id IS NULL THEN
|
||||
RAISE WARNING 'trg_sync_channel_routing: schema % sem tenant correspondente', TG_TABLE_SCHEMA;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
DELETE FROM public.channel_routing WHERE channel_id = OLD.id;
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
INSERT INTO public.channel_routing AS cr
|
||||
(channel_id, tenant_id, channel, provider, sender_address,
|
||||
twilio_subaccount_sid, twilio_phone_number, metadata, is_active, updated_at)
|
||||
VALUES
|
||||
(NEW.id, v_tenant_id, NEW.channel, NEW.provider, NEW.sender_address,
|
||||
NEW.twilio_subaccount_sid, NEW.twilio_phone_number, NEW.metadata,
|
||||
COALESCE(NEW.is_active, false) AND NEW.deleted_at IS NULL, now())
|
||||
ON CONFLICT (channel_id) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
channel = EXCLUDED.channel,
|
||||
provider = EXCLUDED.provider,
|
||||
sender_address = EXCLUDED.sender_address,
|
||||
twilio_subaccount_sid = EXCLUDED.twilio_subaccount_sid,
|
||||
twilio_phone_number = EXCLUDED.twilio_phone_number,
|
||||
metadata = EXCLUDED.metadata,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- clone_tenant_template
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.clone_tenant_template(p_tenant_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_slug text;
|
||||
v_schema text;
|
||||
v_version int;
|
||||
t text;
|
||||
r record;
|
||||
v_def text;
|
||||
v_seq text;
|
||||
v_n int;
|
||||
v_pending text[];
|
||||
v_failed text[];
|
||||
BEGIN
|
||||
SELECT slug INTO v_slug FROM public.tenants WHERE id = p_tenant_id;
|
||||
IF v_slug IS NULL THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: tenant % não existe ou sem slug', p_tenant_id;
|
||||
END IF;
|
||||
v_schema := public.tenant_schema_name(v_slug);
|
||||
IF v_schema IS NULL THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: slug % inválido', v_slug;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: schema % já existe', v_schema;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = '_tenant_template') THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: _tenant_template não existe (rode a F1.3)';
|
||||
END IF;
|
||||
SELECT (value)::int INTO v_version FROM _tenant_template._meta WHERE key = 'template_version';
|
||||
|
||||
EXECUTE format('CREATE SCHEMA %I', v_schema);
|
||||
|
||||
-- nomes qualificados nas definições geradas pelo catálogo
|
||||
PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true);
|
||||
|
||||
-- 1. tabelas
|
||||
FOR r IN
|
||||
SELECT table_name AS tab FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT LIKE '\_%'
|
||||
ORDER BY table_name
|
||||
LOOP
|
||||
EXECUTE format('CREATE TABLE %I.%I (LIKE _tenant_template.%I INCLUDING ALL)',
|
||||
v_schema, r.tab, r.tab);
|
||||
END LOOP;
|
||||
|
||||
-- 2. sequences locais (defaults que apontam pro template)
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, a.attname AS col
|
||||
FROM pg_attrdef d
|
||||
JOIN pg_class c ON c.oid = d.adrelid
|
||||
JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum
|
||||
WHERE c.relnamespace = v_schema::regnamespace
|
||||
AND pg_get_expr(d.adbin, d.adrelid) LIKE 'nextval(''_tenant_template.%'
|
||||
LOOP
|
||||
v_seq := r.tab || '_' || r.col || '_seq';
|
||||
EXECUTE format('CREATE SEQUENCE %I.%I', v_schema, v_seq);
|
||||
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT nextval(%L::regclass)',
|
||||
v_schema, r.tab, r.col, format('%I.%I', v_schema, v_seq));
|
||||
EXECUTE format('ALTER SEQUENCE %I.%I OWNED BY %I.%I.%I',
|
||||
v_schema, v_seq, v_schema, r.tab, r.col);
|
||||
END LOOP;
|
||||
|
||||
-- 3. seeds (linhas-default do sistema guardadas no template)
|
||||
-- Sem session_replication_role (postgres não é superuser no Supabase):
|
||||
-- ordem de FK resolvida por tentativa-e-repetição em rounds.
|
||||
v_pending := ARRAY[]::text[];
|
||||
FOR r IN
|
||||
SELECT table_name AS tab FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT LIKE '\_%'
|
||||
ORDER BY table_name
|
||||
LOOP
|
||||
EXECUTE format('SELECT count(*) FROM _tenant_template.%I', r.tab) INTO v_n;
|
||||
IF v_n > 0 THEN v_pending := v_pending || r.tab; END IF;
|
||||
END LOOP;
|
||||
|
||||
WHILE coalesce(array_length(v_pending, 1), 0) > 0 LOOP
|
||||
v_failed := ARRAY[]::text[];
|
||||
FOR r IN SELECT unnest(v_pending) AS tab LOOP
|
||||
BEGIN
|
||||
EXECUTE format('INSERT INTO %I.%I SELECT * FROM _tenant_template.%I',
|
||||
v_schema, r.tab, r.tab);
|
||||
EXCEPTION WHEN foreign_key_violation THEN
|
||||
v_failed := v_failed || r.tab;
|
||||
END;
|
||||
END LOOP;
|
||||
IF array_length(v_failed, 1) = array_length(v_pending, 1) THEN
|
||||
RAISE EXCEPTION 'clone_tenant_template: dependência circular nos seeds: %', v_failed;
|
||||
END IF;
|
||||
v_pending := v_failed;
|
||||
END LOOP;
|
||||
|
||||
-- 4. FKs (intra-schema e pra public/auth)
|
||||
FOR r IN
|
||||
SELECT cl.relname AS tab, con.conname, pg_get_constraintdef(con.oid) AS def
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class cl ON cl.oid = con.conrelid
|
||||
WHERE con.contype = 'f'
|
||||
AND cl.relnamespace = '_tenant_template'::regnamespace
|
||||
ORDER BY cl.relname, con.conname
|
||||
LOOP
|
||||
v_def := replace(r.def, ' REFERENCES _tenant_template.', format(' REFERENCES %I.', v_schema));
|
||||
EXECUTE format('ALTER TABLE %I.%I ADD CONSTRAINT %I %s', v_schema, r.tab, r.conname, v_def);
|
||||
END LOOP;
|
||||
|
||||
-- 5. views (placeholders __SCHEMA__ / __TENANT_ID__)
|
||||
PERFORM pg_catalog.set_config('search_path', 'public, pg_catalog', true);
|
||||
FOR r IN SELECT * FROM _tenant_template._views ORDER BY position LOOP
|
||||
EXECUTE replace(replace(r.definition, '__SCHEMA__', quote_ident(v_schema)),
|
||||
'__TENANT_ID__', p_tenant_id::text);
|
||||
END LOOP;
|
||||
|
||||
-- 6. RLS: tenant_id embutido (multi-membership: o usuário só enxerga
|
||||
-- schemas de tenants onde tenant_members o lista como ativo)
|
||||
FOR r IN
|
||||
SELECT table_name AS tab FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_type = 'BASE TABLE'
|
||||
AND table_name NOT LIKE '\_%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, r.tab);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_member_full ON %I.%I FOR ALL TO authenticated USING (public.is_tenant_member(%L::uuid)) WITH CHECK (public.is_tenant_member(%L::uuid))',
|
||||
v_schema, r.tab, p_tenant_id, p_tenant_id);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY saas_admin_full ON %I.%I FOR ALL TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin())',
|
||||
v_schema, r.tab);
|
||||
END LOOP;
|
||||
|
||||
-- 7. trigger de roteamento de canais
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER trg_channel_routing AFTER INSERT OR UPDATE OR DELETE ON %I.notification_channels FOR EACH ROW EXECUTE FUNCTION public.trg_sync_channel_routing()',
|
||||
v_schema);
|
||||
|
||||
-- 8. realtime
|
||||
FOR r IN SELECT table_name FROM _tenant_template._realtime_tables LOOP
|
||||
EXECUTE format('ALTER PUBLICATION supabase_realtime ADD TABLE %I.%I', v_schema, r.table_name);
|
||||
END LOOP;
|
||||
|
||||
-- 9. grants (espelha o padrão do Supabase pra schemas expostos)
|
||||
EXECUTE format('GRANT USAGE ON SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('GRANT ALL ON ALL TABLES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('GRANT ALL ON ALL SEQUENCES IN SCHEMA %I TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON TABLES TO anon, authenticated, service_role', v_schema);
|
||||
EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT ALL ON SEQUENCES TO anon, authenticated, service_role', v_schema);
|
||||
|
||||
INSERT INTO public.tenant_schemas (tenant_id, schema_name, template_version)
|
||||
VALUES (p_tenant_id, v_schema, COALESCE(v_version, 1));
|
||||
|
||||
RETURN v_schema;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- drop_tenant_schema
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.drop_tenant_schema(p_tenant_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_schema text;
|
||||
BEGIN
|
||||
SELECT schema_name INTO v_schema FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
|
||||
IF v_schema IS NULL THEN
|
||||
v_schema := public.tenant_schema_for(p_tenant_id);
|
||||
END IF;
|
||||
IF v_schema IS NULL OR v_schema NOT LIKE 'tenant\_%' THEN
|
||||
RAISE EXCEPTION 'drop_tenant_schema: schema inválido pra tenant % (%)', p_tenant_id, v_schema;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = v_schema) THEN
|
||||
RAISE EXCEPTION 'drop_tenant_schema: schema % não existe', v_schema;
|
||||
END IF;
|
||||
DELETE FROM public.channel_routing WHERE tenant_id = p_tenant_id;
|
||||
DELETE FROM public.tenant_schemas WHERE tenant_id = p_tenant_id;
|
||||
EXECUTE format('DROP SCHEMA %I CASCADE', v_schema);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Clone/drop são operações de provisionamento: só service_role (edge) e postgres
|
||||
REVOKE ALL ON FUNCTION public.clone_tenant_template(uuid) FROM PUBLIC, anon, authenticated;
|
||||
REVOKE ALL ON FUNCTION public.drop_tenant_schema(uuid) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.clone_tenant_template(uuid) TO service_role;
|
||||
GRANT EXECUTE ON FUNCTION public.drop_tenant_schema(uuid) TO service_role;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- =============================================================================
|
||||
-- F1.5 — Correção dos seeds do _tenant_template
|
||||
--
|
||||
-- O PASS 5 da F1.3 semeou TODA linha com tenant_id IS NULL de public — mas
|
||||
-- patient_intake_requests (2), patient_invites (1) e notifications (2) eram
|
||||
-- dados operacionais órfãos, não defaults do sistema. Cada tenant novo nasceria
|
||||
-- com esses registros fantasmas.
|
||||
--
|
||||
-- Whitelist canônica de seeds do template (lookups/templates do sistema):
|
||||
-- clinical_note_templates, contact_email_types, contact_types,
|
||||
-- conversation_optout_keywords, conversation_tags, document_templates,
|
||||
-- notification_templates, feriados
|
||||
--
|
||||
-- (20260612000003 foi corrigida em retrospecto pra instalações do zero;
|
||||
-- esta migration corrige bancos que já aplicaram a versão original.)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM _tenant_template.patient_intake_requests;
|
||||
DELETE FROM _tenant_template.patient_invites;
|
||||
DELETE FROM _tenant_template.notifications;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,167 @@
|
||||
-- =============================================================================
|
||||
-- F2 — Schema-per-tenant: provisionamento cria o schema físico
|
||||
--
|
||||
-- Os 3 pontos de criação de tenant passam a chamar clone_tenant_template()
|
||||
-- logo após inserir em tenants/tenant_members. Tudo na mesma transação:
|
||||
-- se o clone falhar, o tenant não nasce (atomicidade).
|
||||
--
|
||||
-- Pontos cobertos (F0 §levantamento — não há outros INSERT INTO tenants):
|
||||
-- * provision_account_tenant — wizard de cadastro (therapist/clinic_*)
|
||||
-- * create_clinic_tenant — criação avulsa de clínica
|
||||
-- * ensure_personal_tenant_for_user — tenant pessoal (kind='saas'),
|
||||
-- chamado também pelo trigger de signup (handle_new_user_create_personal_tenant)
|
||||
--
|
||||
-- Decisão Q2: TODO tenant ganha schema, inclusive therapist e pessoal.
|
||||
-- Clones nascem sem triggers de negócio (F6) e fora do PostgREST (F5).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_clinic_tenant(p_name text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_tenant uuid;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := auth.uid();
|
||||
if v_uid is null then
|
||||
raise exception 'Not authenticated';
|
||||
end if;
|
||||
|
||||
v_name := nullif(trim(coalesce(p_name, '')), '');
|
||||
if v_name is null then
|
||||
v_name := 'Clínica';
|
||||
end if;
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name, 'clinic', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
perform public.clone_tenant_template(v_tenant);
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
declare
|
||||
v_uid uuid;
|
||||
v_existing uuid;
|
||||
v_tenant uuid;
|
||||
v_email text;
|
||||
v_name text;
|
||||
begin
|
||||
v_uid := p_user_id;
|
||||
if v_uid is null then
|
||||
raise exception 'Missing user id';
|
||||
end if;
|
||||
|
||||
-- só considera tenant pessoal (kind='saas')
|
||||
select tm.tenant_id
|
||||
into v_existing
|
||||
from public.tenant_members tm
|
||||
join public.tenants t on t.id = tm.tenant_id
|
||||
where tm.user_id = v_uid
|
||||
and tm.status = 'active'
|
||||
and t.kind = 'saas'
|
||||
order by tm.created_at desc
|
||||
limit 1;
|
||||
|
||||
if v_existing is not null then
|
||||
return v_existing;
|
||||
end if;
|
||||
|
||||
select email into v_email
|
||||
from auth.users
|
||||
where id = v_uid;
|
||||
|
||||
v_name := coalesce(split_part(v_email, '@', 1), 'Conta');
|
||||
|
||||
insert into public.tenants (name, kind, created_at)
|
||||
values (v_name || ' (Pessoal)', 'saas', now())
|
||||
returning id into v_tenant;
|
||||
|
||||
insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
values (v_tenant, v_uid, 'tenant_admin', 'active', now());
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
perform public.clone_tenant_template(v_tenant);
|
||||
|
||||
return v_tenant;
|
||||
end;
|
||||
$function$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_tenant_id uuid;
|
||||
v_account_type text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
|
||||
RAISE EXCEPTION 'kind inválido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
JOIN public.tenants t ON t.id = tm.tenant_id
|
||||
WHERE tm.user_id = p_user_id
|
||||
AND tm.role = 'tenant_admin'
|
||||
AND tm.status = 'active'
|
||||
AND t.kind = p_kind
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Usuário já possui um tenant do tipo "%".', p_kind
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
v_name := COALESCE(
|
||||
NULLIF(TRIM(p_name), ''),
|
||||
(
|
||||
SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
|
||||
FROM public.profiles pr
|
||||
JOIN auth.users au ON au.id = pr.id
|
||||
WHERE pr.id = p_user_id
|
||||
),
|
||||
'Conta'
|
||||
);
|
||||
|
||||
INSERT INTO public.tenants (name, kind, created_at)
|
||||
VALUES (v_name, p_kind, now())
|
||||
RETURNING id INTO v_tenant_id;
|
||||
|
||||
INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
|
||||
VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());
|
||||
|
||||
UPDATE public.profiles
|
||||
SET account_type = v_account_type
|
||||
WHERE id = p_user_id;
|
||||
|
||||
PERFORM public.seed_determined_commitments(v_tenant_id);
|
||||
|
||||
-- F2: schema físico do tenant (rollback junto se algo falhar)
|
||||
PERFORM public.clone_tenant_template(v_tenant_id);
|
||||
|
||||
RETURN v_tenant_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- =============================================================================
|
||||
-- F3 — my_tenants() passa a devolver slug (e nome) do tenant
|
||||
--
|
||||
-- O frontend resolve o schema físico do tenant ativo no cliente:
|
||||
-- tenantStore guarda memberships de my_tenants(); slug -> 'tenant_<slug>'.
|
||||
-- Campo extra é inofensivo pro frontend atual (main) que ignora colunas novas.
|
||||
-- (mudança de RETURNS TABLE exige DROP + CREATE)
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.my_tenants();
|
||||
|
||||
CREATE FUNCTION public.my_tenants()
|
||||
RETURNS TABLE(tenant_id uuid, role text, status text, kind text, slug text, tenant_name text)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
select
|
||||
tm.tenant_id,
|
||||
tm.role,
|
||||
tm.status,
|
||||
t.kind,
|
||||
t.slug,
|
||||
t.name
|
||||
from public.tenant_members tm
|
||||
join public.tenants t on t.id = tm.tenant_id
|
||||
where tm.user_id = auth.uid();
|
||||
$function$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.my_tenants() TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- =============================================================================
|
||||
-- F1b — Decisão de roteamento anon: 6 tabelas anon-facing FICAM em public
|
||||
--
|
||||
-- Fluxos anônimos identificam o tenant por TOKEN/SLUG (não por login), então
|
||||
-- não conseguem resolver o schema físico. Decisão (2026-06-13, opção C):
|
||||
-- manter essas tabelas em public com tenant_id + RLS por token, como hoje.
|
||||
--
|
||||
-- patient_intake_requests — intake de paciente por convite (token)
|
||||
-- patient_invites — tokens de convite
|
||||
-- patient_invite_attempts — rate-limit anon dos convites
|
||||
-- document_share_links — assinatura pública de documento (token)
|
||||
-- agendador_configuracoes — agendador público (link_slug)
|
||||
-- agendador_solicitacoes — solicitação criada por visitante anon
|
||||
--
|
||||
-- Logo, REMOVE essas 6 do _tenant_template (não viram schema do tenant).
|
||||
-- O clone_tenant_template itera as tabelas do template dinamicamente, então
|
||||
-- novos clones nascem sem elas automaticamente. Classificação final:
|
||||
-- 78 tenant-scoped + 59 globais (era 84 + 53).
|
||||
--
|
||||
-- Nota F6: public.document_share_links.documento_id tem FK -> documents, que
|
||||
-- VAI pro schema do tenant. No drop de public.documents (F6), essa FK precisa
|
||||
-- virar coluna solta (uuid sem constraint) — o RPC valida via token. Idem
|
||||
-- qualquer FK public->tenant dessas 6 (registrar no lote de FKs da F6).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
anon_tabs text[] := ARRAY[
|
||||
'patient_intake_requests','patient_invites','patient_invite_attempts',
|
||||
'document_share_links','agendador_configuracoes','agendador_solicitacoes'
|
||||
];
|
||||
t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY anon_tabs LOOP
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = '_tenant_template' AND table_name = t) THEN
|
||||
EXECUTE format('DROP TABLE _tenant_template.%I CASCADE', t);
|
||||
RAISE NOTICE 'F1b: _tenant_template.% removida (fica em public)', t;
|
||||
END IF;
|
||||
-- defensivo: tira do registro de realtime do template, se estiver lá
|
||||
DELETE FROM _tenant_template._realtime_tables WHERE table_name = t;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
UPDATE _tenant_template._meta SET value = '2'::jsonb WHERE key = 'template_version';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- =============================================================================
|
||||
-- F5 — Trigger que re-expõe schemas tenant no PostgREST a cada clone/drop
|
||||
--
|
||||
-- public.refresh_pgrst_schemas() (criada em manual/f5_pgrst_refresh_schemas.
|
||||
-- supabase_admin.sql, owned por supabase_admin) seta pgrst.db_schemas + NOTIFY.
|
||||
-- Este trigger em tenant_schemas a dispara automaticamente — clone_tenant_template
|
||||
-- e drop_tenant_schema NÃO precisam ser tocados (eles inserem/removem em
|
||||
-- tenant_schemas, o que aciona o refresh no COMMIT).
|
||||
--
|
||||
-- PRÉ-REQUISITO: aplicar f5_pgrst_refresh_schemas.supabase_admin.sql ANTES desta
|
||||
-- migration (a função precisa existir e estar owned por supabase_admin).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public' AND p.proname = 'refresh_pgrst_schemas'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'F5: public.refresh_pgrst_schemas() não existe — aplique manual/f5_pgrst_refresh_schemas.supabase_admin.sql primeiro';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Trigger function (owned por postgres) só delega pro refresh (SECDEF supabase_admin)
|
||||
CREATE OR REPLACE FUNCTION public.trg_refresh_pgrst_schemas()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM public.refresh_pgrst_schemas();
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_tenant_schemas_pgrst_refresh ON public.tenant_schemas;
|
||||
CREATE TRIGGER trg_tenant_schemas_pgrst_refresh
|
||||
AFTER INSERT OR DELETE OR UPDATE OF schema_name ON public.tenant_schemas
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION public.trg_refresh_pgrst_schemas();
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- F6.0 — Clona os schemas dos tenants JÁ EXISTENTES (cutover)
|
||||
--
|
||||
-- Até aqui só tenants criados PÓS-F2 ganhavam schema. Os 9 tenants que já
|
||||
-- existiam precisam dos seus schemas (ainda vazios — dados migram na F6.1).
|
||||
-- Idempotente: só clona quem não está em tenant_schemas. Cada clone dispara
|
||||
-- o trigger da F5 (expõe no PostgREST).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
v_schema text;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT t.id, t.slug
|
||||
FROM public.tenants t
|
||||
LEFT JOIN public.tenant_schemas ts ON ts.tenant_id = t.id
|
||||
WHERE ts.tenant_id IS NULL
|
||||
ORDER BY t.created_at, t.id
|
||||
LOOP
|
||||
v_schema := public.clone_tenant_template(r.id);
|
||||
RAISE NOTICE 'F6.0: tenant % (%) -> %', r.id, r.slug, v_schema;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,76 @@
|
||||
-- =============================================================================
|
||||
-- F6.2 Lote A — anexa triggers schema-agnósticos aos schemas tenant
|
||||
--
|
||||
-- O clone (LIKE INCLUDING ALL) NÃO copia triggers. As tabelas tenant nos
|
||||
-- schemas precisam dos triggers de negócio. Lote A: os PROVADAMENTE
|
||||
-- schema-agnósticos (só mexem em NEW/OLD, não referenciam outras tabelas) —
|
||||
-- seguros pra anexar sem reescrever a função:
|
||||
-- família updated_at (8) + prevent_promoting_to_system +
|
||||
-- prevent_system_group_changes
|
||||
-- Os schema-aware (financeiro/audit/notif/timeline/sync) vêm no Lote B.
|
||||
--
|
||||
-- attach_agnostic_triggers(schema) recria, no schema dado, os triggers de
|
||||
-- public cuja função está na whitelist agnóstica. A função do trigger continua
|
||||
-- sendo a de public (agnóstica → funciona em qualquer schema). Backfill dos 9;
|
||||
-- o wiring no clone_tenant_template acontece no fim da F6.2 (com todos prontos).
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.attach_agnostic_triggers(p_schema text)
|
||||
RETURNS int
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path TO 'public', 'pg_temp'
|
||||
AS $$
|
||||
DECLARE
|
||||
agnostic text[] := ARRAY[
|
||||
'set_updated_at','fn_clinical_notes_updated_at','set_insurance_plans_updated_at',
|
||||
'set_medicos_updated_at','set_services_updated_at','set_updated_at_recurrence',
|
||||
'update_payment_settings_updated_at','update_professional_pricing_updated_at',
|
||||
'prevent_promoting_to_system','prevent_system_group_changes'
|
||||
];
|
||||
r record;
|
||||
v_def text;
|
||||
v_count int := 0;
|
||||
BEGIN
|
||||
IF p_schema NOT LIKE 'tenant\_%' THEN
|
||||
RAISE EXCEPTION 'attach_agnostic_triggers: schema inválido %', p_schema;
|
||||
END IF;
|
||||
|
||||
FOR r IN
|
||||
SELECT c.relname AS tab, t.tgname, pg_get_triggerdef(t.oid) AS def
|
||||
FROM pg_trigger t
|
||||
JOIN pg_class c ON c.oid = t.tgrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_proc p ON p.oid = t.tgfoid
|
||||
WHERE n.nspname = 'public' AND NOT t.tgisinternal
|
||||
AND p.proname = ANY(agnostic)
|
||||
AND EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = p_schema AND table_name = c.relname)
|
||||
LOOP
|
||||
-- redireciona o ON public.<tab> pro schema do tenant (a função fica em public)
|
||||
v_def := replace(r.def, 'ON public.' || r.tab || ' ', 'ON ' || p_schema || '.' || r.tab || ' ');
|
||||
IF v_def = r.def THEN
|
||||
RAISE EXCEPTION 'attach_agnostic_triggers: não consegui redirecionar % (%.%)', r.tgname, p_schema, r.tab;
|
||||
END IF;
|
||||
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', r.tgname, p_schema, r.tab);
|
||||
EXECUTE v_def;
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Backfill dos 9 schemas existentes
|
||||
DO $$
|
||||
DECLARE r record; v int;
|
||||
BEGIN
|
||||
FOR r IN SELECT schema_name FROM public.tenant_schemas ORDER BY schema_name LOOP
|
||||
v := public.attach_agnostic_triggers(r.schema_name);
|
||||
RAISE NOTICE 'F6.2A %: % triggers agnósticos', r.schema_name, v;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- =============================================================================
|
||||
-- Freemium F1 — limite de pacientes do plano therapist_free
|
||||
--
|
||||
-- clinic_free já traz max_patients=30 (em plan_features.limits da feature
|
||||
-- clinic_calendar, semeado). O therapist_free não tinha limite de pacientes.
|
||||
-- Pendura max_patients=20 na feature 'patients.manage' (a que o therapist_free
|
||||
-- já possui, enabled).
|
||||
--
|
||||
-- REGRA DE OURO: referenciar plano/feature POR KEY via subquery, nunca por uuid
|
||||
-- hardcoded (uuids divergem entre ambientes). Idempotente (merge no jsonb).
|
||||
-- O enforcement em runtime (trigger) está em manual/freemium_f1_plan_limits.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.plan_features pf
|
||||
SET limits = COALESCE(pf.limits, '{}'::jsonb) || jsonb_build_object('max_patients', 20)
|
||||
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
|
||||
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
|
||||
|
||||
-- Sanidade: garante que o limite ficou gravado (1 linha afetada esperada).
|
||||
DO $$
|
||||
DECLARE v int;
|
||||
BEGIN
|
||||
SELECT (pf.limits->>'max_patients')::int INTO v
|
||||
FROM public.plan_features pf
|
||||
WHERE pf.plan_id = (SELECT id FROM public.plans WHERE key = 'therapist_free')
|
||||
AND pf.feature_id = (SELECT id FROM public.features WHERE key = 'patients.manage');
|
||||
IF v IS DISTINCT FROM 20 THEN
|
||||
RAISE EXCEPTION 'therapist_free max_patients esperado 20, obtido %', v;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- =============================================================================
|
||||
-- Fix (regressão schema-per-tenant): log_audit_change quebra INSERT em tabelas
|
||||
-- GLOBAIS auditadas.
|
||||
--
|
||||
-- log_audit_change deriva o tenant via tenant_id_for_schema(TG_TABLE_SCHEMA).
|
||||
-- Para tabelas em tenant_<slug> isso resolve certo. Mas o trigger também está
|
||||
-- em public.tenant_members (tabela global) — e tenant_id_for_schema('public')
|
||||
-- retorna NULL, violando audit_logs.tenant_id (NOT NULL). Resultado: QUALQUER
|
||||
-- INSERT em tenant_members falhava (provisionamento, aceite de convite).
|
||||
--
|
||||
-- Fix: quando o schema não resolve um tenant (tabela global), usa o tenant_id
|
||||
-- da própria linha (tenant_members.tenant_id). Se ainda assim for NULL, não
|
||||
-- audita — mas NUNCA quebra a operação de negócio.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.log_audit_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public','pg_temp'
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_tenant_id uuid; v_entity_id text; v_old jsonb; v_new jsonb; v_changed text[];
|
||||
v_heavy text[] := ARRAY['content','content_html','content_json','raw_data','signature_data','pdf_blob','binary','body_html','body_text'];
|
||||
v_noise text[] := ARRAY['updated_at','last_seen_at','last_activity_at'];
|
||||
BEGIN
|
||||
v_tenant_id := public.tenant_id_for_schema(TG_TABLE_SCHEMA);
|
||||
|
||||
-- tabela global (public.*): cai no tenant_id da própria linha, se existir
|
||||
IF v_tenant_id IS NULL THEN
|
||||
v_tenant_id := NULLIF(to_jsonb(COALESCE(NEW, OLD)) ->> 'tenant_id', '')::uuid;
|
||||
END IF;
|
||||
|
||||
-- sem tenant resolvível → não audita, mas não quebra a operação
|
||||
IF v_tenant_id IS NULL THEN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_entity_id := OLD.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := NULL;
|
||||
ELSIF TG_OP = 'INSERT' THEN
|
||||
v_entity_id := NEW.id::text; v_old := NULL; v_new := to_jsonb(NEW) - v_heavy;
|
||||
ELSE
|
||||
v_entity_id := NEW.id::text; v_old := to_jsonb(OLD) - v_heavy; v_new := to_jsonb(NEW) - v_heavy;
|
||||
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;
|
||||
IF v_changed IS NULL THEN RETURN NEW; END IF;
|
||||
IF v_changed <@ v_noise 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 $function$;
|
||||
+1699
-122
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
-- Extensions
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:33.041Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:49.849Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- All Functions
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
|
||||
-- Total: 192
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.921Z
|
||||
-- Total: 211
|
||||
|
||||
CREATE FUNCTION auth.email() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
@@ -287,6 +287,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
|
||||
select 'ok'::text;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH msgs AS (
|
||||
SELECT
|
||||
m.id,
|
||||
m.tenant_id,
|
||||
m.direction,
|
||||
m.created_at,
|
||||
m.patient_id,
|
||||
m.from_number,
|
||||
m.to_number,
|
||||
-- mesma logica da view conversation_threads
|
||||
COALESCE(
|
||||
m.patient_id::text,
|
||||
'anon:' || COALESCE(
|
||||
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
|
||||
'unknown'
|
||||
)
|
||||
) AS tk
|
||||
FROM public.conversation_messages m
|
||||
WHERE m.tenant_id = p_tenant_id
|
||||
AND m.direction IN ('inbound', 'outbound')
|
||||
AND m.created_at >= p_from
|
||||
AND m.created_at <= p_to
|
||||
),
|
||||
with_prev AS (
|
||||
SELECT *,
|
||||
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
|
||||
FROM msgs
|
||||
),
|
||||
run_starts AS (
|
||||
-- Primeira mensagem de cada "run inbound"
|
||||
SELECT tk, tenant_id, created_at AS inbound_started_at
|
||||
FROM with_prev
|
||||
WHERE direction = 'inbound'
|
||||
AND (prev_direction IS NULL OR prev_direction = 'outbound')
|
||||
)
|
||||
SELECT
|
||||
r.tk AS thread_key,
|
||||
r.inbound_started_at,
|
||||
o.created_at AS responded_at,
|
||||
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
|
||||
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
|
||||
a.assigned_to AS responder_id
|
||||
FROM run_starts r
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT created_at
|
||||
FROM public.conversation_messages m2
|
||||
WHERE m2.tenant_id = r.tenant_id
|
||||
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
|
||||
AND m2.direction = 'outbound'
|
||||
AND m2.created_at > r.inbound_started_at
|
||||
ORDER BY m2.created_at
|
||||
LIMIT 1
|
||||
) o ON true
|
||||
LEFT JOIN public.conversation_assignments a
|
||||
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
|
||||
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -451,6 +513,95 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_new_balance INT;
|
||||
v_current_balance INT;
|
||||
v_topup_net INT;
|
||||
v_usage_total INT;
|
||||
v_removable INT;
|
||||
v_clean_note TEXT;
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_required';
|
||||
END IF;
|
||||
|
||||
IF p_amount IS NULL OR p_amount = 0 THEN
|
||||
RAISE EXCEPTION 'amount_required';
|
||||
END IF;
|
||||
|
||||
IF ABS(p_amount) > 1000 THEN
|
||||
RAISE EXCEPTION 'amount_exceeds_limit_1000';
|
||||
END IF;
|
||||
|
||||
IF p_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'admin_id_required';
|
||||
END IF;
|
||||
|
||||
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
|
||||
IF v_clean_note IS NOT NULL THEN
|
||||
v_clean_note := LEFT(v_clean_note, 500);
|
||||
END IF;
|
||||
|
||||
IF p_amount > 0 THEN
|
||||
-- ADICIONAR
|
||||
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
|
||||
VALUES (p_tenant_id, p_amount)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
|
||||
low_balance_alerted_at = NULL
|
||||
RETURNING balance INTO v_new_balance;
|
||||
|
||||
ELSE
|
||||
-- REMOVER (amount < 0)
|
||||
SELECT balance INTO v_current_balance
|
||||
FROM public.whatsapp_credits_balance
|
||||
WHERE tenant_id = p_tenant_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'tenant_has_no_balance';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind = 'usage';
|
||||
|
||||
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||
v_removable := LEAST(v_removable, v_current_balance);
|
||||
|
||||
IF ABS(p_amount) > v_removable THEN
|
||||
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
|
||||
END IF;
|
||||
|
||||
UPDATE public.whatsapp_credits_balance
|
||||
SET balance = balance + p_amount -- p_amount ja e negativo
|
||||
WHERE tenant_id = p_tenant_id
|
||||
RETURNING balance INTO v_new_balance;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.whatsapp_credits_transactions
|
||||
(tenant_id, kind, amount, balance_after, admin_id, note)
|
||||
VALUES
|
||||
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
|
||||
|
||||
RETURN v_new_balance;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
@@ -1053,9 +1204,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status IN ('cancelado', 'excluido')
|
||||
AND OLD.status NOT IN ('cancelado', 'excluido')
|
||||
THEN
|
||||
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(
|
||||
NEW.patient_id, NULL, NEW.id
|
||||
);
|
||||
@@ -1429,6 +1578,101 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_intake RECORD;
|
||||
v_tenant_id UUID;
|
||||
v_thread_key TEXT;
|
||||
v_phone TEXT;
|
||||
v_note_body TEXT;
|
||||
v_admin_id UUID;
|
||||
v_msg_id BIGINT;
|
||||
BEGIN
|
||||
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::UUID; END IF;
|
||||
|
||||
-- Tenant_id vem via owner_id (tenant_members)
|
||||
SELECT tenant_id INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_intake.owner_id
|
||||
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END
|
||||
LIMIT 1;
|
||||
|
||||
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||
|
||||
-- Normaliza telefone pra thread_key
|
||||
v_phone := regexp_replace(COALESCE(v_intake.telefone, ''), '\D', '', 'g');
|
||||
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55' || v_phone; END IF;
|
||||
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||
v_thread_key := 'anon:' || v_phone;
|
||||
|
||||
-- Nota com dados coletados
|
||||
v_note_body := format(
|
||||
'📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||
E'\n', E'\n',
|
||||
COALESCE(v_intake.nome_completo, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.telefone, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.email_principal, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.onde_nos_conheceu, '—'),
|
||||
E'\n', E'\n',
|
||||
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI'),
|
||||
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI')
|
||||
);
|
||||
|
||||
-- Pega 1 admin do tenant pra preencher created_by da nota
|
||||
SELECT user_id INTO v_admin_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = v_tenant_id
|
||||
AND role IN ('tenant_admin', 'clinic_admin')
|
||||
AND status = 'active'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_admin_id IS NULL THEN
|
||||
v_admin_id := v_intake.owner_id;
|
||||
END IF;
|
||||
|
||||
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
|
||||
INSERT INTO public.conversation_messages
|
||||
(tenant_id, channel, direction, from_number, to_number, body, provider,
|
||||
provider_raw, kanban_status)
|
||||
VALUES (
|
||||
v_tenant_id, 'whatsapp', 'inbound',
|
||||
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||
NULL,
|
||||
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.',
|
||||
COALESCE(v_intake.nome_completo, 'Visitante')),
|
||||
'system',
|
||||
jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id),
|
||||
'awaiting_us'
|
||||
) RETURNING id INTO v_msg_id;
|
||||
|
||||
-- Cria nota interna
|
||||
INSERT INTO public.conversation_notes
|
||||
(tenant_id, thread_key, contact_number, body, created_by)
|
||||
VALUES (
|
||||
v_tenant_id, v_thread_key,
|
||||
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||
v_note_body, v_admin_id
|
||||
);
|
||||
|
||||
-- Atualiza intake
|
||||
UPDATE public.patient_intake_requests
|
||||
SET status = 'abandoned_lead',
|
||||
lead_thread_key = v_thread_key,
|
||||
updated_at = now()
|
||||
WHERE id = p_intake_id;
|
||||
|
||||
RETURN p_intake_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -3032,6 +3276,84 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
SELECT
|
||||
r.responder_id AS therapist_id,
|
||||
COUNT(*)::INT AS runs_count,
|
||||
AVG(r.response_seconds)::INT AS avg_seconds,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
|
||||
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE r.responder_id IS NOT NULL
|
||||
GROUP BY r.responder_id
|
||||
ORDER BY avg_seconds ASC;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH runs AS (
|
||||
SELECT r.inbound_started_at, r.response_seconds
|
||||
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT
|
||||
-- Janela alinhada a p_from: bucket_index * N dias + p_from
|
||||
p_from + (
|
||||
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||
* p_bucket_days * interval '1 day'
|
||||
) AS bucket_start,
|
||||
response_seconds
|
||||
FROM runs
|
||||
)
|
||||
SELECT
|
||||
bucket_start,
|
||||
COUNT(*)::INT AS runs_count,
|
||||
AVG(response_seconds)::INT AS avg_seconds
|
||||
FROM bucketed
|
||||
GROUP BY bucket_start
|
||||
ORDER BY bucket_start;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_threshold_seconds INT;
|
||||
BEGIN
|
||||
-- Pega threshold do SLA (se habilitado)
|
||||
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
|
||||
INTO v_threshold_seconds
|
||||
FROM public.conversation_sla_rules
|
||||
WHERE tenant_id = p_tenant_id;
|
||||
|
||||
RETURN QUERY
|
||||
WITH runs AS (
|
||||
SELECT r.response_seconds, r.responder_id
|
||||
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::INT AS runs_count,
|
||||
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
|
||||
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
|
||||
COALESCE(MIN(response_seconds), 0) AS min_seconds,
|
||||
COALESCE(MAX(response_seconds), 0) AS max_seconds,
|
||||
v_threshold_seconds AS sla_threshold_seconds,
|
||||
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
|
||||
CASE
|
||||
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
|
||||
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
|
||||
END AS sla_compliance_rate
|
||||
FROM runs;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -3138,6 +3460,146 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_url TEXT;
|
||||
v_key TEXT;
|
||||
BEGIN
|
||||
-- So dispara se status realmente mudou
|
||||
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
|
||||
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
|
||||
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Precisa de paciente vinculado (senao nao tem telefone)
|
||||
IF NEW.patient_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Busca settings
|
||||
BEGIN
|
||||
v_url := current_setting('app.settings.supabase_url', true);
|
||||
v_key := current_setting('app.settings.service_role_key', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Settings nao configuradas — silencioso
|
||||
RETURN NEW;
|
||||
END;
|
||||
|
||||
IF v_url IS NULL OR v_key IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Fire and forget (pg_net)
|
||||
PERFORM net.http_post(
|
||||
url := v_url || '/functions/v1/send-session-status-notification',
|
||||
headers := jsonb_build_object(
|
||||
'Authorization', 'Bearer ' || v_key,
|
||||
'Content-Type', 'application/json'
|
||||
),
|
||||
body := jsonb_build_object(
|
||||
'event_id', NEW.id,
|
||||
'old_status', OLD.status,
|
||||
'new_status', NEW.status
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_thread_key TEXT;
|
||||
BEGIN
|
||||
-- So processa outbound
|
||||
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||
|
||||
-- Calcula thread_key no mesmo padrao da view conversation_threads
|
||||
v_thread_key := COALESCE(
|
||||
NEW.patient_id::text,
|
||||
'anon:' || COALESCE(NEW.to_number, 'unknown')
|
||||
);
|
||||
|
||||
UPDATE public.conversation_sla_breaches
|
||||
SET resolved_at = now(),
|
||||
resolved_by_message_id = NEW.id
|
||||
WHERE tenant_id = NEW.tenant_id
|
||||
AND thread_key = v_thread_key
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_detail TEXT;
|
||||
BEGIN
|
||||
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
|
||||
IF NEW.balance < NEW.low_balance_threshold
|
||||
AND NEW.low_balance_alerted_at IS NULL THEN
|
||||
|
||||
v_detail := format(
|
||||
'Saldo atual: %s credito(s). Alerta configurado em %s. '
|
||||
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
|
||||
NEW.balance,
|
||||
NEW.low_balance_threshold
|
||||
);
|
||||
|
||||
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
|
||||
INSERT INTO public.notifications
|
||||
(owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||
SELECT
|
||||
u.user_id,
|
||||
NEW.tenant_id,
|
||||
'system_alert',
|
||||
NEW.tenant_id,
|
||||
'whatsapp_credits_balance',
|
||||
jsonb_build_object(
|
||||
'title', 'Saldo de WhatsApp baixo',
|
||||
'detail', v_detail,
|
||||
'severity', 'warn',
|
||||
'deeplink', '/configuracoes/creditos-whatsapp'
|
||||
)
|
||||
FROM (
|
||||
SELECT owner_id AS user_id
|
||||
FROM public.notification_channels
|
||||
WHERE tenant_id = NEW.tenant_id
|
||||
AND channel = 'whatsapp'
|
||||
AND is_active = true
|
||||
AND deleted_at IS NULL
|
||||
UNION
|
||||
SELECT user_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = NEW.tenant_id
|
||||
AND role IN ('clinic_admin', 'tenant_admin')
|
||||
AND status = 'active'
|
||||
) u
|
||||
WHERE u.user_id IS NOT NULL;
|
||||
|
||||
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
|
||||
-- reseta alerted_at pra NULL (acontece em purchase/topup)
|
||||
NEW.low_balance_alerted_at := now();
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
@@ -3456,6 +3918,48 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_balance INT := 0;
|
||||
v_topup_net INT := 0;
|
||||
v_usage_total INT := 0;
|
||||
v_removable INT := 0;
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(b.balance, 0) INTO v_balance
|
||||
FROM public.whatsapp_credits_balance b
|
||||
WHERE b.tenant_id = p_tenant_id;
|
||||
|
||||
v_balance := COALESCE(v_balance, 0);
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind = 'usage';
|
||||
|
||||
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||
v_removable := LEAST(v_removable, v_balance);
|
||||
|
||||
RETURN QUERY SELECT
|
||||
v_balance,
|
||||
v_removable,
|
||||
GREATEST(0, v_balance - v_removable),
|
||||
v_topup_net,
|
||||
v_usage_total;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -4689,6 +5193,120 @@ begin
|
||||
end;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH purchases AS (
|
||||
SELECT p.paid_at, p.amount_brl
|
||||
FROM public.whatsapp_credit_purchases p
|
||||
WHERE p.status = 'paid'
|
||||
AND p.paid_at >= p_from
|
||||
AND p.paid_at <= p_to
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT
|
||||
p_from + (
|
||||
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||
* p_bucket_days * interval '1 day'
|
||||
) AS bucket_start,
|
||||
amount_brl
|
||||
FROM purchases
|
||||
)
|
||||
SELECT
|
||||
bucket_start,
|
||||
COUNT(*)::INT AS purchases_count,
|
||||
SUM(amount_brl)::NUMERIC AS revenue_brl
|
||||
FROM bucketed
|
||||
GROUP BY bucket_start
|
||||
ORDER BY bucket_start;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
|
||||
COUNT(*)::INT AS purchases_count,
|
||||
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
|
||||
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
|
||||
END AS avg_ticket_brl
|
||||
FROM public.whatsapp_credit_purchases p
|
||||
WHERE p.status = 'paid'
|
||||
AND p.paid_at >= p_from
|
||||
AND p.paid_at <= p_to;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
p.package_id,
|
||||
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
|
||||
-- atual pra consolidar pacotes renomeados
|
||||
COALESCE(
|
||||
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
|
||||
p.package_name
|
||||
) AS package_name,
|
||||
COUNT(*)::INT AS purchases_count,
|
||||
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
|
||||
SUM(p.credits)::INT AS credits_sold
|
||||
FROM public.whatsapp_credit_purchases p
|
||||
WHERE p.status = 'paid'
|
||||
AND p.paid_at >= p_from
|
||||
AND p.paid_at <= p_to
|
||||
GROUP BY p.package_id, p.package_name
|
||||
ORDER BY revenue_brl DESC
|
||||
LIMIT 10;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
|
||||
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
|
||||
COALESCE(SUM(balance), 0)::INT AS current_balance,
|
||||
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
|
||||
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
|
||||
END AS usage_rate,
|
||||
COUNT(*)::INT AS tenants_with_balance
|
||||
FROM public.whatsapp_credits_balance;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -5006,7 +5624,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
-- Sess??o (locked + sempre ativa)
|
||||
-- Sessão (locked + sempre ativa)
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
@@ -5014,7 +5632,7 @@ begin
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
|
||||
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
@@ -5028,7 +5646,7 @@ begin
|
||||
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||
end if;
|
||||
|
||||
-- Supervis??o
|
||||
-- Supervisão
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
@@ -5036,10 +5654,10 @@ begin
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
|
||||
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||
end if;
|
||||
|
||||
-- Aula ??? (corrigido)
|
||||
-- Aula
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||
@@ -5050,7 +5668,7 @@ begin
|
||||
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||
end if;
|
||||
|
||||
-- An??lise pessoal
|
||||
-- Análise pessoal
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
@@ -5058,13 +5676,26 @@ begin
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
|
||||
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||
end if;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Campos padr??o (idempotentes por (commitment_id, key))
|
||||
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||
-- -------------------------------------------------------
|
||||
|
||||
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
@@ -5084,11 +5715,11 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Supervis??o
|
||||
-- Supervisão
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
@@ -5107,7 +5738,7 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
@@ -5130,11 +5761,11 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- An??lise
|
||||
-- Análise
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
@@ -5153,7 +5784,7 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
end;
|
||||
@@ -5335,6 +5966,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
|
||||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.conversation_sla_breaches
|
||||
SET notified_at = now(),
|
||||
notification_count = notification_count + 1
|
||||
WHERE id = p_breach_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_existing_id UUID;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_and_thread_required';
|
||||
END IF;
|
||||
|
||||
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||
SELECT id INTO v_existing_id
|
||||
FROM public.conversation_sla_breaches
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND thread_key = p_thread_key
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.conversation_sla_breaches
|
||||
SET assigned_to = COALESCE(p_assigned_to, assigned_to),
|
||||
last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at)
|
||||
WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.conversation_sla_breaches
|
||||
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||
VALUES
|
||||
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
@@ -6698,6 +7378,87 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.whatsapp_connection_incidents
|
||||
SET notified_at = now(),
|
||||
notification_count = notification_count + 1
|
||||
WHERE id = p_incident_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id UUID;
|
||||
v_provider TEXT;
|
||||
v_existing_id UUID;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
-- Busca tenant/provider do channel
|
||||
SELECT tenant_id, provider INTO v_tenant_id, v_provider
|
||||
FROM public.notification_channels
|
||||
WHERE id = p_channel_id
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'channel_not_found';
|
||||
END IF;
|
||||
|
||||
IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN
|
||||
RAISE EXCEPTION 'invalid_kind: %', p_kind;
|
||||
END IF;
|
||||
|
||||
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||
SELECT id INTO v_existing_id
|
||||
FROM public.whatsapp_connection_incidents
|
||||
WHERE channel_id = p_channel_id
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
IF FOUND THEN
|
||||
-- Atualiza o incident existente com detalhes frescos
|
||||
UPDATE public.whatsapp_connection_incidents
|
||||
SET last_state = COALESCE(p_last_state, last_state),
|
||||
details = COALESCE(p_details, details),
|
||||
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
|
||||
WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
|
||||
-- Abre novo
|
||||
INSERT INTO public.whatsapp_connection_incidents
|
||||
(tenant_id, channel_id, provider, kind, last_state, details)
|
||||
VALUES
|
||||
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count INT := 0;
|
||||
BEGIN
|
||||
UPDATE public.whatsapp_connection_incidents
|
||||
SET resolved_at = now(),
|
||||
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT
|
||||
WHERE channel_id = p_channel_id
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Functions: auth
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.941Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
|
||||
-- Total: 4
|
||||
|
||||
CREATE FUNCTION auth.email() RETURNS text
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Functions: extensions
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.942Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
|
||||
-- Total: 6
|
||||
|
||||
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Functions: pgbouncer
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.943Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.917Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Functions: public
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.944Z
|
||||
-- Total: 153
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.918Z
|
||||
-- Total: 172
|
||||
|
||||
CREATE FUNCTION public.__rls_ping() RETURNS text
|
||||
LANGUAGE sql STABLE
|
||||
@@ -8,6 +8,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
|
||||
select 'ok'::text;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH msgs AS (
|
||||
SELECT
|
||||
m.id,
|
||||
m.tenant_id,
|
||||
m.direction,
|
||||
m.created_at,
|
||||
m.patient_id,
|
||||
m.from_number,
|
||||
m.to_number,
|
||||
-- mesma logica da view conversation_threads
|
||||
COALESCE(
|
||||
m.patient_id::text,
|
||||
'anon:' || COALESCE(
|
||||
CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
|
||||
'unknown'
|
||||
)
|
||||
) AS tk
|
||||
FROM public.conversation_messages m
|
||||
WHERE m.tenant_id = p_tenant_id
|
||||
AND m.direction IN ('inbound', 'outbound')
|
||||
AND m.created_at >= p_from
|
||||
AND m.created_at <= p_to
|
||||
),
|
||||
with_prev AS (
|
||||
SELECT *,
|
||||
LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
|
||||
FROM msgs
|
||||
),
|
||||
run_starts AS (
|
||||
-- Primeira mensagem de cada "run inbound"
|
||||
SELECT tk, tenant_id, created_at AS inbound_started_at
|
||||
FROM with_prev
|
||||
WHERE direction = 'inbound'
|
||||
AND (prev_direction IS NULL OR prev_direction = 'outbound')
|
||||
)
|
||||
SELECT
|
||||
r.tk AS thread_key,
|
||||
r.inbound_started_at,
|
||||
o.created_at AS responded_at,
|
||||
EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
|
||||
-- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
|
||||
a.assigned_to AS responder_id
|
||||
FROM run_starts r
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT created_at
|
||||
FROM public.conversation_messages m2
|
||||
WHERE m2.tenant_id = r.tenant_id
|
||||
AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
|
||||
AND m2.direction = 'outbound'
|
||||
AND m2.created_at > r.inbound_started_at
|
||||
ORDER BY m2.created_at
|
||||
LIMIT 1
|
||||
) o ON true
|
||||
LEFT JOIN public.conversation_assignments a
|
||||
ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
|
||||
WHERE o.created_at IS NOT NULL; -- so runs que foram respondidos
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -172,6 +234,95 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_new_balance INT;
|
||||
v_current_balance INT;
|
||||
v_topup_net INT;
|
||||
v_usage_total INT;
|
||||
v_removable INT;
|
||||
v_clean_note TEXT;
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
IF p_tenant_id IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_required';
|
||||
END IF;
|
||||
|
||||
IF p_amount IS NULL OR p_amount = 0 THEN
|
||||
RAISE EXCEPTION 'amount_required';
|
||||
END IF;
|
||||
|
||||
IF ABS(p_amount) > 1000 THEN
|
||||
RAISE EXCEPTION 'amount_exceeds_limit_1000';
|
||||
END IF;
|
||||
|
||||
IF p_admin_id IS NULL THEN
|
||||
RAISE EXCEPTION 'admin_id_required';
|
||||
END IF;
|
||||
|
||||
v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
|
||||
IF v_clean_note IS NOT NULL THEN
|
||||
v_clean_note := LEFT(v_clean_note, 500);
|
||||
END IF;
|
||||
|
||||
IF p_amount > 0 THEN
|
||||
-- ADICIONAR
|
||||
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
|
||||
VALUES (p_tenant_id, p_amount)
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
|
||||
low_balance_alerted_at = NULL
|
||||
RETURNING balance INTO v_new_balance;
|
||||
|
||||
ELSE
|
||||
-- REMOVER (amount < 0)
|
||||
SELECT balance INTO v_current_balance
|
||||
FROM public.whatsapp_credits_balance
|
||||
WHERE tenant_id = p_tenant_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'tenant_has_no_balance';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind = 'usage';
|
||||
|
||||
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||
v_removable := LEAST(v_removable, v_current_balance);
|
||||
|
||||
IF ABS(p_amount) > v_removable THEN
|
||||
RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
|
||||
END IF;
|
||||
|
||||
UPDATE public.whatsapp_credits_balance
|
||||
SET balance = balance + p_amount -- p_amount ja e negativo
|
||||
WHERE tenant_id = p_tenant_id
|
||||
RETURNING balance INTO v_new_balance;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.whatsapp_credits_transactions
|
||||
(tenant_id, kind, amount, balance_after, admin_id, note)
|
||||
VALUES
|
||||
(p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);
|
||||
|
||||
RETURN v_new_balance;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
@@ -774,9 +925,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status IN ('cancelado', 'excluido')
|
||||
AND OLD.status NOT IN ('cancelado', 'excluido')
|
||||
THEN
|
||||
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||
PERFORM public.cancel_patient_pending_notifications(
|
||||
NEW.patient_id, NULL, NEW.id
|
||||
);
|
||||
@@ -1150,6 +1299,101 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_intake RECORD;
|
||||
v_tenant_id UUID;
|
||||
v_thread_key TEXT;
|
||||
v_phone TEXT;
|
||||
v_note_body TEXT;
|
||||
v_admin_id UUID;
|
||||
v_msg_id BIGINT;
|
||||
BEGIN
|
||||
SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
|
||||
IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::UUID; END IF;
|
||||
|
||||
-- Tenant_id vem via owner_id (tenant_members)
|
||||
SELECT tenant_id INTO v_tenant_id
|
||||
FROM public.tenant_members
|
||||
WHERE user_id = v_intake.owner_id
|
||||
ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END
|
||||
LIMIT 1;
|
||||
|
||||
IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;
|
||||
|
||||
-- Normaliza telefone pra thread_key
|
||||
v_phone := regexp_replace(COALESCE(v_intake.telefone, ''), '\D', '', 'g');
|
||||
IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55' || v_phone; END IF;
|
||||
IF v_phone = '' THEN v_phone := 'unknown'; END IF;
|
||||
v_thread_key := 'anon:' || v_phone;
|
||||
|
||||
-- Nota com dados coletados
|
||||
v_note_body := format(
|
||||
'📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
|
||||
E'\n', E'\n',
|
||||
COALESCE(v_intake.nome_completo, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.telefone, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.email_principal, '—'),
|
||||
E'\n',
|
||||
COALESCE(v_intake.onde_nos_conheceu, '—'),
|
||||
E'\n', E'\n',
|
||||
to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI'),
|
||||
to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI')
|
||||
);
|
||||
|
||||
-- Pega 1 admin do tenant pra preencher created_by da nota
|
||||
SELECT user_id INTO v_admin_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = v_tenant_id
|
||||
AND role IN ('tenant_admin', 'clinic_admin')
|
||||
AND status = 'active'
|
||||
LIMIT 1;
|
||||
|
||||
IF v_admin_id IS NULL THEN
|
||||
v_admin_id := v_intake.owner_id;
|
||||
END IF;
|
||||
|
||||
-- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
|
||||
INSERT INTO public.conversation_messages
|
||||
(tenant_id, channel, direction, from_number, to_number, body, provider,
|
||||
provider_raw, kanban_status)
|
||||
VALUES (
|
||||
v_tenant_id, 'whatsapp', 'inbound',
|
||||
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||
NULL,
|
||||
format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.',
|
||||
COALESCE(v_intake.nome_completo, 'Visitante')),
|
||||
'system',
|
||||
jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id),
|
||||
'awaiting_us'
|
||||
) RETURNING id INTO v_msg_id;
|
||||
|
||||
-- Cria nota interna
|
||||
INSERT INTO public.conversation_notes
|
||||
(tenant_id, thread_key, contact_number, body, created_by)
|
||||
VALUES (
|
||||
v_tenant_id, v_thread_key,
|
||||
CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
|
||||
v_note_body, v_admin_id
|
||||
);
|
||||
|
||||
-- Atualiza intake
|
||||
UPDATE public.patient_intake_requests
|
||||
SET status = 'abandoned_lead',
|
||||
lead_thread_key = v_thread_key,
|
||||
updated_at = now()
|
||||
WHERE id = p_intake_id;
|
||||
|
||||
RETURN p_intake_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -2753,6 +2997,84 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
SELECT
|
||||
r.responder_id AS therapist_id,
|
||||
COUNT(*)::INT AS runs_count,
|
||||
AVG(r.response_seconds)::INT AS avg_seconds,
|
||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
|
||||
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE r.responder_id IS NOT NULL
|
||||
GROUP BY r.responder_id
|
||||
ORDER BY avg_seconds ASC;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
WITH runs AS (
|
||||
SELECT r.inbound_started_at, r.response_seconds
|
||||
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT
|
||||
-- Janela alinhada a p_from: bucket_index * N dias + p_from
|
||||
p_from + (
|
||||
FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||
* p_bucket_days * interval '1 day'
|
||||
) AS bucket_start,
|
||||
response_seconds
|
||||
FROM runs
|
||||
)
|
||||
SELECT
|
||||
bucket_start,
|
||||
COUNT(*)::INT AS runs_count,
|
||||
AVG(response_seconds)::INT AS avg_seconds
|
||||
FROM bucketed
|
||||
GROUP BY bucket_start
|
||||
ORDER BY bucket_start;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_threshold_seconds INT;
|
||||
BEGIN
|
||||
-- Pega threshold do SLA (se habilitado)
|
||||
SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
|
||||
INTO v_threshold_seconds
|
||||
FROM public.conversation_sla_rules
|
||||
WHERE tenant_id = p_tenant_id;
|
||||
|
||||
RETURN QUERY
|
||||
WITH runs AS (
|
||||
SELECT r.response_seconds, r.responder_id
|
||||
FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
|
||||
WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::INT AS runs_count,
|
||||
COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
|
||||
COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
|
||||
COALESCE(MIN(response_seconds), 0) AS min_seconds,
|
||||
COALESCE(MAX(response_seconds), 0) AS max_seconds,
|
||||
v_threshold_seconds AS sla_threshold_seconds,
|
||||
COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
|
||||
CASE
|
||||
WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
|
||||
ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
|
||||
END AS sla_compliance_rate
|
||||
FROM runs;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -2859,6 +3181,146 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_url TEXT;
|
||||
v_key TEXT;
|
||||
BEGIN
|
||||
-- So dispara se status realmente mudou
|
||||
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
|
||||
-- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
|
||||
IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Precisa de paciente vinculado (senao nao tem telefone)
|
||||
IF NEW.patient_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Busca settings
|
||||
BEGIN
|
||||
v_url := current_setting('app.settings.supabase_url', true);
|
||||
v_key := current_setting('app.settings.service_role_key', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- Settings nao configuradas — silencioso
|
||||
RETURN NEW;
|
||||
END;
|
||||
|
||||
IF v_url IS NULL OR v_key IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Fire and forget (pg_net)
|
||||
PERFORM net.http_post(
|
||||
url := v_url || '/functions/v1/send-session-status-notification',
|
||||
headers := jsonb_build_object(
|
||||
'Authorization', 'Bearer ' || v_key,
|
||||
'Content-Type', 'application/json'
|
||||
),
|
||||
body := jsonb_build_object(
|
||||
'event_id', NEW.id,
|
||||
'old_status', OLD.status,
|
||||
'new_status', NEW.status
|
||||
)
|
||||
);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_thread_key TEXT;
|
||||
BEGIN
|
||||
-- So processa outbound
|
||||
IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;
|
||||
|
||||
-- Calcula thread_key no mesmo padrao da view conversation_threads
|
||||
v_thread_key := COALESCE(
|
||||
NEW.patient_id::text,
|
||||
'anon:' || COALESCE(NEW.to_number, 'unknown')
|
||||
);
|
||||
|
||||
UPDATE public.conversation_sla_breaches
|
||||
SET resolved_at = now(),
|
||||
resolved_by_message_id = NEW.id
|
||||
WHERE tenant_id = NEW.tenant_id
|
||||
AND thread_key = v_thread_key
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_detail TEXT;
|
||||
BEGIN
|
||||
-- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
|
||||
IF NEW.balance < NEW.low_balance_threshold
|
||||
AND NEW.low_balance_alerted_at IS NULL THEN
|
||||
|
||||
v_detail := format(
|
||||
'Saldo atual: %s credito(s). Alerta configurado em %s. '
|
||||
'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
|
||||
NEW.balance,
|
||||
NEW.low_balance_threshold
|
||||
);
|
||||
|
||||
-- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
|
||||
INSERT INTO public.notifications
|
||||
(owner_id, tenant_id, type, ref_id, ref_table, payload)
|
||||
SELECT
|
||||
u.user_id,
|
||||
NEW.tenant_id,
|
||||
'system_alert',
|
||||
NEW.tenant_id,
|
||||
'whatsapp_credits_balance',
|
||||
jsonb_build_object(
|
||||
'title', 'Saldo de WhatsApp baixo',
|
||||
'detail', v_detail,
|
||||
'severity', 'warn',
|
||||
'deeplink', '/configuracoes/creditos-whatsapp'
|
||||
)
|
||||
FROM (
|
||||
SELECT owner_id AS user_id
|
||||
FROM public.notification_channels
|
||||
WHERE tenant_id = NEW.tenant_id
|
||||
AND channel = 'whatsapp'
|
||||
AND is_active = true
|
||||
AND deleted_at IS NULL
|
||||
UNION
|
||||
SELECT user_id
|
||||
FROM public.tenant_members
|
||||
WHERE tenant_id = NEW.tenant_id
|
||||
AND role IN ('clinic_admin', 'tenant_admin')
|
||||
AND status = 'active'
|
||||
) u
|
||||
WHERE u.user_id IS NOT NULL;
|
||||
|
||||
-- Anti-spam: so alerta de novo depois que add_whatsapp_credits
|
||||
-- reseta alerted_at pra NULL (acontece em purchase/topup)
|
||||
NEW.low_balance_alerted_at := now();
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
@@ -3177,6 +3639,48 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_balance INT := 0;
|
||||
v_topup_net INT := 0;
|
||||
v_usage_total INT := 0;
|
||||
v_removable INT := 0;
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(b.balance, 0) INTO v_balance
|
||||
FROM public.whatsapp_credits_balance b
|
||||
WHERE b.tenant_id = p_tenant_id;
|
||||
|
||||
v_balance := COALESCE(v_balance, 0);
|
||||
|
||||
SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind IN ('topup_manual', 'adjustment', 'refund');
|
||||
|
||||
SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
|
||||
FROM public.whatsapp_credits_transactions
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND kind = 'usage';
|
||||
|
||||
v_removable := GREATEST(0, v_topup_net - v_usage_total);
|
||||
v_removable := LEAST(v_removable, v_balance);
|
||||
|
||||
RETURN QUERY SELECT
|
||||
v_balance,
|
||||
v_removable,
|
||||
GREATEST(0, v_balance - v_removable),
|
||||
v_topup_net,
|
||||
v_usage_total;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
@@ -4410,6 +4914,120 @@ begin
|
||||
end;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
WITH purchases AS (
|
||||
SELECT p.paid_at, p.amount_brl
|
||||
FROM public.whatsapp_credit_purchases p
|
||||
WHERE p.status = 'paid'
|
||||
AND p.paid_at >= p_from
|
||||
AND p.paid_at <= p_to
|
||||
),
|
||||
bucketed AS (
|
||||
SELECT
|
||||
p_from + (
|
||||
FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
|
||||
* p_bucket_days * interval '1 day'
|
||||
) AS bucket_start,
|
||||
amount_brl
|
||||
FROM purchases
|
||||
)
|
||||
SELECT
|
||||
bucket_start,
|
||||
COUNT(*)::INT AS purchases_count,
|
||||
SUM(amount_brl)::NUMERIC AS revenue_brl
|
||||
FROM bucketed
|
||||
GROUP BY bucket_start
|
||||
ORDER BY bucket_start;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
|
||||
COUNT(*)::INT AS purchases_count,
|
||||
COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
|
||||
COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
|
||||
END AS avg_ticket_brl
|
||||
FROM public.whatsapp_credit_purchases p
|
||||
WHERE p.status = 'paid'
|
||||
AND p.paid_at >= p_from
|
||||
AND p.paid_at <= p_to;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
p.package_id,
|
||||
-- Nome snapshot do momento da compra; se tem package_id, usa o nome
|
||||
-- atual pra consolidar pacotes renomeados
|
||||
COALESCE(
|
||||
(SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
|
||||
p.package_name
|
||||
) AS package_name,
|
||||
COUNT(*)::INT AS purchases_count,
|
||||
SUM(p.amount_brl)::NUMERIC AS revenue_brl,
|
||||
SUM(p.credits)::INT AS credits_sold
|
||||
FROM public.whatsapp_credit_purchases p
|
||||
WHERE p.status = 'paid'
|
||||
AND p.paid_at >= p_from
|
||||
AND p.paid_at <= p_to
|
||||
GROUP BY p.package_id, p.package_name
|
||||
ORDER BY revenue_brl DESC
|
||||
LIMIT 10;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT public.is_saas_admin() THEN
|
||||
RAISE EXCEPTION 'permission_denied';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
|
||||
COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
|
||||
COALESCE(SUM(balance), 0)::INT AS current_balance,
|
||||
CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
|
||||
ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
|
||||
END AS usage_rate,
|
||||
COUNT(*)::INT AS tenants_with_balance
|
||||
FROM public.whatsapp_credits_balance;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
@@ -4727,7 +5345,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
|
||||
declare
|
||||
v_id uuid;
|
||||
begin
|
||||
-- Sess??o (locked + sempre ativa)
|
||||
-- Sessão (locked + sempre ativa)
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
@@ -4735,7 +5353,7 @@ begin
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'session', true, true, 'Sess??o', 'Sess??o com paciente');
|
||||
(p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
@@ -4749,7 +5367,7 @@ begin
|
||||
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
|
||||
end if;
|
||||
|
||||
-- Supervis??o
|
||||
-- Supervisão
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
@@ -4757,10 +5375,10 @@ begin
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'supervision', false, true, 'Supervis??o', 'Supervis??o');
|
||||
(p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
|
||||
end if;
|
||||
|
||||
-- Aula ??? (corrigido)
|
||||
-- Aula
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
|
||||
@@ -4771,7 +5389,7 @@ begin
|
||||
(p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
|
||||
end if;
|
||||
|
||||
-- An??lise pessoal
|
||||
-- Análise pessoal
|
||||
if not exists (
|
||||
select 1 from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
@@ -4779,13 +5397,26 @@ begin
|
||||
insert into public.determined_commitments
|
||||
(tenant_id, is_native, native_key, is_locked, active, name, description)
|
||||
values
|
||||
(p_tenant_id, true, 'analysis', false, true, 'An??lise Pessoal', 'Minha an??lise pessoal');
|
||||
(p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
|
||||
end if;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Campos padr??o (idempotentes por (commitment_id, key))
|
||||
-- Campos padrão (idempotentes por (commitment_id, key))
|
||||
-- -------------------------------------------------------
|
||||
|
||||
-- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
|
||||
limit 1;
|
||||
|
||||
if v_id is not null then
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Leitura
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
@@ -4805,11 +5436,11 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- Supervis??o
|
||||
-- Supervisão
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
|
||||
@@ -4828,7 +5459,7 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
@@ -4851,11 +5482,11 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
|
||||
-- An??lise
|
||||
-- Análise
|
||||
select id into v_id
|
||||
from public.determined_commitments
|
||||
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
|
||||
@@ -4874,7 +5505,7 @@ begin
|
||||
|
||||
if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
|
||||
insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
|
||||
values (p_tenant_id, v_id, 'notes', 'Observa????o', 'textarea', false, 30);
|
||||
values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
|
||||
end if;
|
||||
end if;
|
||||
end;
|
||||
@@ -5056,6 +5687,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
|
||||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.conversation_sla_breaches
|
||||
SET notified_at = now(),
|
||||
notification_count = notification_count + 1
|
||||
WHERE id = p_breach_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_existing_id UUID;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN
|
||||
RAISE EXCEPTION 'tenant_and_thread_required';
|
||||
END IF;
|
||||
|
||||
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||
SELECT id INTO v_existing_id
|
||||
FROM public.conversation_sla_breaches
|
||||
WHERE tenant_id = p_tenant_id
|
||||
AND thread_key = p_thread_key
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.conversation_sla_breaches
|
||||
SET assigned_to = COALESCE(p_assigned_to, assigned_to),
|
||||
last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at)
|
||||
WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.conversation_sla_breaches
|
||||
(tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
|
||||
VALUES
|
||||
(p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
@@ -6419,6 +7099,87 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.whatsapp_connection_incidents
|
||||
SET notified_at = now(),
|
||||
notification_count = notification_count + 1
|
||||
WHERE id = p_incident_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tenant_id UUID;
|
||||
v_provider TEXT;
|
||||
v_existing_id UUID;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
-- Busca tenant/provider do channel
|
||||
SELECT tenant_id, provider INTO v_tenant_id, v_provider
|
||||
FROM public.notification_channels
|
||||
WHERE id = p_channel_id
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'channel_not_found';
|
||||
END IF;
|
||||
|
||||
IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN
|
||||
RAISE EXCEPTION 'invalid_kind: %', p_kind;
|
||||
END IF;
|
||||
|
||||
-- Ja tem aberto? Retorna o mesmo id (idempotente)
|
||||
SELECT id INTO v_existing_id
|
||||
FROM public.whatsapp_connection_incidents
|
||||
WHERE channel_id = p_channel_id
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
IF FOUND THEN
|
||||
-- Atualiza o incident existente com detalhes frescos
|
||||
UPDATE public.whatsapp_connection_incidents
|
||||
SET last_state = COALESCE(p_last_state, last_state),
|
||||
details = COALESCE(p_details, details),
|
||||
kind = p_kind -- pode mudar de qr_pending → disconnected, por ex
|
||||
WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
|
||||
-- Abre novo
|
||||
INSERT INTO public.whatsapp_connection_incidents
|
||||
(tenant_id, channel_id, provider, kind, last_state, details)
|
||||
VALUES
|
||||
(v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
RETURN v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count INT := 0;
|
||||
BEGIN
|
||||
UPDATE public.whatsapp_connection_incidents
|
||||
SET resolved_at = now(),
|
||||
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT
|
||||
WHERE channel_id = p_channel_id
|
||||
AND resolved_at IS NULL;
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN v_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
|
||||
LANGUAGE sql STABLE
|
||||
AS $$
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Functions: realtime
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.949Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.919Z
|
||||
-- Total: 12
|
||||
|
||||
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Functions: storage
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.920Z
|
||||
-- Total: 15
|
||||
|
||||
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Functions: supabase_functions
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.920Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Addons / Créditos
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.927Z
|
||||
-- Total: 7
|
||||
|
||||
CREATE TABLE public.addon_credits (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Agenda / Agendamento
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.927Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.agenda_bloqueios (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Central SaaS (docs/FAQ)
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.931Z
|
||||
-- Total: 4
|
||||
|
||||
CREATE TABLE public.saas_doc_votos (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Tables: Comunicação / Notificações
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
|
||||
-- Total: 14
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.929Z
|
||||
-- Total: 15
|
||||
|
||||
CREATE TABLE public.notification_logs (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
@@ -260,7 +260,23 @@ CREATE TABLE public.notifications (
|
||||
read_at timestamp with time zone,
|
||||
archived boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text, 'inbound_message'::text])))
|
||||
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text, 'inbound_message'::text, 'system_alert'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_twilio_config (
|
||||
id boolean DEFAULT true NOT NULL,
|
||||
account_sid text,
|
||||
whatsapp_webhook_url text,
|
||||
usd_brl_rate numeric(10,4) DEFAULT 5.5 NOT NULL,
|
||||
margin_multiplier numeric(10,4) DEFAULT 1.4 NOT NULL,
|
||||
notes text,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_twilio_config_mult_chk CHECK (((margin_multiplier >= (1)::numeric) AND (margin_multiplier <= (10)::numeric))),
|
||||
CONSTRAINT saas_twilio_config_rate_chk CHECK (((usd_brl_rate > (0)::numeric) AND (usd_brl_rate < (100)::numeric))),
|
||||
CONSTRAINT saas_twilio_config_sid_chk CHECK (((account_sid IS NULL) OR (account_sid ~ '^AC[a-zA-Z0-9]{32}$'::text))),
|
||||
CONSTRAINT saas_twilio_config_singleton CHECK ((id = true)),
|
||||
CONSTRAINT saas_twilio_config_url_chk CHECK (((whatsapp_webhook_url IS NULL) OR (whatsapp_webhook_url ~ '^https?://'::text)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.twilio_subaccount_usage (
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
-- Tables: CRM Conversas (WhatsApp)
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
|
||||
-- Total: 10
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
|
||||
-- Total: 16
|
||||
|
||||
CREATE TABLE public.conversation_assignments (
|
||||
tenant_id uuid NOT NULL,
|
||||
thread_key text NOT NULL,
|
||||
patient_id uuid,
|
||||
contact_number text,
|
||||
assigned_to uuid,
|
||||
assigned_by uuid NOT NULL,
|
||||
assigned_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_autoreply_log (
|
||||
id bigint NOT NULL,
|
||||
@@ -25,6 +37,39 @@ CREATE TABLE public.conversation_autoreply_settings (
|
||||
CONSTRAINT conversation_autoreply_settings_schedule_mode_check CHECK ((schedule_mode = ANY (ARRAY['agenda'::text, 'business_hours'::text, 'custom'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_bot_sessions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
thread_key text NOT NULL,
|
||||
contact_number text,
|
||||
current_step integer DEFAULT 0 NOT NULL,
|
||||
collected_data jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
status text DEFAULT 'active'::text NOT NULL,
|
||||
started_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
last_advance_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
completed_at timestamp with time zone,
|
||||
abandoned_at timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT conversation_bot_sessions_status_check CHECK ((status = ANY (ARRAY['active'::text, 'completed'::text, 'abandoned_idle'::text, 'abandoned_manual'::text, 'opted_out'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_bots (
|
||||
tenant_id uuid NOT NULL,
|
||||
enabled boolean DEFAULT false NOT NULL,
|
||||
greeting_message text DEFAULT 'Olá! 👋 Sou o assistente virtual. Vou te fazer algumas perguntas rápidas pra a equipe preparar seu atendimento.'::text NOT NULL,
|
||||
closing_message text DEFAULT 'Obrigado! Recebemos suas informações e a equipe entrará em contato em breve. 💙'::text NOT NULL,
|
||||
steps jsonb DEFAULT jsonb_build_array(jsonb_build_object('prompt', 'Qual seu nome completo?', 'variable', 'nome_completo', 'type', 'text'), jsonb_build_object('prompt', 'O que te levou a buscar atendimento? Pode me contar brevemente.', 'variable', 'motivo', 'type', 'text'), jsonb_build_object('prompt', 'Prefere atendimento online ou presencial?', 'variable', 'modalidade', 'type', 'text'), jsonb_build_object('prompt', 'Qual o melhor dia e horário pra você? (Ex: terça à tarde)', 'variable', 'horario_preferido', 'type', 'text')) NOT NULL,
|
||||
trigger_mode text DEFAULT 'new_contact'::text NOT NULL,
|
||||
trigger_keywords text[] DEFAULT ARRAY[]::text[] NOT NULL,
|
||||
idle_timeout_minutes integer DEFAULT 30 NOT NULL,
|
||||
respect_optout boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT conversation_bots_idle_timeout_minutes_check CHECK (((idle_timeout_minutes >= 5) AND (idle_timeout_minutes <= 1440))),
|
||||
CONSTRAINT conversation_bots_trigger_mode_check CHECK ((trigger_mode = ANY (ARRAY['new_contact'::text, 'all_unassigned'::text, 'keyword'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_messages (
|
||||
id bigint NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
@@ -99,6 +144,39 @@ CREATE TABLE public.conversation_optouts (
|
||||
CONSTRAINT conversation_optouts_source_check CHECK ((source = ANY (ARRAY['keyword'::text, 'manual'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_sla_breaches (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
thread_key text NOT NULL,
|
||||
assigned_to uuid,
|
||||
last_inbound_at timestamp with time zone NOT NULL,
|
||||
threshold_minutes_at_breach integer NOT NULL,
|
||||
breached_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
resolved_at timestamp with time zone,
|
||||
resolved_by_message_id bigint,
|
||||
notified_at timestamp with time zone,
|
||||
notification_count integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_sla_rules (
|
||||
tenant_id uuid NOT NULL,
|
||||
enabled boolean DEFAULT false NOT NULL,
|
||||
threshold_minutes integer DEFAULT 60 NOT NULL,
|
||||
respect_business_hours boolean DEFAULT true NOT NULL,
|
||||
business_hours_start time without time zone DEFAULT '08:00:00'::time without time zone NOT NULL,
|
||||
business_hours_end time without time zone DEFAULT '18:00:00'::time without time zone NOT NULL,
|
||||
business_days smallint[] DEFAULT ARRAY[(1)::smallint, (2)::smallint, (3)::smallint, (4)::smallint, (5)::smallint] NOT NULL,
|
||||
alert_scope text DEFAULT 'assigned_only'::text NOT NULL,
|
||||
notify_admin_on_breach boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT conversation_sla_rules_alert_scope_check CHECK ((alert_scope = ANY (ARRAY['assigned_only'::text, 'all'::text]))),
|
||||
CONSTRAINT conversation_sla_rules_business_days_check CHECK (((array_length(business_days, 1) >= 1) AND (array_length(business_days, 1) <= 7))),
|
||||
CONSTRAINT conversation_sla_rules_threshold_minutes_check CHECK (((threshold_minutes >= 1) AND (threshold_minutes <= 1440)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.conversation_tags (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid,
|
||||
@@ -134,7 +212,7 @@ CREATE TABLE public.session_reminder_logs (
|
||||
to_phone text,
|
||||
provider_message_id text,
|
||||
conversation_message_id bigint,
|
||||
CONSTRAINT session_reminder_logs_reminder_type_check CHECK ((reminder_type = ANY (ARRAY['24h'::text, '2h'::text])))
|
||||
CONSTRAINT session_reminder_logs_reminder_type_check CHECK ((reminder_type = ANY (ARRAY['24h'::text, '2h'::text, 'manual'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.session_reminder_settings (
|
||||
@@ -153,3 +231,22 @@ CREATE TABLE public.session_reminder_settings (
|
||||
CONSTRAINT session_reminder_settings_template_24h_check CHECK (((length(template_24h) > 0) AND (length(template_24h) <= 2000))),
|
||||
CONSTRAINT session_reminder_settings_template_2h_check CHECK (((length(template_2h) > 0) AND (length(template_2h) <= 2000)))
|
||||
);
|
||||
|
||||
CREATE TABLE public.whatsapp_connection_incidents (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
provider text NOT NULL,
|
||||
kind text NOT NULL,
|
||||
last_state text,
|
||||
details jsonb,
|
||||
started_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
resolved_at timestamp with time zone,
|
||||
duration_seconds integer,
|
||||
notified_at timestamp with time zone,
|
||||
notification_count integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT whatsapp_connection_incidents_kind_check CHECK ((kind = ANY (ARRAY['disconnected'::text, 'error'::text, 'qr_pending'::text, 'connecting'::text, 'unknown'::text]))),
|
||||
CONSTRAINT whatsapp_connection_incidents_provider_check CHECK ((provider = ANY (ARRAY['evolution_api'::text, 'twilio'::text])))
|
||||
);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Tables: Dev / Tracking
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.dev_auditoria_items (
|
||||
id bigint NOT NULL,
|
||||
categoria character varying(120),
|
||||
titulo text NOT NULL,
|
||||
descricao_problema text,
|
||||
solucao text,
|
||||
severidade character varying(20),
|
||||
status character varying(20) DEFAULT 'aberto'::character varying NOT NULL,
|
||||
resolvido_em date,
|
||||
sessao_resolucao character varying(160),
|
||||
arquivo_afetado text,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT dev_auditoria_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
|
||||
CONSTRAINT dev_auditoria_items_status_check CHECK (((status)::text = ANY ((ARRAY['aberto'::character varying, 'em_analise'::character varying, 'resolvido'::character varying, 'wontfix'::character varying, 'duplicado'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_comparison_competitor_status (
|
||||
id bigint NOT NULL,
|
||||
comparison_id bigint NOT NULL,
|
||||
competitor_id bigint NOT NULL,
|
||||
status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
|
||||
nota text,
|
||||
fonte character varying(20),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_comparison_competitor_status_fonte_check CHECK (((fonte IS NULL) OR ((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))),
|
||||
CONSTRAINT dev_comparison_competitor_status_status_check CHECK (((status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_comparison_matrix (
|
||||
id bigint NOT NULL,
|
||||
dominio character varying(120),
|
||||
feature text NOT NULL,
|
||||
nosso_status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
|
||||
nossa_nota text,
|
||||
importancia character varying(20),
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_comparison_matrix_importancia_check CHECK (((importancia IS NULL) OR ((importancia)::text = ANY ((ARRAY['alta'::character varying, 'media'::character varying, 'baixa'::character varying])::text[])))),
|
||||
CONSTRAINT dev_comparison_matrix_nosso_status_check CHECK (((nosso_status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_competitor_features (
|
||||
id bigint NOT NULL,
|
||||
competitor_id bigint NOT NULL,
|
||||
categoria character varying(120),
|
||||
nome text NOT NULL,
|
||||
descricao text,
|
||||
fonte character varying(20) DEFAULT 'publico'::character varying NOT NULL,
|
||||
fonte_url text,
|
||||
data_fonte date,
|
||||
destaque boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT dev_competitor_features_fonte_check CHECK (((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_competitors (
|
||||
id bigint NOT NULL,
|
||||
slug character varying(80) NOT NULL,
|
||||
nome character varying(160) NOT NULL,
|
||||
pais character varying(40),
|
||||
foco character varying(160),
|
||||
pricing text,
|
||||
posicionamento text,
|
||||
url text,
|
||||
ultima_pesquisa date,
|
||||
notas text,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_generation_log (
|
||||
id bigint NOT NULL,
|
||||
tipo character varying(40) NOT NULL,
|
||||
comando text,
|
||||
sucesso boolean DEFAULT false NOT NULL,
|
||||
stdout text,
|
||||
stderr text,
|
||||
duration_ms integer,
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
trigger_user_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_roadmap_items (
|
||||
id bigint NOT NULL,
|
||||
phase_id bigint NOT NULL,
|
||||
numero integer,
|
||||
bloco character varying(160),
|
||||
feature text NOT NULL,
|
||||
descricao text,
|
||||
esforco character varying(4),
|
||||
prioridade character varying(20),
|
||||
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
|
||||
notas text,
|
||||
assignee character varying(120),
|
||||
data_inicio date,
|
||||
data_conclusao date,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_roadmap_items_esforco_check CHECK (((esforco IS NULL) OR ((esforco)::text = ANY ((ARRAY['S'::character varying, 'M'::character varying, 'L'::character varying, 'XL'::character varying])::text[])))),
|
||||
CONSTRAINT dev_roadmap_items_prioridade_check CHECK (((prioridade IS NULL) OR ((prioridade)::text = ANY ((ARRAY['bloqueador'::character varying, 'alta'::character varying, 'media'::character varying, 'diferencial'::character varying])::text[])))),
|
||||
CONSTRAINT dev_roadmap_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'em_andamento'::character varying, 'concluido'::character varying, 'cancelado'::character varying, 'bloqueado'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_roadmap_phases (
|
||||
id bigint NOT NULL,
|
||||
numero integer NOT NULL,
|
||||
nome character varying(160) NOT NULL,
|
||||
objetivo text,
|
||||
timeline_sugerida character varying(160),
|
||||
criterio_saida text,
|
||||
status character varying(20) DEFAULT 'planejada'::character varying NOT NULL,
|
||||
data_inicio date,
|
||||
data_fim date,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_roadmap_phases_status_check CHECK (((status)::text = ANY ((ARRAY['planejada'::character varying, 'em_andamento'::character varying, 'concluida'::character varying, 'arquivada'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_test_items (
|
||||
id bigint NOT NULL,
|
||||
area character varying(80) NOT NULL,
|
||||
categoria character varying(120),
|
||||
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),
|
||||
status character varying(20) DEFAULT 'ok'::character varying NOT NULL,
|
||||
last_run_at timestamp with time zone,
|
||||
sessao_criacao character varying(160),
|
||||
notas text,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_test_items_status_check CHECK (((status)::text = ANY ((ARRAY['ok'::character varying, 'falhando'::character varying, 'pendente'::character varying, 'obsoleto'::character varying, 'a_escrever'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_verificacoes_items (
|
||||
id bigint NOT NULL,
|
||||
area character varying(80) NOT NULL,
|
||||
categoria character varying(120),
|
||||
titulo text NOT NULL,
|
||||
descricao text,
|
||||
resultado text,
|
||||
acao_sugerida text,
|
||||
severidade character varying(20),
|
||||
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
|
||||
verificado_em date,
|
||||
sessao_verificacao character varying(160),
|
||||
arquivo_afetado text,
|
||||
auditoria_item_id bigint,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_verificacoes_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
|
||||
CONSTRAINT dev_verificacoes_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'verificando'::character varying, 'ok'::character varying, 'problema'::character varying, 'corrigido'::character varying, 'wontfix'::character varying])::text[])))
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Documentos
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.928Z
|
||||
-- Total: 6
|
||||
|
||||
CREATE TABLE public.document_access_logs (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Estrutura / Calendário
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE TABLE public.feriados (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Financeiro
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.926Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.financial_records (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Tables: outros
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
|
||||
-- Total: 17
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.926Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE TABLE public._db_migrations (
|
||||
id integer NOT NULL,
|
||||
@@ -9,259 +9,3 @@ CREATE TABLE public._db_migrations (
|
||||
category text DEFAULT 'migration'::text NOT NULL,
|
||||
applied_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.audit_logs (
|
||||
id bigint NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid,
|
||||
entity_type text NOT NULL,
|
||||
entity_id text,
|
||||
action text NOT NULL,
|
||||
old_values jsonb,
|
||||
new_values jsonb,
|
||||
changed_fields text[],
|
||||
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT audit_logs_action_check CHECK ((action = ANY (ARRAY['insert'::text, 'update'::text, 'delete'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_auditoria_items (
|
||||
id bigint NOT NULL,
|
||||
categoria character varying(120),
|
||||
titulo text NOT NULL,
|
||||
descricao_problema text,
|
||||
solucao text,
|
||||
severidade character varying(20),
|
||||
status character varying(20) DEFAULT 'aberto'::character varying NOT NULL,
|
||||
resolvido_em date,
|
||||
sessao_resolucao character varying(160),
|
||||
arquivo_afetado text,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT dev_auditoria_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
|
||||
CONSTRAINT dev_auditoria_items_status_check CHECK (((status)::text = ANY ((ARRAY['aberto'::character varying, 'em_analise'::character varying, 'resolvido'::character varying, 'wontfix'::character varying, 'duplicado'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_comparison_competitor_status (
|
||||
id bigint NOT NULL,
|
||||
comparison_id bigint NOT NULL,
|
||||
competitor_id bigint NOT NULL,
|
||||
status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
|
||||
nota text,
|
||||
fonte character varying(20),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_comparison_competitor_status_fonte_check CHECK (((fonte IS NULL) OR ((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))),
|
||||
CONSTRAINT dev_comparison_competitor_status_status_check CHECK (((status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_comparison_matrix (
|
||||
id bigint NOT NULL,
|
||||
dominio character varying(120),
|
||||
feature text NOT NULL,
|
||||
nosso_status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
|
||||
nossa_nota text,
|
||||
importancia character varying(20),
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_comparison_matrix_importancia_check CHECK (((importancia IS NULL) OR ((importancia)::text = ANY ((ARRAY['alta'::character varying, 'media'::character varying, 'baixa'::character varying])::text[])))),
|
||||
CONSTRAINT dev_comparison_matrix_nosso_status_check CHECK (((nosso_status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_competitor_features (
|
||||
id bigint NOT NULL,
|
||||
competitor_id bigint NOT NULL,
|
||||
categoria character varying(120),
|
||||
nome text NOT NULL,
|
||||
descricao text,
|
||||
fonte character varying(20) DEFAULT 'publico'::character varying NOT NULL,
|
||||
fonte_url text,
|
||||
data_fonte date,
|
||||
destaque boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT dev_competitor_features_fonte_check CHECK (((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_competitors (
|
||||
id bigint NOT NULL,
|
||||
slug character varying(80) NOT NULL,
|
||||
nome character varying(160) NOT NULL,
|
||||
pais character varying(40),
|
||||
foco character varying(160),
|
||||
pricing text,
|
||||
posicionamento text,
|
||||
url text,
|
||||
ultima_pesquisa date,
|
||||
notas text,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_generation_log (
|
||||
id bigint NOT NULL,
|
||||
tipo character varying(40) NOT NULL,
|
||||
comando text,
|
||||
sucesso boolean DEFAULT false NOT NULL,
|
||||
stdout text,
|
||||
stderr text,
|
||||
duration_ms integer,
|
||||
metadata jsonb DEFAULT '{}'::jsonb,
|
||||
trigger_user_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_roadmap_items (
|
||||
id bigint NOT NULL,
|
||||
phase_id bigint NOT NULL,
|
||||
numero integer,
|
||||
bloco character varying(160),
|
||||
feature text NOT NULL,
|
||||
descricao text,
|
||||
esforco character varying(4),
|
||||
prioridade character varying(20),
|
||||
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
|
||||
notas text,
|
||||
assignee character varying(120),
|
||||
data_inicio date,
|
||||
data_conclusao date,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_roadmap_items_esforco_check CHECK (((esforco IS NULL) OR ((esforco)::text = ANY ((ARRAY['S'::character varying, 'M'::character varying, 'L'::character varying, 'XL'::character varying])::text[])))),
|
||||
CONSTRAINT dev_roadmap_items_prioridade_check CHECK (((prioridade IS NULL) OR ((prioridade)::text = ANY ((ARRAY['bloqueador'::character varying, 'alta'::character varying, 'media'::character varying, 'diferencial'::character varying])::text[])))),
|
||||
CONSTRAINT dev_roadmap_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'em_andamento'::character varying, 'concluido'::character varying, 'cancelado'::character varying, 'bloqueado'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_roadmap_phases (
|
||||
id bigint NOT NULL,
|
||||
numero integer NOT NULL,
|
||||
nome character varying(160) NOT NULL,
|
||||
objetivo text,
|
||||
timeline_sugerida character varying(160),
|
||||
criterio_saida text,
|
||||
status character varying(20) DEFAULT 'planejada'::character varying NOT NULL,
|
||||
data_inicio date,
|
||||
data_fim date,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_roadmap_phases_status_check CHECK (((status)::text = ANY ((ARRAY['planejada'::character varying, 'em_andamento'::character varying, 'concluida'::character varying, 'arquivada'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_test_items (
|
||||
id bigint NOT NULL,
|
||||
area character varying(80) NOT NULL,
|
||||
categoria character varying(120),
|
||||
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),
|
||||
status character varying(20) DEFAULT 'ok'::character varying NOT NULL,
|
||||
last_run_at timestamp with time zone,
|
||||
sessao_criacao character varying(160),
|
||||
notas text,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_test_items_status_check CHECK (((status)::text = ANY ((ARRAY['ok'::character varying, 'falhando'::character varying, 'pendente'::character varying, 'obsoleto'::character varying, 'a_escrever'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.dev_verificacoes_items (
|
||||
id bigint NOT NULL,
|
||||
area character varying(80) NOT NULL,
|
||||
categoria character varying(120),
|
||||
titulo text NOT NULL,
|
||||
descricao text,
|
||||
resultado text,
|
||||
acao_sugerida text,
|
||||
severidade character varying(20),
|
||||
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
|
||||
verificado_em date,
|
||||
sessao_verificacao character varying(160),
|
||||
arquivo_afetado text,
|
||||
auditoria_item_id bigint,
|
||||
tags text[] DEFAULT '{}'::text[],
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT dev_verificacoes_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
|
||||
CONSTRAINT dev_verificacoes_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'verificando'::character varying, 'ok'::character varying, 'problema'::character varying, 'corrigido'::character varying, 'wontfix'::character varying])::text[])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.math_challenges (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
question text NOT NULL,
|
||||
answer integer NOT NULL,
|
||||
used boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '00:05:00'::interval) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_invite_attempts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
token text NOT NULL,
|
||||
ok boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
client_info text,
|
||||
owner_id uuid,
|
||||
tenant_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.public_submission_attempts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
ip_hash text,
|
||||
success boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
blocked_by text,
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_security_config (
|
||||
id boolean DEFAULT true NOT NULL,
|
||||
honeypot_enabled boolean DEFAULT true NOT NULL,
|
||||
rate_limit_enabled boolean DEFAULT true NOT NULL,
|
||||
rate_limit_window_min integer DEFAULT 10 NOT NULL,
|
||||
rate_limit_max_attempts integer DEFAULT 5 NOT NULL,
|
||||
captcha_after_failures integer DEFAULT 3 NOT NULL,
|
||||
captcha_required_globally boolean DEFAULT false NOT NULL,
|
||||
block_duration_min integer DEFAULT 30 NOT NULL,
|
||||
captcha_required_window_min integer DEFAULT 60 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_security_config_singleton CHECK ((id = true))
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_twilio_config (
|
||||
id boolean DEFAULT true NOT NULL,
|
||||
account_sid text,
|
||||
whatsapp_webhook_url text,
|
||||
usd_brl_rate numeric(10,4) DEFAULT 5.5 NOT NULL,
|
||||
margin_multiplier numeric(10,4) DEFAULT 1.4 NOT NULL,
|
||||
notes text,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_twilio_config_mult_chk CHECK (((margin_multiplier >= (1)::numeric) AND (margin_multiplier <= (10)::numeric))),
|
||||
CONSTRAINT saas_twilio_config_rate_chk CHECK (((usd_brl_rate > (0)::numeric) AND (usd_brl_rate < (100)::numeric))),
|
||||
CONSTRAINT saas_twilio_config_sid_chk CHECK (((account_sid IS NULL) OR (account_sid ~ '^AC[a-zA-Z0-9]{32}$'::text))),
|
||||
CONSTRAINT saas_twilio_config_singleton CHECK ((id = true)),
|
||||
CONSTRAINT saas_twilio_config_url_chk CHECK (((whatsapp_webhook_url IS NULL) OR (whatsapp_webhook_url ~ '^https?://'::text)))
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Pacientes
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.929Z
|
||||
-- Total: 16
|
||||
|
||||
CREATE TABLE public.patient_status_history (
|
||||
@@ -247,7 +247,9 @@ CREATE TABLE public.patient_intake_requests (
|
||||
nacionalidade text,
|
||||
avatar_url text,
|
||||
tenant_id uuid,
|
||||
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'converted'::text, 'rejected'::text])))
|
||||
last_progress_at timestamp with time zone,
|
||||
lead_thread_key text,
|
||||
CONSTRAINT chk_intakes_status CHECK ((status = ANY (ARRAY['new'::text, 'in_progress'::text, 'converted'::text, 'rejected'::text, 'abandoned_lead'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_invites (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: SaaS / Planos
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.953Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.925Z
|
||||
-- Total: 18
|
||||
|
||||
CREATE TABLE public.subscriptions (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- Tables: Segurança / Auditoria
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.928Z
|
||||
-- Total: 6
|
||||
|
||||
CREATE TABLE public.audit_logs (
|
||||
id bigint NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid,
|
||||
entity_type text NOT NULL,
|
||||
entity_id text,
|
||||
action text NOT NULL,
|
||||
old_values jsonb,
|
||||
new_values jsonb,
|
||||
changed_fields text[],
|
||||
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT audit_logs_action_check CHECK ((action = ANY (ARRAY['insert'::text, 'update'::text, 'delete'::text])))
|
||||
);
|
||||
|
||||
CREATE TABLE public.math_challenges (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
question text NOT NULL,
|
||||
answer integer NOT NULL,
|
||||
used boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone DEFAULT (now() + '00:05:00'::interval) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.patient_invite_attempts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
token text NOT NULL,
|
||||
ok boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
client_info text,
|
||||
owner_id uuid,
|
||||
tenant_id uuid,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.public_submission_attempts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
ip_hash text,
|
||||
success boolean NOT NULL,
|
||||
error_code text,
|
||||
error_msg text,
|
||||
blocked_by text,
|
||||
user_agent text,
|
||||
metadata jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE public.saas_security_config (
|
||||
id boolean DEFAULT true NOT NULL,
|
||||
honeypot_enabled boolean DEFAULT true NOT NULL,
|
||||
rate_limit_enabled boolean DEFAULT true NOT NULL,
|
||||
rate_limit_window_min integer DEFAULT 10 NOT NULL,
|
||||
rate_limit_max_attempts integer DEFAULT 5 NOT NULL,
|
||||
captcha_after_failures integer DEFAULT 3 NOT NULL,
|
||||
captcha_required_globally boolean DEFAULT false NOT NULL,
|
||||
block_duration_min integer DEFAULT 30 NOT NULL,
|
||||
captcha_required_window_min integer DEFAULT 60 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_by uuid,
|
||||
CONSTRAINT saas_security_config_singleton CHECK ((id = true))
|
||||
);
|
||||
|
||||
CREATE TABLE public.submission_rate_limits (
|
||||
ip_hash text NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
attempt_count integer DEFAULT 0 NOT NULL,
|
||||
fail_count integer DEFAULT 0 NOT NULL,
|
||||
window_start timestamp with time zone DEFAULT now() NOT NULL,
|
||||
blocked_until timestamp with time zone,
|
||||
requires_captcha_until timestamp with time zone,
|
||||
last_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Tables: Segurança / Rate limiting
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
|
||||
-- Total: 1
|
||||
|
||||
CREATE TABLE public.submission_rate_limits (
|
||||
ip_hash text NOT NULL,
|
||||
endpoint text NOT NULL,
|
||||
attempt_count integer DEFAULT 0 NOT NULL,
|
||||
fail_count integer DEFAULT 0 NOT NULL,
|
||||
window_start timestamp with time zone DEFAULT now() NOT NULL,
|
||||
blocked_until timestamp with time zone,
|
||||
requires_captcha_until timestamp with time zone,
|
||||
last_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Serviços / Prontuários
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.930Z
|
||||
-- Total: 8
|
||||
|
||||
CREATE TABLE public.commitment_services (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Tables: Tenants / Multi-tenant
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.926Z
|
||||
-- Total: 10
|
||||
|
||||
CREATE TABLE public.tenant_members (
|
||||
@@ -119,6 +119,8 @@ CREATE TABLE public.tenants (
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
kind text DEFAULT 'saas'::text NOT NULL,
|
||||
papel_timbrado jsonb DEFAULT '{"footer": {"slots": {"left": null, "right": null, "center": {"type": "custom-text", "content": ""}}, "height": 40, "preset": "text-center", "divider": {"show": true, "color": "#cccccc", "style": "solid"}, "enabled": true, "showPageNumber": false}, "header": {"slots": {"left": {"size": "medium", "type": "logo"}, "right": {"type": "institution-data", "fields": ["nome", "cnpj", "endereco_linha"]}, "center": null}, "height": 80, "preset": "logo-left-text-right", "divider": {"show": true, "color": "#cccccc", "style": "solid"}, "enabled": true}, "margins": {"top": 20, "left": 25, "right": 25, "bottom": 20}}'::jsonb,
|
||||
cpf_cnpj text,
|
||||
CONSTRAINT tenants_cpf_cnpj_format CHECK (((cpf_cnpj IS NULL) OR (cpf_cnpj ~ '^[0-9]{11}$'::text) OR (cpf_cnpj ~ '^[0-9]{14}$'::text))),
|
||||
CONSTRAINT tenants_kind_check CHECK ((kind = ANY (ARRAY['therapist'::text, 'clinic_coworking'::text, 'clinic_reception'::text, 'clinic_full'::text, 'clinic'::text, 'saas'::text, 'supervisor'::text])))
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- Views
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.958Z
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.932Z
|
||||
-- Total: 29
|
||||
|
||||
CREATE VIEW public.audit_log_unified WITH (security_invoker='true') AS
|
||||
@@ -133,10 +133,13 @@ CREATE VIEW public.conversation_threads WITH (security_invoker='true') AS
|
||||
l.last_message_at,
|
||||
l.last_message_body,
|
||||
l.last_message_direction,
|
||||
l.kanban_status
|
||||
FROM ((latest l
|
||||
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.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))));
|
||||
|
||||
CREATE VIEW public.current_tenant_id AS
|
||||
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Indexes
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.961Z
|
||||
-- Total: 361
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.934Z
|
||||
-- Total: 372
|
||||
|
||||
CREATE INDEX agenda_bloqueios_owner_data_idx ON public.agenda_bloqueios USING btree (owner_id, data_inicio, data_fim);
|
||||
|
||||
@@ -184,6 +184,8 @@ CREATE INDEX idx_audit_logs_user_created ON public.audit_logs USING btree (user_
|
||||
|
||||
CREATE INDEX idx_autoreply_log_cooldown ON public.conversation_autoreply_log USING btree (tenant_id, thread_key, sent_at DESC);
|
||||
|
||||
CREATE INDEX idx_bot_sessions_tenant_status ON public.conversation_bot_sessions USING btree (tenant_id, status, started_at DESC);
|
||||
|
||||
CREATE INDEX idx_contact_email_types_tenant ON public.contact_email_types USING btree (tenant_id, "position");
|
||||
|
||||
CREATE INDEX idx_contact_emails_email ON public.contact_emails USING btree (tenant_id, email);
|
||||
@@ -196,6 +198,10 @@ CREATE INDEX idx_contact_phones_number ON public.contact_phones USING btree (ten
|
||||
|
||||
CREATE INDEX idx_contact_types_tenant ON public.contact_types USING btree (tenant_id, "position");
|
||||
|
||||
CREATE INDEX idx_conv_assign_patient ON public.conversation_assignments USING btree (patient_id) WHERE (patient_id IS NOT NULL);
|
||||
|
||||
CREATE INDEX idx_conv_assign_tenant_user ON public.conversation_assignments USING btree (tenant_id, assigned_to) WHERE (assigned_to IS NOT NULL);
|
||||
|
||||
CREATE INDEX idx_conv_msg_delivery_status ON public.conversation_messages USING btree (tenant_id, delivery_status) WHERE (direction = 'outbound'::text);
|
||||
|
||||
CREATE INDEX idx_conv_msg_from_number ON public.conversation_messages USING btree (tenant_id, from_number);
|
||||
@@ -332,6 +338,8 @@ CREATE INDEX idx_intakes_owner_created ON public.patient_intake_requests USING b
|
||||
|
||||
CREATE INDEX idx_intakes_owner_status_created ON public.patient_intake_requests USING btree (owner_id, status, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_intakes_progress_pending ON public.patient_intake_requests USING btree (last_progress_at) WHERE (status = 'in_progress'::text);
|
||||
|
||||
CREATE INDEX idx_intakes_status_created ON public.patient_intake_requests USING btree (status, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_mc_expires ON public.math_challenges USING btree (expires_at);
|
||||
@@ -460,6 +468,10 @@ CREATE INDEX idx_services_name_trgm ON public.services USING gin (name public.gi
|
||||
|
||||
CREATE INDEX idx_session_reminder_tenant_sent ON public.session_reminder_logs USING btree (tenant_id, sent_at DESC);
|
||||
|
||||
CREATE INDEX idx_sla_breaches_open ON public.conversation_sla_breaches USING btree (resolved_at) WHERE (resolved_at IS NULL);
|
||||
|
||||
CREATE INDEX idx_sla_breaches_tenant_breached ON public.conversation_sla_breaches USING btree (tenant_id, breached_at DESC);
|
||||
|
||||
CREATE INDEX idx_slots_bloq_owner_dia ON public.agenda_slots_bloqueados_semanais USING btree (owner_id, dia_semana);
|
||||
|
||||
CREATE INDEX idx_srl_blocked_until ON public.submission_rate_limits USING btree (blocked_until) WHERE (blocked_until IS NOT NULL);
|
||||
@@ -506,6 +518,10 @@ CREATE INDEX idx_wa_credits_tx_kind ON public.whatsapp_credits_transactions USIN
|
||||
|
||||
CREATE INDEX idx_wa_credits_tx_tenant_created ON public.whatsapp_credits_transactions USING btree (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX idx_wa_incidents_open ON public.whatsapp_connection_incidents USING btree (resolved_at) WHERE (resolved_at IS NULL);
|
||||
|
||||
CREATE INDEX idx_wa_incidents_tenant_started ON public.whatsapp_connection_incidents USING btree (tenant_id, started_at DESC);
|
||||
|
||||
CREATE INDEX insurance_plans_owner_idx ON public.insurance_plans USING btree (owner_id);
|
||||
|
||||
CREATE INDEX insurance_plans_tenant_idx ON public.insurance_plans USING btree (tenant_id);
|
||||
@@ -684,6 +700,8 @@ CREATE INDEX tenant_modules_owner_idx ON public.tenant_modules USING btree (owne
|
||||
|
||||
CREATE UNIQUE INDEX unique_member_per_tenant ON public.tenant_members USING btree (tenant_id, user_id);
|
||||
|
||||
CREATE UNIQUE INDEX uq_bot_sessions_active_per_thread ON public.conversation_bot_sessions USING btree (tenant_id, thread_key) WHERE (status = 'active'::text);
|
||||
|
||||
CREATE UNIQUE INDEX uq_contact_email_types_system_slug ON public.contact_email_types USING btree (slug) WHERE (tenant_id IS NULL);
|
||||
|
||||
CREATE UNIQUE INDEX uq_contact_email_types_tenant_slug ON public.contact_email_types USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
|
||||
@@ -712,6 +730,8 @@ CREATE UNIQUE INDEX uq_plan_prices_active ON public.plan_prices USING btree (pla
|
||||
|
||||
CREATE UNIQUE INDEX uq_session_reminder_event_type ON public.session_reminder_logs USING btree (event_id, reminder_type);
|
||||
|
||||
CREATE UNIQUE INDEX uq_sla_breaches_open_per_thread ON public.conversation_sla_breaches USING btree (tenant_id, thread_key) WHERE (resolved_at IS NULL);
|
||||
|
||||
CREATE UNIQUE INDEX uq_subscriptions_active_by_tenant ON public.subscriptions USING btree (tenant_id) WHERE ((tenant_id IS NOT NULL) AND (status = 'active'::text));
|
||||
|
||||
CREATE UNIQUE INDEX uq_subscriptions_active_personal_by_user ON public.subscriptions USING btree (user_id) WHERE ((tenant_id IS NULL) AND (status = 'active'::text));
|
||||
@@ -720,6 +740,8 @@ CREATE UNIQUE INDEX uq_tenant_invites_pending ON public.tenant_invites USING btr
|
||||
|
||||
CREATE UNIQUE INDEX uq_tenant_members_tenant_user ON public.tenant_members USING btree (tenant_id, user_id);
|
||||
|
||||
CREATE UNIQUE INDEX uq_wa_incidents_open_per_channel ON public.whatsapp_connection_incidents USING btree (channel_id) WHERE (resolved_at IS NULL);
|
||||
|
||||
CREATE UNIQUE INDEX ux_subscriptions_active_per_personal_user ON public.subscriptions USING btree (user_id) WHERE ((status = 'active'::text) AND (tenant_id IS NULL));
|
||||
|
||||
CREATE UNIQUE INDEX ux_subscriptions_active_per_tenant ON public.subscriptions USING btree (tenant_id) WHERE ((status = 'active'::text) AND (tenant_id IS NOT NULL));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Constraints (PK, FK, UNIQUE, CHECK)
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.963Z
|
||||
-- Total: 353
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.935Z
|
||||
-- Total: 369
|
||||
|
||||
ALTER TABLE ONLY public._db_migrations
|
||||
ADD CONSTRAINT _db_migrations_filename_key UNIQUE (filename);
|
||||
@@ -95,12 +95,21 @@ ALTER TABLE ONLY public.contact_phones
|
||||
ALTER TABLE ONLY public.contact_types
|
||||
ADD CONSTRAINT contact_types_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_assignments
|
||||
ADD CONSTRAINT conversation_assignments_pkey PRIMARY KEY (tenant_id, thread_key);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_autoreply_log
|
||||
ADD CONSTRAINT conversation_autoreply_log_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_autoreply_settings
|
||||
ADD CONSTRAINT conversation_autoreply_settings_pkey PRIMARY KEY (tenant_id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_bot_sessions
|
||||
ADD CONSTRAINT conversation_bot_sessions_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_bots
|
||||
ADD CONSTRAINT conversation_bots_pkey PRIMARY KEY (tenant_id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_messages
|
||||
ADD CONSTRAINT conversation_messages_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -113,6 +122,12 @@ ALTER TABLE ONLY public.conversation_optout_keywords
|
||||
ALTER TABLE ONLY public.conversation_optouts
|
||||
ADD CONSTRAINT conversation_optouts_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_sla_breaches
|
||||
ADD CONSTRAINT conversation_sla_breaches_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_sla_rules
|
||||
ADD CONSTRAINT conversation_sla_rules_pkey PRIMARY KEY (tenant_id);
|
||||
|
||||
ALTER TABLE ONLY public.conversation_tags
|
||||
ADD CONSTRAINT conversation_tags_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -500,6 +515,9 @@ ALTER TABLE ONLY public.notification_templates
|
||||
ALTER TABLE ONLY public.user_settings
|
||||
ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id);
|
||||
|
||||
ALTER TABLE ONLY public.whatsapp_connection_incidents
|
||||
ADD CONSTRAINT whatsapp_connection_incidents_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY public.whatsapp_credit_packages
|
||||
ADD CONSTRAINT whatsapp_credit_packages_pkey PRIMARY KEY (id);
|
||||
|
||||
@@ -638,12 +656,30 @@ ALTER TABLE ONLY public.contact_phones
|
||||
ALTER TABLE ONLY public.contact_types
|
||||
ADD CONSTRAINT contact_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_assignments
|
||||
ADD CONSTRAINT conversation_assignments_assigned_by_fkey FOREIGN KEY (assigned_by) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_assignments
|
||||
ADD CONSTRAINT conversation_assignments_assigned_to_fkey FOREIGN KEY (assigned_to) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_assignments
|
||||
ADD CONSTRAINT conversation_assignments_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_assignments
|
||||
ADD CONSTRAINT conversation_assignments_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_autoreply_log
|
||||
ADD CONSTRAINT conversation_autoreply_log_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_autoreply_settings
|
||||
ADD CONSTRAINT conversation_autoreply_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_bot_sessions
|
||||
ADD CONSTRAINT conversation_bot_sessions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_bots
|
||||
ADD CONSTRAINT conversation_bots_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_messages
|
||||
ADD CONSTRAINT conversation_messages_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -671,6 +707,12 @@ ALTER TABLE ONLY public.conversation_optouts
|
||||
ALTER TABLE ONLY public.conversation_optouts
|
||||
ADD CONSTRAINT conversation_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_sla_breaches
|
||||
ADD CONSTRAINT conversation_sla_breaches_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_sla_rules
|
||||
ADD CONSTRAINT conversation_sla_rules_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.conversation_tags
|
||||
ADD CONSTRAINT conversation_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
@@ -1037,6 +1079,12 @@ ALTER TABLE ONLY public.twilio_subaccount_usage
|
||||
ALTER TABLE ONLY public.user_settings
|
||||
ADD CONSTRAINT user_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.whatsapp_connection_incidents
|
||||
ADD CONSTRAINT whatsapp_connection_incidents_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.whatsapp_connection_incidents
|
||||
ADD CONSTRAINT whatsapp_connection_incidents_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY public.whatsapp_credit_purchases
|
||||
ADD CONSTRAINT whatsapp_credit_purchases_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Triggers
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.965Z
|
||||
-- Total: 111
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.937Z
|
||||
-- Total: 120
|
||||
|
||||
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
|
||||
|
||||
@@ -40,6 +40,8 @@ CREATE TRIGGER trg_agenda_eventos_busy_mirror_upd AFTER UPDATE ON public.agenda_
|
||||
|
||||
CREATE TRIGGER trg_agenda_regras_semanais_no_overlap BEFORE INSERT OR UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap();
|
||||
|
||||
CREATE TRIGGER trg_agenda_status_notify AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.fn_notify_agenda_status_change();
|
||||
|
||||
CREATE TRIGGER trg_audit_agenda_eventos AFTER INSERT OR DELETE OR UPDATE ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
|
||||
CREATE TRIGGER trg_audit_documents AFTER INSERT OR DELETE OR UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
|
||||
@@ -52,6 +54,8 @@ CREATE TRIGGER trg_audit_tenant_members AFTER INSERT OR DELETE OR UPDATE ON publ
|
||||
|
||||
CREATE TRIGGER trg_auto_financial_from_session AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session();
|
||||
|
||||
CREATE TRIGGER trg_bot_sessions_updated_at BEFORE UPDATE ON public.conversation_bot_sessions FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_cancel_notifs_on_opt_out AFTER UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out();
|
||||
|
||||
CREATE TRIGGER trg_cancel_notifs_on_session_cancel AFTER UPDATE ON public.agenda_eventos FOR EACH ROW WHEN ((new.status IS DISTINCT FROM old.status)) EXECUTE FUNCTION public.cancel_notifications_on_session_cancel();
|
||||
@@ -70,8 +74,12 @@ CREATE TRIGGER trg_contact_phones_updated_at BEFORE UPDATE ON public.contact_pho
|
||||
|
||||
CREATE TRIGGER trg_contact_types_updated_at BEFORE UPDATE ON public.contact_types FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_conv_assign_updated_at BEFORE UPDATE ON public.conversation_assignments FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_conv_autoreply_settings_updated_at BEFORE UPDATE ON public.conversation_autoreply_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_conv_bots_updated_at BEFORE UPDATE ON public.conversation_bots FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_conv_messages_updated_at BEFORE UPDATE ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_conv_notes_updated_at BEFORE UPDATE ON public.conversation_notes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
@@ -194,6 +202,12 @@ CREATE TRIGGER trg_services_updated_at BEFORE UPDATE ON public.services FOR EACH
|
||||
|
||||
CREATE TRIGGER trg_session_reminder_settings_updated_at BEFORE UPDATE ON public.session_reminder_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_sla_breaches_updated_at BEFORE UPDATE ON public.conversation_sla_breaches FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_sla_resolve_on_outbound AFTER INSERT ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.fn_sla_resolve_on_outbound();
|
||||
|
||||
CREATE TRIGGER trg_sla_rules_updated_at BEFORE UPDATE ON public.conversation_sla_rules FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_subscription_intents_view_insert INSTEAD OF INSERT ON public.subscription_intents FOR EACH ROW EXECUTE FUNCTION public.subscription_intents_view_insert();
|
||||
|
||||
CREATE TRIGGER trg_subscriptions_validate_scope BEFORE INSERT OR UPDATE ON public.subscriptions FOR EACH ROW EXECUTE FUNCTION public.subscriptions_validate_scope();
|
||||
@@ -214,6 +228,10 @@ CREATE TRIGGER trg_wa_credit_purchases_updated_at BEFORE UPDATE ON public.whatsa
|
||||
|
||||
CREATE TRIGGER trg_wa_credits_balance_updated_at BEFORE UPDATE ON public.whatsapp_credits_balance FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_wa_incidents_updated_at BEFORE UPDATE ON public.whatsapp_connection_incidents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_whatsapp_low_balance_notify BEFORE UPDATE ON public.whatsapp_credits_balance FOR EACH ROW EXECUTE FUNCTION public.fn_whatsapp_low_balance_notify();
|
||||
|
||||
CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters();
|
||||
|
||||
CREATE TRIGGER enforce_bucket_name_length_trigger BEFORE INSERT OR UPDATE OF name ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.enforce_bucket_name_length();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- RLS Policies
|
||||
-- Gerado automaticamente em 2026-04-21T23:16:34.967Z
|
||||
-- Enable RLS: 131 tabelas
|
||||
-- Policies: 344
|
||||
-- Gerado automaticamente em 2026-05-11T16:53:50.939Z
|
||||
-- Enable RLS: 137 tabelas
|
||||
-- Policies: 357
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.addon_credits ENABLE ROW LEVEL SECURITY;
|
||||
@@ -26,12 +26,17 @@ ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_assignments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_bot_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_bots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_sla_breaches ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_sla_rules ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.determined_commitment_fields ENABLE ROW LEVEL SECURITY;
|
||||
@@ -131,6 +136,7 @@ ALTER TABLE public.therapist_payout_records ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.therapist_payouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.whatsapp_connection_incidents ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
|
||||
@@ -273,6 +279,12 @@ CREATE POLICY bloqueios_select_own ON public.agenda_bloqueios FOR SELECT TO auth
|
||||
|
||||
CREATE POLICY bloqueios_update ON public.agenda_bloqueios FOR UPDATE TO authenticated USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
|
||||
|
||||
CREATE POLICY "bot_sessions: select membros" ON public.conversation_bot_sessions FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_bot_sessions.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "bot_sessions: write service_role" ON public.conversation_bot_sessions TO service_role USING (true) WITH CHECK (true);
|
||||
|
||||
CREATE POLICY clinic_admin_read_all_docs ON public.saas_docs FOR SELECT TO authenticated USING (((ativo = true) AND (EXISTS ( SELECT 1
|
||||
FROM public.profiles
|
||||
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])))))));
|
||||
@@ -335,6 +347,32 @@ CREATE POLICY "contact_types: select" ON public.contact_types FOR SELECT TO auth
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text))))));
|
||||
|
||||
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'::text)))) 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'::text)))))));
|
||||
|
||||
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'::text))))));
|
||||
|
||||
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'::text))))) 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'::text)))))));
|
||||
|
||||
CREATE POLICY "conv_bots: select membros" ON public.conversation_bots FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_bots.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "conv_bots: write admins" ON public.conversation_bots TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_bots.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_bots.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages FOR DELETE TO authenticated USING (false);
|
||||
|
||||
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages FOR INSERT TO authenticated WITH CHECK (false);
|
||||
@@ -677,9 +715,9 @@ CREATE POLICY notif_channels_insert ON public.notification_channels FOR INSERT T
|
||||
|
||||
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_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
|
||||
CREATE POLICY notif_channels_select ON public.notification_channels FOR SELECT USING ((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'::text)))))));
|
||||
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY notif_logs_owner ON public.notification_logs FOR SELECT USING ((owner_id = auth.uid()));
|
||||
|
||||
@@ -933,6 +971,22 @@ CREATE POLICY "services: select" ON public.services FOR SELECT TO authenticated
|
||||
|
||||
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 "sla_breaches: select membros/admin" ON public.conversation_sla_breaches FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_sla_breaches.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "sla_breaches: write service_role" ON public.conversation_sla_breaches TO service_role USING (true) WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "sla_rules: select membros/admin" ON public.conversation_sla_rules FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_sla_rules.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "sla_rules: write admins" ON public.conversation_sla_rules TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_sla_rules.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = conversation_sla_rules.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits FOR SELECT TO authenticated USING (public.is_saas_admin());
|
||||
|
||||
CREATE POLICY subscription_events_read_saas ON public.subscription_events FOR SELECT USING (public.is_saas_admin());
|
||||
@@ -1067,6 +1121,12 @@ CREATE POLICY "wa_credits_tx: select tenant" ON public.whatsapp_credits_transact
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_transactions.tenant_id) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "wa_incidents: select membros/admin" ON public.whatsapp_connection_incidents FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
|
||||
FROM public.tenant_members tm
|
||||
WHERE ((tm.tenant_id = whatsapp_connection_incidents.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
|
||||
|
||||
CREATE POLICY "wa_incidents: write service_role" ON public.whatsapp_connection_incidents TO service_role USING (true) WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "wa_packages: manage saas admin" ON public.whatsapp_credit_packages TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||
|
||||
CREATE POLICY "wa_packages: select active" ON public.whatsapp_credit_packages FOR SELECT TO authenticated USING (((is_active = true) OR public.is_saas_admin()));
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
-- ============================================================================
|
||||
-- Seed dos templates do sistema de prontuário clínico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Templates is_system=true, sem tenant_id, sem owner_id.
|
||||
-- Cobrem os 4 tipos mais comuns de nota clínica em psicologia:
|
||||
-- • Anamnese padrão CFP-style
|
||||
-- • Evolução: SOAP / DAP / BIRP
|
||||
-- • Plano terapêutico padrão
|
||||
--
|
||||
-- structure jsonb segue schema:
|
||||
-- [
|
||||
-- { key, label, type, required?, hint?, options? },
|
||||
-- ...
|
||||
-- ]
|
||||
-- type: 'text' | 'textarea' | 'select' | 'date' | 'multiselect'
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Anamnese padrão (CFP)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'anamnese_padrao',
|
||||
'Anamnese Padrão',
|
||||
'anamnese',
|
||||
'Estrutura padrão de anamnese clínica em psicologia. Pode ser preenchida em 1-3 sessões iniciais.',
|
||||
'[
|
||||
{"key": "queixa_principal", "label": "Queixa principal", "type": "textarea", "required": true, "hint": "O que trouxe o paciente à terapia"},
|
||||
{"key": "historia_queixa", "label": "História da queixa", "type": "textarea", "hint": "Quando começou, evolução, fatores agravantes/atenuantes"},
|
||||
{"key": "historia_vida", "label": "História de vida", "type": "textarea", "hint": "Infância, adolescência, eventos marcantes"},
|
||||
{"key": "antecedentes_psicologicos", "label": "Antecedentes psicológicos", "type": "textarea", "hint": "Tratamentos anteriores, medicações, internações"},
|
||||
{"key": "antecedentes_medicos", "label": "Antecedentes médicos", "type": "textarea", "hint": "Doenças, cirurgias, medicações em uso"},
|
||||
{"key": "antecedentes_familiares", "label": "Antecedentes familiares", "type": "textarea", "hint": "Histórico familiar de transtornos psicológicos/psiquiátricos"},
|
||||
{"key": "vida_atual_relacionamentos", "label": "Relacionamentos atuais", "type": "textarea"},
|
||||
{"key": "vida_atual_trabalho_estudo", "label": "Trabalho / estudo atual", "type": "textarea"},
|
||||
{"key": "hipoteses_iniciais", "label": "Hipóteses iniciais", "type": "textarea", "hint": "Hipóteses do terapeuta — não compartilhar com paciente"},
|
||||
{"key": "plano_inicial", "label": "Plano terapêutico inicial", "type": "textarea"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Evolução SOAP (Subjective, Objective, Assessment, Plan)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'soap',
|
||||
'Evolução SOAP',
|
||||
'evolucao_sessao',
|
||||
'Padrão internacional: Subjetivo (relato do paciente), Objetivo (observações), Avaliação (análise), Plano (próximos passos).',
|
||||
'[
|
||||
{"key": "subjetivo", "label": "S — Subjetivo", "type": "textarea", "required": true, "hint": "O que o paciente relatou; humor; queixas verbalizadas"},
|
||||
{"key": "objetivo", "label": "O — Objetivo", "type": "textarea", "hint": "Observações do terapeuta: comportamento, afeto, aparência, postura"},
|
||||
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true, "hint": "Análise clínica, hipóteses, evolução"},
|
||||
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true, "hint": "Intervenções planejadas, tarefas, foco da próxima sessão"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Evolução DAP (Data, Assessment, Plan)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'dap',
|
||||
'Evolução DAP',
|
||||
'evolucao_sessao',
|
||||
'Mais conciso que SOAP: Dados (relato + observações), Avaliação, Plano.',
|
||||
'[
|
||||
{"key": "dados", "label": "D — Dados", "type": "textarea", "required": true, "hint": "Relato + observações em texto único"},
|
||||
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true},
|
||||
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 4. Evolução BIRP (Behavior, Intervention, Response, Plan)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'birp',
|
||||
'Evolução BIRP',
|
||||
'evolucao_sessao',
|
||||
'Foco em intervenção: Comportamento observado, Intervenção aplicada, Resposta do paciente, Plano.',
|
||||
'[
|
||||
{"key": "behavior", "label": "B — Comportamento", "type": "textarea", "required": true, "hint": "Comportamento/queixa observada na sessão"},
|
||||
{"key": "intervention", "label": "I — Intervenção", "type": "textarea", "required": true, "hint": "Técnicas ou abordagens aplicadas pelo terapeuta"},
|
||||
{"key": "response", "label": "R — Resposta", "type": "textarea", "required": true, "hint": "Como o paciente respondeu à intervenção"},
|
||||
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 5. Evolução livre (CFP-style — texto único)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'evolucao_livre',
|
||||
'Evolução Livre',
|
||||
'evolucao_sessao',
|
||||
'Texto único, sem estrutura — pra quem prefere prosa contínua estilo CFP tradicional.',
|
||||
'[
|
||||
{"key": "evolucao", "label": "Evolução", "type": "textarea", "required": true, "hint": "Texto único descrevendo a sessão"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 6. Plano terapêutico padrão
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.clinical_note_templates (
|
||||
key, name, note_type, description, structure, is_system, is_global, active
|
||||
) VALUES (
|
||||
'plano_terapeutico_padrao',
|
||||
'Plano Terapêutico Padrão',
|
||||
'plano_terapeutico',
|
||||
'Estrutura básica de plano: objetivos, estratégia, recursos, prazo estimado.',
|
||||
'[
|
||||
{"key": "objetivos_gerais", "label": "Objetivos gerais", "type": "textarea", "required": true, "hint": "O que o paciente quer alcançar"},
|
||||
{"key": "objetivos_especificos", "label": "Objetivos específicos / metas", "type": "textarea", "hint": "Metas mensuráveis"},
|
||||
{"key": "estrategia_terapeutica", "label": "Estratégia terapêutica", "type": "textarea", "required": true, "hint": "Abordagem teórica, técnicas previstas"},
|
||||
{"key": "recursos_indicados", "label": "Recursos / intervenções indicadas", "type": "textarea"},
|
||||
{"key": "duracao_estimada", "label": "Duração estimada", "type": "text", "hint": "Ex: 6 meses, indeterminado"},
|
||||
{"key": "criterios_alta", "label": "Critérios de alta", "type": "textarea"},
|
||||
{"key": "encaminhamentos", "label": "Encaminhamentos paralelos", "type": "textarea", "hint": "Psiquiatria, médico, outras especialidades"}
|
||||
]'::jsonb,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- ============================================================================
|
||||
-- Seed: Especialidades do sistema (ROADMAP item #9)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Lista canônica de especialidades + abordagens psicológicas no Brasil.
|
||||
-- is_system=true; usuário escolhe múltiplas; 'outra' permite custom via
|
||||
-- profile_specialties.other_label.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO public.specialties (key, name, category, is_system, active) VALUES
|
||||
-- Especialidades CFP (psicologia)
|
||||
('psicologia_clinica', 'Psicologia Clínica', 'psicologia', true, true),
|
||||
('psicologia_hospitalar', 'Psicologia Hospitalar', 'psicologia', true, true),
|
||||
('neuropsicologia', 'Neuropsicologia', 'psicologia', true, true),
|
||||
('psicologia_organizacional', 'Psicologia Organizacional e do Trabalho', 'psicologia', true, true),
|
||||
('psicologia_escolar', 'Psicologia Escolar e Educacional', 'psicologia', true, true),
|
||||
('psicologia_juridica', 'Psicologia Jurídica', 'psicologia', true, true),
|
||||
('psicologia_esporte', 'Psicologia do Esporte', 'psicologia', true, true),
|
||||
('psicologia_social', 'Psicologia Social', 'psicologia', true, true),
|
||||
('psicologia_transito', 'Psicologia do Trânsito', 'psicologia', true, true),
|
||||
|
||||
-- Abordagens teóricas
|
||||
('psicanalise', 'Psicanálise', 'abordagem', true, true),
|
||||
('tcc', 'Terapia Cognitivo-Comportamental (TCC)', 'abordagem', true, true),
|
||||
('psicodrama', 'Psicodrama', 'abordagem', true, true),
|
||||
('gestalt_terapia', 'Gestalt-terapia', 'abordagem', true, true),
|
||||
('analise_comportamento', 'Análise do Comportamento (ABA)', 'abordagem', true, true),
|
||||
('humanista', 'Abordagem Humanista (Rogers)', 'abordagem', true, true),
|
||||
('sistemica_familiar', 'Terapia Sistêmica Familiar', 'abordagem', true, true),
|
||||
('logoterapia', 'Logoterapia (Frankl)', 'abordagem', true, true),
|
||||
('analitica_jung', 'Psicologia Analítica (Jung)', 'abordagem', true, true),
|
||||
|
||||
-- Públicos
|
||||
('infantil', 'Atendimento Infantil', 'publico', true, true),
|
||||
('adolescentes', 'Atendimento de Adolescentes', 'publico', true, true),
|
||||
('casais', 'Terapia de Casal', 'publico', true, true),
|
||||
('familia', 'Terapia Familiar', 'publico', true, true),
|
||||
('grupos', 'Atendimento de Grupos', 'publico', true, true),
|
||||
('idosos', 'Atendimento de Idosos / Gerontologia', 'publico', true, true),
|
||||
('lgbtqia', 'Atendimento LGBTQIA+', 'publico', true, true),
|
||||
|
||||
-- Temas
|
||||
('ansiedade', 'Transtornos de Ansiedade', 'tema', true, true),
|
||||
('depressao', 'Depressão', 'tema', true, true),
|
||||
('tdah', 'TDAH', 'tema', true, true),
|
||||
('autismo', 'Transtorno do Espectro Autista', 'tema', true, true),
|
||||
('luto', 'Luto e Perdas', 'tema', true, true),
|
||||
('dependencia_quimica', 'Dependência Química', 'tema', true, true),
|
||||
('transtornos_alimentares', 'Transtornos Alimentares', 'tema', true, true),
|
||||
('trauma', 'Trauma e Estresse Pós-Traumático', 'tema', true, true),
|
||||
|
||||
-- Catch-all
|
||||
('outra', 'Outra', 'outro', true, true)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #6 — Consent forms extra (LGPD + Gravação) + LGPD amend
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Adiciona 2 templates globais novos exigidos pra completar a biblioteca de
|
||||
-- consent forms do ROADMAP item #1.2 #6:
|
||||
-- • termo_lgpd — Consentimento LGPD (tratamento de dados pessoais)
|
||||
-- • autorizacao_gravacao — Autorização de gravação de sessão
|
||||
--
|
||||
-- Também atualiza o template tcle_online existente pra incluir cláusula
|
||||
-- explícita de LGPD (estava mencionando criptografia mas não a Lei 13.709/2018
|
||||
-- nem direitos do titular).
|
||||
--
|
||||
-- Pré-requisito: migration 20260521000005_document_templates_consent_types.sql
|
||||
-- já aplicada (adiciona 'termo_lgpd' e 'autorizacao_gravacao' ao CHECK).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Termo de Consentimento LGPD (tratamento de dados pessoais)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.document_templates (
|
||||
id, tenant_id, owner_id, nome_template, tipo, descricao,
|
||||
corpo_html, cabecalho_html, rodape_html,
|
||||
variaveis, is_global, ativo
|
||||
) VALUES (
|
||||
gen_random_uuid(), NULL, NULL,
|
||||
'Termo de Consentimento LGPD',
|
||||
'termo_lgpd',
|
||||
'Consentimento específico para tratamento de dados pessoais conforme Lei nº 13.709/2018 (LGPD).',
|
||||
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA TRATAMENTO DE DADOS PESSOAIS</h2>\n\n<p>Em conformidade com a <strong>Lei Geral de Proteção de Dados Pessoais — Lei nº 13.709/2018 (LGPD)</strong>, eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido informado(a) e <strong>consinto livremente</strong> com o tratamento dos meus dados pessoais nos termos abaixo.</p>\n\n<h3>1. Controlador dos dados</h3>\n<p><strong>{{terapeuta_nome}}</strong>, Psicólogo(a) — CRP <strong>{{terapeuta_crp}}</strong>, atuando em <strong>{{clinica_nome}}</strong>, com endereço em <strong>{{clinica_endereco}}</strong>, atua como controlador dos dados pessoais coletados.</p>\n\n<h3>2. Dados tratados</h3>\n<p>Serão coletados e tratados os seguintes dados:</p>\n<ul>\n <li><strong>Identificação:</strong> nome, CPF, RG, data de nascimento, endereço, telefone, e-mail;</li>\n <li><strong>Dados sensíveis de saúde:</strong> histórico clínico, hipóteses diagnósticas, evolução terapêutica, prescrições, encaminhamentos (Art. 11 LGPD);</li>\n <li><strong>Dados de pagamento:</strong> valores, formas de pagamento, recibos emitidos;</li>\n <li><strong>Registros de atendimento:</strong> data, horário, modalidade e duração das sessões.</li>\n</ul>\n\n<h3>3. Finalidade e base legal</h3>\n<p>Os dados serão utilizados exclusivamente para:</p>\n<ul>\n <li><strong>Execução do contrato</strong> de prestação de serviços psicológicos (Art. 7º, V e Art. 11, II, "a" da LGPD);</li>\n <li>Cumprimento de <strong>obrigações legais e regulatórias</strong> (Resoluções CFP, retenção de prontuário por 5 anos — Art. 1º Res. CFP 001/2009);</li>\n <li>Proteção da <strong>vida e incolumidade física</strong> do titular ou de terceiros, quando necessário (Art. 11, II, "f" LGPD);</li>\n <li>Emissão de documentos solicitados (recibos, atestados, declarações).</li>\n</ul>\n\n<h3>4. Compartilhamento</h3>\n<p>Os dados <strong>não serão compartilhados</strong> com terceiros, exceto:</p>\n<ul>\n <li>Mediante <strong>autorização expressa</strong> do titular (ex: encaminhamentos);</li>\n <li>Por <strong>determinação judicial</strong> ou requisição legal de autoridade competente;</li>\n <li>Para <strong>processadores</strong> contratados (plataforma de prontuário eletrônico, serviços de armazenamento em nuvem), com cláusulas de confidencialidade e proteção equivalentes.</li>\n</ul>\n\n<h3>5. Armazenamento e retenção</h3>\n<p>Os dados serão mantidos pelo prazo mínimo de <strong>5 anos</strong> após o término do acompanhamento, conforme exigência do CFP (Resolução nº 001/2009), em ambiente eletrônico criptografado com controle de acesso restrito ao profissional responsável.</p>\n\n<h3>6. Direitos do titular (Art. 18 LGPD)</h3>\n<p>O(A) titular pode, a qualquer momento, solicitar ao controlador:</p>\n<ul>\n <li>Confirmação da existência de tratamento;</li>\n <li>Acesso aos seus dados;</li>\n <li>Correção de dados incompletos, inexatos ou desatualizados;</li>\n <li>Anonimização, bloqueio ou eliminação de dados desnecessários ou tratados em desconformidade;</li>\n <li>Portabilidade dos dados;</li>\n <li>Revogação deste consentimento, nos termos do §5º do Art. 8º (sem prejuízo do tratamento legalmente exigido).</li>\n</ul>\n\n<h3>7. Contato</h3>\n<p>Para exercer seus direitos ou esclarecer dúvidas: <strong>{{terapeuta_email}}</strong> · <strong>{{terapeuta_telefone}}</strong>.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e consinto</strong> livremente com o tratamento dos meus dados pessoais conforme descrito acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Titular dos dados — CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Controlador — CRP {{terapeuta_crp}}\n </div>\n</div>',
|
||||
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
|
||||
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pela Lei nº 13.709/2018 (LGPD) e pelo Código de Ética Profissional do Psicólogo.\n</div>',
|
||||
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
|
||||
true, true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Autorização para Gravação de Sessão
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.document_templates (
|
||||
id, tenant_id, owner_id, nome_template, tipo, descricao,
|
||||
corpo_html, cabecalho_html, rodape_html,
|
||||
variaveis, is_global, ativo
|
||||
) VALUES (
|
||||
gen_random_uuid(), NULL, NULL,
|
||||
'Autorização para Gravação de Sessão',
|
||||
'autorizacao_gravacao',
|
||||
'Autorização específica do paciente para gravação de áudio/vídeo das sessões (supervisão, ensino, registro clínico).',
|
||||
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA GRAVAÇÃO DE SESSÃO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido devidamente informado(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, e <strong>AUTORIZO</strong> a gravação das sessões de atendimento psicológico nas condições abaixo.</p>\n\n<h3>1. Tipo de gravação</h3>\n<p>Modalidade autorizada: <strong>{{tipo_gravacao}}</strong> (áudio, vídeo ou ambos).</p>\n\n<h3>2. Finalidade</h3>\n<p>As gravações serão utilizadas exclusivamente para:</p>\n<ul>\n <li><strong>{{finalidade_gravacao}}</strong></li>\n</ul>\n<p>Finalidades comuns: registro clínico para análise posterior do profissional; supervisão técnica com supervisor identificado; uso didático em formação (com anonimização); pesquisa científica (mediante consentimento adicional específico).</p>\n\n<h3>3. Compartilhamento</h3>\n<p>As gravações são <strong>confidenciais</strong>. Não serão compartilhadas com terceiros, exceto quando:</p>\n<ul>\n <li>Houver autorização expressa e por escrito do(a) titular;</li>\n <li>Para fins de supervisão técnica, com o(a) supervisor(a) identificado(a) — <strong>{{supervisor_nome}}</strong> (quando aplicável);</li>\n <li>Anonimizadas, para fins didáticos ou de pesquisa (com novo consentimento específico).</li>\n</ul>\n\n<h3>4. Armazenamento e descarte</h3>\n<p>As gravações serão armazenadas em ambiente criptografado, com acesso restrito ao(à) profissional responsável, pelo prazo de <strong>{{prazo_retencao}}</strong>, após o qual serão definitivamente eliminadas, conforme a LGPD (Lei nº 13.709/2018).</p>\n\n<h3>5. Direitos do(a) paciente</h3>\n<ul>\n <li>Revogar esta autorização a qualquer tempo, com efeito sobre gravações futuras;</li>\n <li>Solicitar a eliminação imediata de gravações específicas;</li>\n <li>Solicitar cópia da gravação para fins próprios;</li>\n <li>Ser informado(a) sobre cada utilização (supervisão, pesquisa).</li>\n</ul>\n\n<h3>6. Considerações éticas</h3>\n<p>A presente autorização está em conformidade com o Código de Ética Profissional do Psicólogo, com a Resolução CFP nº 010/2005 (sigilo profissional) e com a Lei nº 13.709/2018 (LGPD). A negativa de gravação <strong>não prejudica</strong> o atendimento psicológico, que prosseguirá normalmente.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e autorizo</strong> a gravação das sessões nos termos acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
|
||||
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
|
||||
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Autorização regida pelo Código de Ética do Psicólogo (CFP 010/2005) e pela Lei nº 13.709/2018 (LGPD).\n</div>',
|
||||
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','tipo_gravacao','finalidade_gravacao','supervisor_nome','prazo_retencao','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
|
||||
true, true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Amend tcle_online com cláusula LGPD explícita
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- O template original (seed_015) menciona criptografia mas não cita a LGPD
|
||||
-- explicitamente nem os direitos do titular. Acrescenta uma seção 5.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
UPDATE public.document_templates
|
||||
SET corpo_html = REPLACE(
|
||||
corpo_html,
|
||||
'<h3>4. Limitações</h3>',
|
||||
E'<h3>5. Proteção de Dados (LGPD)</h3>\n<p>O atendimento online é regido pela <strong>Lei Geral de Proteção de Dados — Lei nº 13.709/2018 (LGPD)</strong>. Você tem direito a (Art. 18 LGPD): confirmar a existência de tratamento dos seus dados; acessar seus dados; corrigir dados incompletos ou inexatos; solicitar eliminação dos dados após o término do tratamento (resguardados os prazos legais de retenção do CFP); e revogar este consentimento a qualquer momento. Para exercê-los, contate <strong>{{terapeuta_email}}</strong>.</p>\n\n<h3>6. Limitações</h3>'
|
||||
),
|
||||
variaveis = ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','terapeuta_email','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
|
||||
descricao = 'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018) + cláusula LGPD.',
|
||||
updated_at = now()
|
||||
WHERE tipo = 'tcle_online' AND is_global = true;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Backfill: linkar document_generated.documento_id em registros antigos
|
||||
-- pra suportar re-edicao in-place de documentos gerados.
|
||||
--
|
||||
-- O codigo novo (DocumentGenerate.service.js saveGeneratedDocument) ja
|
||||
-- preenche o documento_id no INSERT pra criacoes novas. Este script eh
|
||||
-- one-off pra docs gerados ANTES desse fix.
|
||||
--
|
||||
-- Match: dg.pdf_path = d.bucket_path + match de tenant/patient pra evitar
|
||||
-- linkar a doc errado em caso colidente. Registros sem match (paths que
|
||||
-- nao existem mais em documents — docs deletados/cleanup) ficam orfaos
|
||||
-- com documento_id=NULL: nao quebra nada, so nao tem caminho de re-edit.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.document_generated dg
|
||||
SET documento_id = d.id
|
||||
FROM public.documents d
|
||||
WHERE dg.documento_id IS NULL
|
||||
AND dg.pdf_path = d.bucket_path
|
||||
AND dg.patient_id = d.patient_id
|
||||
AND dg.tenant_id = d.tenant_id
|
||||
AND d.deleted_at IS NULL;
|
||||
|
||||
-- Relatorio pos-backfill
|
||||
DO $REPORT$
|
||||
DECLARE
|
||||
v_linked int;
|
||||
v_orphans int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE documento_id IS NOT NULL),
|
||||
count(*) FILTER (WHERE documento_id IS NULL)
|
||||
INTO v_linked, v_orphans
|
||||
FROM public.document_generated;
|
||||
RAISE NOTICE 'document_generated: % linked, % orphans (sem documents correspondente)',
|
||||
v_linked, v_orphans;
|
||||
END;
|
||||
$REPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,166 @@
|
||||
-- Importacao da doc Assinatura eletronica de documentos (Fase 3 #7)
|
||||
-- Gerado a partir de development/saas-docs/05-assinatura-eletronica-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Assinatura eletrônica de documentos',
|
||||
$HTML$<h2>Assinatura eletrônica de documentos</h2>
|
||||
|
||||
<p>O sistema permite enviar documentos clínicos (TCLE, contratos, autorizações, laudos) pro paciente assinar <strong>sem que ele precise ter login</strong>. O fluxo registra a assinatura com hash do conteúdo, IP, user-agent e timestamp — gerando um audit trail compliance LGPD/CFP.</p>
|
||||
|
||||
<h3>1. Visão geral do fluxo</h3>
|
||||
<ol>
|
||||
<li><strong>Terapeuta</strong> abre o documento no prontuário e clica em <em>Assinar</em></li>
|
||||
<li>Adiciona os signatários (nome + email) e ativa <em>"Gerar link público para assinatura"</em></li>
|
||||
<li>Sistema cria signature requests + um <strong>link público temporário</strong> com token</li>
|
||||
<li>Terapeuta copia a URL e envia pro paciente (WhatsApp, email, SMS — manual por enquanto)</li>
|
||||
<li><strong>Paciente</strong> abre o link em qualquer navegador, lê o documento, marca o checkbox de aceite LGPD e clica <em>Assinar</em></li>
|
||||
<li>Sistema computa SHA-256 do PDF baixado, registra assinatura via RPC server-side (IP/UA capturados pelo banco)</li>
|
||||
<li>Terapeuta vê o status atualizado no documento (pendente → assinado)</li>
|
||||
</ol>
|
||||
|
||||
<h3>2. Lado terapeuta — criar solicitação</h3>
|
||||
<p>No preview de um documento (na aba <em>Documentos</em> do prontuário), clique no botão <strong>Assinar</strong> na sidebar de ações. O <em>DocumentSignatureDialog</em> abre com:</p>
|
||||
<ul>
|
||||
<li><strong>Lista de signatários:</strong> adicione um ou mais — pra cada um, nome + email são obrigatórios. O paciente principal vem pré-preenchido se disponível.</li>
|
||||
<li><strong>Toggle "Gerar link público para assinatura"</strong> (default ON): cria um share_link junto com as signature requests. Sem isso, só fica a request registrada — o paciente precisa logar no portal pra assinar.</li>
|
||||
<li><strong>Select de validade do link:</strong> 24h / 3 dias / 7 dias / 30 dias. Default 7 dias (168h).</li>
|
||||
<li><strong>Submit:</strong> cria as requests + link e mostra a URL pronta pra copiar.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Lado paciente — link público (sem login)</h3>
|
||||
<p>Ao abrir o link <code>/shared/document/:token</code>, o paciente vê:</p>
|
||||
<ul>
|
||||
<li><strong>Preview do PDF</strong> inline (iframe)</li>
|
||||
<li><strong>Painel azul</strong> embaixo do preview com:
|
||||
<ul>
|
||||
<li>Aviso LGPD/CFP explicando o que vai ser registrado</li>
|
||||
<li>Checkbox <em>"Li o documento e concordo com seu conteúdo"</em> (bloqueia botões até marcado)</li>
|
||||
<li>Select de signatário (se houver mais de um cadastrado)</li>
|
||||
<li>Botões <strong>Assinar</strong> (emerald) e <strong>Recusar</strong> (rose)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>Ao clicar Assinar:</p>
|
||||
<ol>
|
||||
<li>Sistema baixa o PDF e computa <strong>SHA-256</strong> client-side (proof of integrity — se o doc foi alterado depois, hash não bate)</li>
|
||||
<li>Chama a RPC <code>sign_document_by_token</code> passando o hash</li>
|
||||
<li>RPC captura <strong>IP via inet_client_addr()</strong> e <strong>user-agent via current_setting('request.headers')</strong> — server-side, à prova de spoof client-side</li>
|
||||
<li>Registra em <code>document_signatures</code>: timestamp, hash, IP, UA, status='assinado'</li>
|
||||
<li>Mostra tela de confirmação "Documento assinado com sucesso"</li>
|
||||
</ol>
|
||||
|
||||
<h3>4. Audit trail registrado</h3>
|
||||
<p>Cada assinatura grava:</p>
|
||||
<ul>
|
||||
<li><strong>signatario_id</strong> (se o signatário tem cadastro como paciente vinculado)</li>
|
||||
<li><strong>signatario_nome</strong> e <strong>signatario_email</strong> (do que foi cadastrado pelo terapeuta)</li>
|
||||
<li><strong>assinado_em</strong> — timestamp da RPC (server time, não client clock)</li>
|
||||
<li><strong>assinatura_hash</strong> — SHA-256 do PDF no momento da assinatura</li>
|
||||
<li><strong>ip_address</strong> — capturado server-side (anti-spoof)</li>
|
||||
<li><strong>user_agent</strong> — header HTTP via current_setting</li>
|
||||
<li><strong>status</strong> — pendente / enviado / assinado / recusado / expirado</li>
|
||||
</ul>
|
||||
|
||||
<div style="background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
|
||||
<strong>🛡️ Por que server-side?</strong> Capturar IP/UA no banco via <code>inet_client_addr()</code> é anti-spoof: o cliente não consegue forjar valores arbitrários. Garante que o audit trail reflete a sessão HTTP real, não um POST manipulado.
|
||||
</div>
|
||||
|
||||
<h3>5. Recusar a assinatura</h3>
|
||||
<p>O paciente pode <strong>recusar</strong> em vez de assinar — útil se ele não concorda com o conteúdo. Click em <strong>Recusar</strong> abre um <em>confirm</em>; ao confirmar, o sistema registra a recusa (com timestamp + IP/UA da mesma forma) e marca a request como <code>status='recusado'</code>. O terapeuta vê isso na lista de signature requests e pode entrar em contato pra ajustar o documento.</p>
|
||||
|
||||
<h3>6. Portal do paciente — lista de pendências</h3>
|
||||
<p>Pacientes logados no portal (<code>/portal/documentos</code>) veem uma lista de TODOS os documentos solicitados pra eles, com KPIs no topo:</p>
|
||||
<ul>
|
||||
<li><strong>Total</strong> · <strong>Pendentes</strong> · <strong>Assinados</strong> · <strong>Recusados</strong></li>
|
||||
</ul>
|
||||
<p>Filtro por status (todos / pendentes / assinados) + lista. Click em <strong>Assinar agora</strong> num item pendente leva pro <code>/shared/document/:token</code> (mesma página pública, mas com auth já garantida via portal).</p>
|
||||
|
||||
<h3>7. Expiração e múltiplos usos</h3>
|
||||
<ul>
|
||||
<li><strong>Validade do link:</strong> configurada na criação (24h/3d/7d/30d). Após expirar, retorna 410 Gone. Terapeuta pode gerar novo link pelo botão <em>Compartilhar</em> no preview do doc.</li>
|
||||
<li><strong>Limite de usos:</strong> calculado como <code>max(signatários × 3, 5)</code> — gerar 1 signatário dá 5 usos disponíveis (margem de erro / reload / multi-device).</li>
|
||||
<li><strong>Cada assinatura é única:</strong> mesmo signatário não consegue assinar 2x — a RPC bloqueia se já houver registro com status=assinado.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Múltiplos signatários</h3>
|
||||
<p>Documentos como termo de autorização de menor podem precisar de 2+ assinaturas (responsável legal + paciente menor, ou os dois pais). O dialog aceita N signatários; cada um recebe sua própria entry em <code>document_signatures</code>. O link público é o mesmo — quando o paciente abre, escolhe qual signatário ele é no select e assina apenas a sua entry.</p>
|
||||
|
||||
<h3>9. Validade legal</h3>
|
||||
<p>A assinatura eletrônica registrada pelo sistema atende:</p>
|
||||
<ul>
|
||||
<li><strong>LGPD (Lei 13.709/2018):</strong> consentimento explícito registrado com timestamp, IP e UA — base legal Art. 7º I</li>
|
||||
<li><strong>Código de Ética CFP:</strong> documento clínico com identificação inequívoca do signatário (nome+email+IP+hash)</li>
|
||||
<li><strong>MP 2200-2/2001 (ICP-Brasil):</strong> assinatura <em>simples</em> com integridade via hash — não é certificado A1/A3, mas é válida pra documentos sem exigência ICP</li>
|
||||
</ul>
|
||||
<p>⚠️ Pra documentos que exigem ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada — esse fluxo não substitui.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>RPCs:</strong> <code>sign_document_by_signature_id</code> (paciente logado no portal), <code>sign_document_by_token</code> (link público), <code>get_signable_document_by_token</code> (resolve token → doc + signature_request), <code>list_my_signatures</code> (lista do paciente, cruza por signatario_id, signatario_email e patient.user_id)</li>
|
||||
<li><strong>Service:</strong> <code>DocumentSignatures.service.js</code> com wrappers <code>signByPortal</code>, <code>signByToken</code>, <code>getSignableDocumentByToken</code>, <code>listMySignatures</code>, <code>hashDocument</code>, <code>refuseSignature</code>, <code>createSignatureRequests</code>, <code>createShareLink</code>, <code>buildShareUrl</code></li>
|
||||
<li><strong>Composable:</strong> <code>useDocumentSignatures</code> (Tipo A blueprint)</li>
|
||||
<li><strong>UI lado terapeuta:</strong> <code>DocumentSignatureDialog.vue</code> (component)</li>
|
||||
<li><strong>UI lado paciente:</strong> <code>PortalDocumentos.vue</code> (portal logado) + <code>SharedDocumentPage.vue</code> (link público)</li>
|
||||
<li><strong>Notificação automática</strong> (paciente recebe email/WA quando signature criada) — pendente, depende de Módulo 6 (notifications factory channel)</li>
|
||||
</ul>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/paciente',
|
||||
5,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como peço pra um paciente assinar um documento (TCLE, contrato, autorização)?',
|
||||
$FAQ$Na aba <strong>Documentos</strong> do prontuário, clique no doc → no preview, clique em <strong>Assinar</strong> (sidebar de ações). O dialog abre. Adicione o paciente como signatário (nome + email), mantenha <em>"Gerar link público para assinatura"</em> marcado, escolha validade (7 dias é o default) e clique em <strong>Solicitar</strong>. O sistema cria a request e mostra uma URL pra copiar. Envie pro paciente via WhatsApp, email, SMS — como preferir.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Como o paciente assina sem ter login no sistema?',
|
||||
$FAQ$Ele abre o link público (<code>/shared/document/:token</code>) em qualquer navegador. Vê o PDF inline, lê o aviso LGPD/CFP, marca o checkbox <em>"Li o documento e concordo"</em>, e clica <strong>Assinar</strong>. O sistema computa hash SHA-256 do PDF, chama a RPC server-side que captura IP/User-Agent e registra a assinatura. Nada de cadastro, nada de senha.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Que informação fica registrada quando ele assina?',
|
||||
$FAQ$Tudo que precisa pra audit compliance: <strong>nome e email</strong> do signatário (do cadastro), <strong>timestamp server-side</strong> (não do relógio do cliente), <strong>hash SHA-256 do PDF</strong> no momento da assinatura (qualquer alteração posterior invalida a integridade), <strong>IP</strong> e <strong>User-Agent</strong> capturados pelo banco via <code>inet_client_addr()</code> e <code>current_setting('request.headers')</code> — anti-spoof. Tudo fica em <code>public.document_signatures</code>.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'O terapeuta também precisa assinar o documento?',
|
||||
$FAQ$Depende do tipo. Pra atestados, laudos e declarações, geralmente sim — você gera o PDF a partir do template (que já contém seu nome + registro profissional + assinatura digitalizada se você incluiu no rodapé). Pra contratos e termos com paciente como contraparte, você adiciona você mesmo como segundo signatário no dialog antes de enviar. Cada um abre o link e assina sua entry separadamente.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'O link tem validade? E se expirar?',
|
||||
$FAQ$Tem. Você escolhe 24h, 3 dias, 7 dias ou 30 dias na hora de criar (default 7d). Depois disso o link retorna erro 410 Gone. Se o paciente não assinou a tempo, gere um novo link: no preview do doc, clique em <strong>Compartilhar</strong> ou abra o dialog de assinatura novamente — vai criar outro token. Limite de usos do link: ~5 (margem pra reload/multi-device), depois também expira.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'E se o paciente recusar a assinatura?',
|
||||
$FAQ$Tem botão <strong>Recusar</strong> ao lado do <em>Assinar</em>. Clique pede confirmação; ao confirmar, a request fica com <code>status='recusado'</code> com timestamp e IP/UA registrados igual à assinatura. Você vê o status na lista de pendências do doc e na aba do prontuário. Geralmente: ajuste o conteúdo do documento e envie nova solicitação.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O paciente pode assinar depois pelo Portal sem precisar do link?',
|
||||
$FAQ$Sim, se ele tem conta de portal. Em <strong>/portal/documentos</strong> aparece a lista de tudo que está pendente pra ele assinar, com KPIs (total, pendentes, assinados, recusados) e botão <strong>Assinar agora</strong> que leva pra mesma página de assinatura. Útil pra pacientes que perderam o link no WhatsApp — eles loga e acha tudo num lugar só.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como compartilho o link com o paciente — tem envio automático?',
|
||||
$FAQ$Hoje o envio é <strong>manual</strong>: o dialog gera a URL, você copia e cola onde quiser (WhatsApp, email, SMS, AirDrop, QR code, link em conversa direta). Envio automático (notificação por WA/email quando signature é criada) está no roadmap, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'A assinatura tem validade legal mesmo sem certificado ICP-Brasil?',
|
||||
$FAQ$Pra documentos clínicos comuns (TCLE, contrato de prestação, autorizações, declarações entre terapeuta-paciente), <strong>sim</strong>. A assinatura simples com timestamp + hash + IP + UA atende LGPD (Art. 7º I — consentimento explícito) e o Código de Ética do CFP. Pra documentos que exigem certificado ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada — esse fluxo não substitui.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'O paciente consegue editar o documento antes de assinar?',
|
||||
$FAQ$Não. O paciente <strong>só visualiza</strong> — o PDF é renderizado em iframe e a integridade é garantida pelo hash SHA-256 computado no momento da assinatura. Se o conteúdo precisar mudar, é você que ajusta o documento (editar via template ou regenerar) e envia nova solicitação. A assinatura antiga (se houve) fica registrada com o hash do conteúdo antigo — o doc atual tem hash diferente, mostrando que mudou.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como cancelo uma solicitação de assinatura?',
|
||||
$FAQ$Hoje não há um botão "cancelar" direto na UI. O caminho é: ignore (deixa expirar pelo prazo do link) ou peça pro admin marcar como <code>status='expirado'</code> no banco. Em versões futuras teremos botão de cancelar na lista de pendências do doc.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso pedir mais de uma pessoa pra assinar o mesmo documento?',
|
||||
$FAQ$Sim. Pra termos com múltiplos signatários (autorização de atendimento de menor com 2 pais, contrato com responsável legal + paciente), adicione cada um como signatário separado no dialog. Cada um vira uma entry em <code>document_signatures</code>. O link público é o mesmo — quando o signatário abre, escolhe quem ele é no select acima dos botões e assina apenas a entry dele. Útil também pra você incluir si mesmo (terapeuta) + paciente num contrato bilateral.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Importação da doc Fase 1 (Busca global + Recently viewed)
|
||||
-- Gerado a partir de development/saas-docs/01-busca-global-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
-- 1) Cria a doc principal
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Busca global e Acessados recentemente',
|
||||
$HTML$<h2>Busca global no Layout Melissa</h2>
|
||||
|
||||
<p>A <strong>busca global</strong> é o atalho mais rápido para encontrar pacientes, sessões, documentos e cadastros recebidos sem precisar navegar pelos menus. Você acessa pelo <em>dock central</em> do Layout Melissa ou usando o atalho de teclado <kbd>Ctrl</kbd> + <kbd>K</kbd> (em qualquer página do Melissa).</p>
|
||||
|
||||
<h3>1. Como abrir</h3>
|
||||
<p>Localize o campo de busca no dock central do Melissa. Ele aparece como um botão com o ícone de lupa e o placeholder <em>"Buscar paciente, agenda, atalho…"</em>, com o atalho <kbd>Ctrl K</kbd> indicado no canto direito.</p>
|
||||
|
||||
<div style="border: 1px solid #cbd5e1; border-radius: 12px; padding: 0 14px; height: 44px; max-width: 480px; display: flex; align-items: center; gap: 10px; background: #1e2333; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
|
||||
<i class="pi pi-search" style="font-size: 0.95rem;"></i>
|
||||
<span style="flex: 1; font-size: 0.9rem;">Buscar paciente, agenda, atalho…</span>
|
||||
<span style="font-size: 0.62rem; padding: 2px 7px; border-radius: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); letter-spacing: 0.05em;">Ctrl K</span>
|
||||
</div>
|
||||
|
||||
<p><strong>Três jeitos de abrir:</strong></p>
|
||||
<ul>
|
||||
<li>Clicando no campo no dock central</li>
|
||||
<li>Pressionando <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd>⌘ + K</kbd> (Mac) em qualquer página</li>
|
||||
<li>Pelo menu lateral, opção "Buscar" (quando disponível)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. O Dialog Spotlight</h3>
|
||||
<p>Ao abrir, o sistema mostra um <strong>diálogo centralizado</strong> com o input grande no topo e os resultados em colunas abaixo. Isso é o padrão Spotlight (igual ao usado em macOS, Linear, GitHub, Slack).</p>
|
||||
|
||||
<div style="background: var(--surface-card, #fff); border: 1px solid #e2e8f0; border-radius: 14px; max-width: 520px; box-shadow: 0 12px 32px rgba(0,0,0,0.15); overflow: hidden; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
|
||||
<div style="padding: 14px 18px; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; gap: 12px;">
|
||||
<i class="pi pi-search" style="color: #64748b;"></i>
|
||||
<span style="flex: 1; color: #94a3b8; font-size: 1.05rem;">Buscar paciente, agenda, atalho…</span>
|
||||
<span style="font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; background: #f1f5f9; border: 1px solid #e2e8f0; color: #64748b;">Esc</span>
|
||||
</div>
|
||||
<div style="padding: 6px;">
|
||||
<div style="text-transform: uppercase; letter-spacing: 0.18em; color: #64748b; font-size: 0.62rem; font-weight: 700; padding: 8px 10px 4px; opacity: 0.75;">Acessados recentemente</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px;">
|
||||
<span style="width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: rgba(244,114,182,0.18); color: #ec4899; font-size: 0.9rem;">
|
||||
<i class="pi pi-user"></i>
|
||||
</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 0.88rem; font-weight: 500;">André Green</div>
|
||||
<div style="font-size: 0.74rem; color: #64748b;">andre@email.com</div>
|
||||
</div>
|
||||
<i class="pi pi-arrow-right" style="color: #94a3b8; font-size: 0.75rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>3. Onde a busca procura</h3>
|
||||
<p>Digitando <strong>pelo menos 2 caracteres</strong>, o sistema dispara uma busca completa em 5 categorias:</p>
|
||||
<ul>
|
||||
<li><strong style="color: #ec4899;">Pacientes</strong> — por nome completo, e-mail, telefone ou CPF</li>
|
||||
<li><strong style="color: #6366f1;">Sessões</strong> — por título ou nome do paciente, em qualquer data</li>
|
||||
<li><strong style="color: #0ea5e9;">Documentos</strong> — por nome do arquivo ou descrição</li>
|
||||
<li><strong style="color: #f97316;">Cadastros recebidos</strong> — solicitações de novos pacientes pendentes</li>
|
||||
<li><strong>Atalhos</strong> — ações rápidas como "Agenda", "Financeiro", etc.</li>
|
||||
</ul>
|
||||
|
||||
<p>Cada categoria aparece com um <strong>ícone colorido distinto</strong> para facilitar a leitura visual. Os resultados são limitados aos 6 mais relevantes por categoria.</p>
|
||||
|
||||
<h3>4. Como navegar nos resultados</h3>
|
||||
<p>Você pode usar o mouse ou o teclado:</p>
|
||||
<ul>
|
||||
<li><kbd>↑</kbd> / <kbd>↓</kbd> — navegar entre os resultados</li>
|
||||
<li><kbd>Enter</kbd> — abrir o item selecionado</li>
|
||||
<li><kbd>Esc</kbd> — fechar o diálogo</li>
|
||||
<li><kbd>Clique no backdrop</kbd> — fecha também</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Acessados recentemente</h3>
|
||||
<p>Quando você abre a busca <strong>sem digitar nada</strong>, a primeira seção mostra <strong>"Acessados recentemente"</strong> — os últimos 5 pacientes que você visitou (em qualquer dispositivo deste navegador).</p>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 12px 0; font-size: 0.88rem; color: #475569;">
|
||||
<strong>💡 Dica:</strong> Use Ctrl+K + Enter para reabrir o último paciente acessado em 2 segundos.
|
||||
</div>
|
||||
|
||||
<p>Esses 5 pacientes ficam salvos no seu navegador (não no banco de dados), então:</p>
|
||||
<ul>
|
||||
<li>São <strong>privados</strong> — outros usuários não veem</li>
|
||||
<li>São <strong>por navegador</strong> — se trocar do Chrome pro Firefox, a lista recomeça</li>
|
||||
<li><strong>Persistem</strong> após fechar o navegador (localStorage)</li>
|
||||
<li>Auto-rotacionam: ao acessar o 6º paciente, o mais antigo sai</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Clique nos resultados</h3>
|
||||
<p>Ao clicar:</p>
|
||||
<ul>
|
||||
<li><strong>Paciente</strong> → abre o prontuário (<code>/melissa/paciente?id=…</code>)</li>
|
||||
<li><strong>Sessão</strong> → abre o evento na agenda</li>
|
||||
<li><strong>Documento</strong> → abre o prontuário do paciente na aba Documentos</li>
|
||||
<li><strong>Cadastro recebido</strong> → vai pra lista de Cadastros recebidos</li>
|
||||
<li><strong>Atalho</strong> → navega pra seção (Agenda, Financeiro, etc.)</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Tema claro × escuro</h3>
|
||||
<p>O Dialog adapta automaticamente as cores conforme o tema escolhido em <strong>Meu Perfil → Preferências</strong>. Texto, fundos e bordas seguem as configurações do sistema. Apenas os ícones por categoria (paciente rosa, sessão índigo, documento azul, cadastro laranja) mantêm a mesma cor para preservar a identificação visual rápida.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>Atualmente o componente <code>MelissaBusca.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
|
||||
<ul>
|
||||
<li><code>id="melissa-busca-trigger"</code> no botão de trigger no dock</li>
|
||||
<li><code>id="melissa-busca-dialog"</code> no Dialog</li>
|
||||
<li><code>id="melissa-busca-input"</code> no input dentro do Dialog</li>
|
||||
<li><code>id="melissa-busca-recent"</code> no grupo de Acessados recentemente</li>
|
||||
</ul>$HTML$,
|
||||
'Navegação',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa',
|
||||
1,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
-- 2) Insere os 12 FAQ items vinculados
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como abrir a busca rapidamente?',
|
||||
$FAQ$Use o atalho <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd>⌘ + K</kbd> (Mac) em qualquer página do Melissa. Você também pode clicar diretamente no campo de busca no dock central.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Posso buscar paciente por telefone ou CPF?',
|
||||
$FAQ$Sim. A busca de pacientes encontra pelo <strong>nome completo, e-mail, telefone ou CPF</strong>. Digite pelo menos 2 caracteres e aguarde os resultados.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'O que aparece em "Acessados recentemente"?',
|
||||
$FAQ$Os últimos 5 pacientes que você abriu pelo prontuário, em ordem do mais recente pro mais antigo. A lista aparece quando você abre a busca <strong>sem digitar nada</strong>.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Outros usuários veem meus "Acessados recentemente"?',
|
||||
$FAQ$Não. A lista é <strong>privada e local</strong> — fica salva apenas no seu navegador atual (localStorage). Se você logar em outro navegador ou computador, a lista começa vazia naquele dispositivo.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Quantos caracteres preciso digitar pra começar a buscar?',
|
||||
$FAQ$<strong>Pelo menos 2</strong>. Buscas de 1 caractere são muito amplas e não disparam pesquisa. A partir de 2 caracteres, o sistema aguarda 200ms (tempo de digitação) antes de consultar o banco — assim você não dispara dezenas de buscas digitando rápido.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Por que minha busca não retorna nada?',
|
||||
$FAQ$Verifique: (1) digitou pelo menos 2 caracteres; (2) o termo está sem erros graves de digitação (a busca tolera pequenas variações via similarity); (3) o paciente/sessão realmente existe no seu cadastro. Se persistir, faça uma busca mais ampla — ex: apenas o primeiro nome.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O que cada cor de ícone significa?',
|
||||
$FAQ$Cada categoria tem uma cor própria: <strong style="color: #ec4899;">Rosa</strong> = Paciente, <strong style="color: #6366f1;">Índigo</strong> = Sessão da agenda, <strong style="color: #0ea5e9;">Azul</strong> = Documento, <strong style="color: #f97316;">Laranja</strong> = Cadastro recebido pendente. Atalhos vêm em cinza neutro.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como navegar pelos resultados sem usar o mouse?',
|
||||
$FAQ$Use as setas do teclado <kbd>↑</kbd> e <kbd>↓</kbd> para navegar entre os itens e <kbd>Enter</kbd> para abrir o selecionado. Pra fechar sem selecionar, use <kbd>Esc</kbd>.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Posso buscar documentos pelo nome do paciente?',
|
||||
$FAQ$Sim. A busca de documentos cruza pelo nome do arquivo, descrição e <strong>nome do paciente vinculado</strong>. Ao clicar num resultado de documento, você é levado direto pra aba Documentos do prontuário daquele paciente.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Como limpar a lista de "Acessados recentemente"?',
|
||||
$FAQ$Hoje não há um botão na interface — a lista é gerenciada automaticamente (limite de 5, mais antigo cai quando você acessa um novo). Pra limpar manualmente, você pode apagar os dados do site no seu navegador (Configurações → Privacidade → Limpar dados de navegação → escopo "localStorage").$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'A busca encontra sessões antigas ou só as de hoje?',
|
||||
$FAQ$Encontra sessões de <strong>qualquer data</strong> — passadas e futuras. O grupo "Agenda de hoje" mostra apenas as do dia atual (preview rápido); o grupo "Sessões" inclui todas as outras encontradas no banco. Cada item mostra a data e horário da sessão.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os atalhos (Agenda, Financeiro, etc.) sempre aparecem?',
|
||||
$FAQ$Sim. Quando o campo está vazio, mostramos 4 atalhos padrão. Conforme você digita, os atalhos que combinam com sua busca permanecem visíveis (junto com os resultados do banco).$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Importacao da doc do Cronometro de sessao (Melissa)
|
||||
-- Gerado a partir de development/saas-docs/02-cronometro-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
-- 1) Cria a doc principal
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Cronômetro de sessão',
|
||||
$HTML$<h2>Cronômetro de sessão</h2>
|
||||
|
||||
<p>O <strong>cronômetro de sessão</strong> acompanha o tempo decorrido durante o atendimento e é integrado com a agenda. Quando aberto a partir de uma sessão em andamento, ele já vem com o paciente pré-selecionado e dispara automaticamente.</p>
|
||||
|
||||
<h3>1. Três jeitos de abrir</h3>
|
||||
<ul>
|
||||
<li>Pelo botão <strong>⏱</strong> ao lado do relógio gigante do dashboard (abre vazio, escolha o paciente — ou deixe como atividade livre)</li>
|
||||
<li>Pelo botão <strong>⏱</strong> que aparece sobre os cards de sessão em curso na timeline horizontal/vertical do dashboard</li>
|
||||
<li>Pelo CTA <strong>"Iniciar cronômetro"</strong> no card <em>"Próximo paciente"</em> quando a sessão está em andamento</li>
|
||||
</ul>
|
||||
<p>Os dois últimos pré-selecionam o paciente da sessão e disparam o timer automaticamente.</p>
|
||||
|
||||
<h3>2. Sessão em curso na timeline</h3>
|
||||
<p>Quando uma sessão entra em andamento (horário atual entre início e fim do evento), aparece um ícone <strong>⏱</strong> pulsando no canto superior direito do card do evento. O pulso é sutil, em verde — só pra sinalizar que dá pra cronometrar dali.</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px; background: #6366f1; color: white; padding: 4px 10px; border-radius: 4px; max-width: 320px; position: relative; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
|
||||
<span style="font-size: 0.85rem; font-weight: 600;">11:00 – Larissa Almeida</span>
|
||||
<span style="position: absolute; top: 3px; right: 3px; width: 22px; height: 22px; display: grid; place-items: center; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.4); border-radius: 999px; color: white; box-shadow: 0 0 0 4px rgba(16,185,129,0.25);">
|
||||
<i class="pi pi-stopwatch" style="font-size: 0.7rem;"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>Clicar no <strong>⏱</strong> <strong>não abre o evento</strong> — abre o cronômetro pré-configurado pra essa sessão.</p>
|
||||
|
||||
<h3>3. Programado vs tempo real</h3>
|
||||
<p>Quando aberto via timeline ou card "Próximo paciente", o cronômetro mostra o <strong>horário programado original da sessão</strong> sob o select de paciente. Se você abriu depois do horário previsto, aparece um badge laranja <strong>"atrasada X min"</strong>.</p>
|
||||
|
||||
<div style="background: rgba(15,23,42,0.85); color: #cbd5e1; padding: 14px; border-radius: 10px; max-width: 360px; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
|
||||
<label style="font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8px;">Paciente / atividade</label>
|
||||
<div style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); padding: 9px 14px; border-radius: 10px; font-size: 0.9rem;">Larissa Almeida</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 4px 0;">
|
||||
<i class="pi pi-calendar" style="font-size: 0.7rem; color: rgba(255,255,255,0.55);"></i>
|
||||
<span style="font-size: 0.78rem; color: rgba(255,255,255,0.7);">Programado: 11:00 – 11:50</span>
|
||||
<span style="margin-left: 4px; padding: 1px 8px; border-radius: 999px; background: rgba(251,146,60,0.18); color: rgb(253,186,116); font-size: 0.7rem; font-weight: 500; border: 1px solid rgba(251,146,60,0.35);">atrasada 8 min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>⚠️ <strong>O cronômetro NÃO desconta o tempo de atraso automaticamente.</strong> Ele conta a duração configurada cheia (50min padrão) a partir do clique. A info "atrasada" é só pra você decidir se quer encerrar antes ou estender.</p>
|
||||
|
||||
<h3>4. Anatomia do dialog</h3>
|
||||
<ul>
|
||||
<li><strong>Header:</strong> rótulo "Cronômetro" + status (<em>Pronto</em> / <em>Em andamento</em> / <em>Pausado</em>)</li>
|
||||
<li><strong>Botão Minimizar:</strong> recolhe pro chip no dock</li>
|
||||
<li><strong>Botão X (Encerrar sem salvar):</strong> descarta a sessão (com confirmação se houver atividade)</li>
|
||||
<li><strong>Select de paciente:</strong> pode trocar manualmente; opção <em>"— Atividade livre"</em> pra usos sem paciente</li>
|
||||
<li><strong>Programado + badge de atraso:</strong> só aparece quando aberto via evento da agenda</li>
|
||||
<li><strong>Display gigante mm:ss:</strong> vira vermelho quando passa do tempo planejado (mostra <code>-mm:ss</code>)</li>
|
||||
<li><strong>±5 min:</strong> estende ou encurta o tempo configurado a qualquer momento</li>
|
||||
<li><strong>Botão grande inferior:</strong> ▶ Começar / ⏹ Parar</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Minimizar e restaurar</h3>
|
||||
<p>Click no <strong>botão minimizar</strong> (ou click fora do dialog) <strong>recolhe</strong> o cronômetro pra um <strong>chip flutuante no dock</strong> (canto inferior esquerdo, ao lado do ψ). O timer continua rodando em background. Click no chip restaura o dialog em tela cheia.</p>
|
||||
|
||||
<div style="display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px 8px 12px; background: rgba(15,23,42,0.85); border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.25); margin: 12px 0;">
|
||||
<i class="pi pi-stopwatch" style="font-size: 0.85rem; color: #6ee7b7;"></i>
|
||||
<span style="font-variant-numeric: tabular-nums; font-weight: 500; font-size: 0.85rem;">48:13</span>
|
||||
<span style="font-size: 0.72rem; color: rgba(255,255,255,0.6); padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.18);">Larissa Almeida</span>
|
||||
</div>
|
||||
|
||||
<p>Em mobile (telas <768px), o chip mostra só o ícone + tempo — sem o nome do paciente — pra caber no dock estreito. O nome continua acessível ao restaurar.</p>
|
||||
|
||||
<h3>6. Parar (salva) vs Fechar (descarta)</h3>
|
||||
<p>Duas ações diferentes pra terminar — a escolha importa:</p>
|
||||
<ul>
|
||||
<li><strong>⏹ Parar</strong> (botão grande inferior): encerra a contagem e <strong>SALVA o tempo decorrido no banco</strong> (evento <code>session-end</code> com elapsed em segundos). Caminho normal de fim de sessão.</li>
|
||||
<li><strong>X Encerrar sem salvar</strong> (header): descarta. Pede <strong>confirmação</strong> se há sessão em andamento ou tempo decorrido — não fecha por acidente. Se o cronômetro está limpo (não iniciado, sem tempo), fecha direto.</li>
|
||||
<li><strong>Click fora / Minimizar</strong>: NÃO encerra. Esconde o dialog e mantém o timer rodando como chip no dock.</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Quando o tempo acaba</h3>
|
||||
<p>Aos <strong>50 minutos cronometrados</strong> (ou conforme configurado), o sistema toca um <strong>som curto</strong> uma única vez. O display vira <strong>vermelho</strong> e continua contando em negativo (mostra <code>-mm:ss</code>). <strong>Não há corte automático</strong> — você decide quando parar.</p>
|
||||
|
||||
<p>O toque pode ser trocado em <strong>Configurações → Cronômetro → Som de término</strong>. Opções: sino, gong, soft, silêncio.</p>
|
||||
|
||||
<h3>8. Persistência (reload-safe)</h3>
|
||||
<p>Se você fechar a aba ou recarregar o navegador com cronômetro ativo, ao voltar o sistema <strong>retoma exatamente de onde parou</strong> — descontando automaticamente o tempo passado entre fechar e abrir. O snapshot fica no <code>localStorage</code> do navegador, atualizado a cada mudança de estado.</p>
|
||||
|
||||
<p><strong>Limite de segurança:</strong> se passar de 24h sem voltar à aba, o restore não acumula o tempo perdido (proteção contra mudanças do relógio do sistema).</p>
|
||||
|
||||
<h3>9. Cronômetro já ativo</h3>
|
||||
<p>Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong>⏱</strong> de outra sessão enquanto há cronômetro rodando, o sistema mostra um toast <strong>"Cronômetro já ativo"</strong> com o nome do paciente atual — <strong>e não troca</strong>. Pare o cronômetro atual antes de iniciar outro.</p>
|
||||
|
||||
<h3>10. Atividade livre (sem paciente)</h3>
|
||||
<p>Você pode abrir o cronômetro sem paciente (botão ⏱ do dashboard, sem clicar em evento específico) e selecionar <em>"— Atividade livre (sem paciente)"</em> no dropdown. Útil pra:</p>
|
||||
<ul>
|
||||
<li>Pausa cronometrada</li>
|
||||
<li>Pomodoro pessoal</li>
|
||||
<li>Atendimento informal não cadastrado</li>
|
||||
</ul>
|
||||
<p>Atividade livre <strong>não emite session-end</strong> ao parar (não há paciente pra vincular o tempo).</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>Atualmente o componente <code>MelissaCronometro.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
|
||||
<ul>
|
||||
<li><code>id="crono-trigger-hero"</code> no botão ⏱ ao lado do relógio (<code>MelissaHeroClock.vue</code>)</li>
|
||||
<li><code>id="crono-trigger-timeline"</code> nos botões ⏱ overlay (<code>MelissaTimelineHoje.vue</code>)</li>
|
||||
<li><code>id="crono-dialog"</code> no panel principal (<code>.mc-panel</code>)</li>
|
||||
<li><code>id="crono-stop-btn"</code> no botão Parar (caminho do salvamento)</li>
|
||||
<li><code>id="crono-close-btn"</code> no X (caminho do descarte)</li>
|
||||
</ul>$HTML$,
|
||||
'Sessão',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa',
|
||||
2,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
-- 2) Insere os 12 FAQ items vinculados
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como começo o cronômetro da Larissa que chegou agora pra sessão?',
|
||||
$FAQ$Quando o horário programado da Larissa estiver dentro da janela do evento (já começou na agenda), aparece um botão <strong>⏱</strong> pulsando em verde no canto superior direito do card da sessão na timeline. Clique nele — o cronômetro abre com a Larissa pré-selecionada e <strong>já começa a contar automaticamente</strong>. Alternativa: clique no botão <em>"Iniciar cronômetro"</em> no card "Próximo paciente" do dashboard (mesmo efeito).$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'O cronômetro continua se eu fechar a aba do navegador?',
|
||||
$FAQ$<strong>Sim.</strong> O estado é salvo no <code>localStorage</code> a cada mudança (paciente, play, pause, ajustes). Ao reabrir a aba (ou recarregar), o cronômetro retoma do ponto correto — o tempo passado entre fechar e abrir é descontado automaticamente. Limite: se passar de 24h, o sistema não acumula esse tempo (proteção contra mudanças do relógio do sistema).$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Cliquei no X com sessão rodando, perdi o tempo?',
|
||||
$FAQ$Não, o sistema <strong>pede confirmação antes</strong>. Quando há sessão em andamento ou tempo decorrido sem salvar, aparece um diálogo <em>"Encerrar sessão sem salvar?"</em>. Você precisa clicar em <strong>"Encerrar sem salvar"</strong> (botão vermelho) pra confirmar o descarte. Se o cronômetro estiver limpo (não iniciado, sem tempo), o X fecha direto — não há nada pra preservar.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Posso ter dois cronômetros rodando ao mesmo tempo?',
|
||||
$FAQ$Não. Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong>⏱</strong> de outra sessão enquanto já há um cronômetro ativo, o sistema mostra um toast <em>"Cronômetro já ativo — sessão de X em andamento"</em> e <strong>não troca</strong>. Pare ou descarte o atual antes de iniciar outro.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'O que significa o badge laranja "atrasada 8 min"?',
|
||||
$FAQ$Significa que <strong>o cronômetro foi aberto 8 minutos depois do horário programado</strong> da sessão na agenda. Por exemplo: sessão programada pra 11:00, você inicia o cronômetro às 11:08. O badge é apenas informativo — o cronômetro continua contando a duração configurada cheia (50min padrão) a partir do clique. Você decide se vai encerrar antes pra terminar no horário previsto ou deixar rodar pra dar a sessão completa.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'O cronômetro desconta o tempo de atraso automaticamente?',
|
||||
$FAQ$<strong>Não.</strong> A decisão fica com você. Cada clínica e cada terapeuta tem uma política diferente pra atraso (alguns dão sessão cheia, outros encerram no horário programado, outros estendem). O cronômetro mostra a info do atraso pra você decidir, mas conta sempre a duração configurada cheia a partir do clique.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Que som toca quando o tempo acaba?',
|
||||
$FAQ$Por padrão, um <strong>som de sino curto</strong>, uma única vez. Você pode trocar em <strong>Configurações → Cronômetro → Som de término</strong>. Opções: sino, gong, soft, silêncio. O som toca <strong>exatamente na transição</strong> de tempo positivo pra zero/negativo — não repete. Depois disso o display continua contando em negativo (vermelho) até você parar.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como adiciono mais tempo na sessão sem reiniciar?',
|
||||
$FAQ$Use os botões <strong>+5 min</strong> e <strong>-5 min</strong> ao redor do display gigante. Funcionam a qualquer momento — antes, durante ou depois do tempo acabar. Cada clique soma ou desconta 5 minutos. Se o tempo já está negativo (passou do limite), +5min volta a contagem pra positivo.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Onde fica salvo o tempo final da sessão?',
|
||||
$FAQ$Quando você clica em <strong>⏹ Parar</strong>, o tempo cronometrado é gravado no banco vinculado à sessão da agenda (na tabela <code>agenda_eventos</code>, campo de duração real). Esse caminho é o oficial — <strong>fechar pelo X descarta sem salvar</strong>. Sessões com menos de 5 segundos cronometrados são ignoradas (proteção contra start/stop acidentais).$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Posso usar o cronômetro pra coisas que não são sessão de paciente?',
|
||||
$FAQ$Sim. Selecione <em>"— Atividade livre (sem paciente)"</em> no dropdown de paciente. Útil pra pausa cronometrada, pomodoro pessoal, atendimento informal não cadastrado. Atividade livre <strong>não dispara session-end</strong> ao parar — não há paciente pra vincular o tempo no DB.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como minimizo o cronômetro pra continuar trabalhando?',
|
||||
$FAQ$Clique no botão <strong>minimizar</strong> no header (ícone <code>—</code>) ou simplesmente <strong>clique fora do dialog</strong>. O cronômetro vira um chip flutuante no dock (canto inferior esquerdo, ao lado do ψ) e continua contando em background. Pra restaurar: clique no chip. Em mobile, o chip mostra só ícone + tempo (sem nome) pra caber no dock estreito.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Como mudo o paciente no cronômetro já aberto?',
|
||||
$FAQ$Basta clicar no <strong>select de paciente</strong> e escolher outro. A troca é imediata — não reinicia o tempo decorrido (a contagem continua igual). Útil quando você abriu o cronômetro no paciente errado e quer corrigir sem perder o tempo já contado. Mas atenção: o <em>session-end</em> ao parar vai vincular o tempo ao paciente que estiver selecionado <em>no momento da parada</em>.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user