3 Commits

Author SHA1 Message Date
Leonardo 41c44272a3 agenda: dialog UX (bloqueio paciente arquivado/inativo + resumo sticky + card extras + observacao via commitment)
BLOQUEIO REAL Inativo + AVISO Arquivado futura:
- useAgendaEventComposer.canSave agora bloqueia edicao de sessao futura
  quando !perms.canReschedule. Antes canReschedule era dead code
  (definido em getPatientAgendaPermissions mas nunca consumido). Pra
  Inativo: canReschedule=false => Save desabilitado de verdade (antes
  o aviso "Remarcacao bloqueada" mentia, save acontecia mesmo).
- Aviso novo (severity=info) em AgendaEventDialog + V2 pra Arquivado +
  futura + edit: "Sessao futura editavel; novos agendamentos e
  recorrencias bloqueados". Cobria um gap onde nao havia aviso nenhum
  pra esse cenario.

RESUMO FLUTUANTE acompanha o Dialog:
- ResizeObserver no .p-dialog.agenda-event-composer mede top + altura
  e sincroniza com :style do aside via ref resumoStyle. Antes o aside
  tinha top:5vh fixo — dialog baixo (Bloqueio/Atividade) centrava
  vertical e o resumo ficava preso la em cima desalinhado.

CARD "Campos Extras (compromisso)":
- Bloco de selectedCommitmentFields extraido da fields-grid pra um
  .field-card proprio com header pi-list + titulo + .aed-extras-body
  (padding 0.85rem). Hierarquia visual clara: campos do compromisso
  ficam isolados dos campos do form principal.
- Bind especial pra f.key==='notes': v-model="form.observacoes" em vez
  de form.extra_fields.notes. Mantem compat com a coluna nativa
  agenda_eventos.observacoes (consumida por relatorios/prontuario).
- Textareas hardcoded de Observacao removidas do form (fields-grid +
  side-card direito) — agora vem como campo extra do commitment Sessao,
  via migration 20260511000001 (commit anterior).

OUTROS:
- Card "Pagamento" renomeado pra "Sessao / Honorarios" (cobre os 3
  tipos: gratuito, particular, convenio — terminologia mais alinhada
  ao vocabulario clinico).
- composer-grid e composer-right ganharam gap:0 — os cards filhos
  ja tem mb-4 proprio (Tailwind ~1rem), gap do flex duplicava.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:57:16 -03:00
Leonardo dba595fd2d db: migration session_default_notes_field + schema regenerado
Migration 20260511000001 adiciona campo 'notes' (Observacao, textarea,
sort_order=30) como campo extra default no commitment determinado 'Sessao'.
Antes Sessao era a unica excecao entre os nativos — Leitura/Supervisao/
Aula/Analise ja tinham. Padroniza pra que a Observacao da sessao siga o
mesmo mecanismo de extra_fields dos outros, e o frontend remova a textarea
hardcoded do AgendaEventDialog (proximo commit).

Backfill: insere 'notes' em TODOS os commitments Sessao ja existentes
(idempotente). Forward-fix: substitui a funcao seed_determined_commitments
incluindo o bloco de Sessao + 'notes' pra novos tenants.

Schema regenerado via db.cjs schema-export pra refletir o estado pos-
migration. agenciapsi-db-dashboard.html regenerado pelo
generate-dashboard.cjs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:56:59 -03:00
Leonardo af8aee9188 wiki: documenta recorrencia da agenda + log da sessao 2026-05-11
Nova pagina [[recorrencia-agenda]] cobrindo: modelo "1 real + N-1 virtual"
via useRecurrence, quem expande virtuais (composables corrigidos em 39cf017),
pattern de materializacao ao mudar status (4 caminhos), view listAll de
2 anos no MelissaAgenda, visual de evento inativo, e query SQL pra detectar
rows orfas. index.md ganhou link sob Concepts.

Log entry da sessao 2026-05-11 10:50 cobrindo os 6 commits previos
(8b0e633..39cf017): time picker, services nome unico, paciente arquivado/
inativo, AgendaEventDialog overhaul, view lista Melissa, expansao+
materializacao de recorrencia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:56:49 -03:00
39 changed files with 4291 additions and 510 deletions
+58
View File
@@ -543,3 +543,61 @@ placeholders com display:contents; hide rules por breakpoint. Card de preview
usa mag-w--side e perde fundo/borda no floating (glass do painel ja faz papel). usa mag-w--side e perde fundo/borda no floating (glass do painel ja faz papel).
ESLint 0 errors. Working tree: src/auto-imports.d.ts (auto-gerado) + ESLint 0 errors. Working tree: src/auto-imports.d.ts (auto-gerado) +
MelissaAgendador.vue. Nao commitado, nao testado em browser ainda. MelissaAgendador.vue. Nao commitado, nao testado em browser ainda.
## [2026-05-11 10:50] session | Recorrencia: expandir + materializar + view lista
Touched: [[recorrencia-agenda]]
Detalhes: 6 commits criados e pushed (8b0e633..39cf017).
TIME PICKER do AgendaEventDialog (commit 988a4e5):
- Header dinamico (header-dot + "Nova {commitment.name}" + subtitulo
"Inicio da sessao e duracao"). Inicio + Termino lado a lado (Termino
readonly via fimDateTime). Card destacado de Termino removido.
- Picker virou DataTable (.aed-patient-dt) + Tags Arquivado/Inativo + sort
Ativo>Inativo>Arquivado.
- Cadastro completo INLINE via PatientCadastroDialog (botao pi-id-card)
em vez de redirecionar pra rota nova — nao vaza do layout Melissa.
Usa prop hideViewListButton adicionada antes pra esconder "Salvar e
ver pacientes".
- Mini calendar (.mc-mini) no time picker; chips de duracao rapida
(30/50/60/90m); cards .aed-card; popovers de ajuda.
EXPANSAO DE RECORRENCIA cross-layout (commit 39cf017): 3 composables
compartilhados ganharam loadAndExpand — antes so AgendaTerapeutaPage
e AgendaClinicaPage expandiam, deixando widgets do Melissa com 1 sessao
de uma serie de 4. usePatientSessions.load (range -6mo a +12mo, filtra
por patient_id), useMelissaEventos._fetchRange (range visivel),
useMelissaTodasSessoesPaciente.fetch. normalizeEvent aceita shape de
virtual (paciente_nome/patient_name) alem de joined query.
MATERIALIZACAO em 4 caminhos: UPDATE em id virtual "rec::..." quebrava
com "invalid input syntax for type uuid". Corrigido em
usePatientSessions.updateStatus (aceita row inteira, materializa),
useAgendaEventActions watcher (emit updateSeriesEvent com row),
MelissaLayout.updateEventoStatus (detecta virtual, delega passando
row: ev — sem isso dialogEventRow ficava vazio e criava row orfa sem
patient_id), MelissaPaciente wire-up (@updateSeriesEvent aponta pro
handler certo agora), useMelissaAgenda.onUpdateSeriesEvent (aceita row
do chamador, guard contra rid null, error check no maybeSingle).
VIEW LISTA MelissaAgenda (commit 279b4f7): listWeek -> custom listAll
(duration { years: 2 }, centrada via gotoDate(hoje - 1 ano)). Banner
showRecurrenceHint aparece em day/week/month com botao "Ver na lista".
Sticky day header (.fc-list-day) com z-index 3 + bg opaco — antes
.fc-event passava por cima conforme scroll. View toggle dos botoes
manuais -> PrimeVue SelectButton.
VISUAL EVENTO INATIVO: classNames=['ma-evt--inactive-patient'] em
fcEvents quando paciente_status === Arquivado|Inativo (borda tracejada
+ opacidade 0.58 + italico em list view). useAgendaEventPickerBilling
+ AgendaEventDialogV2: picker mostra TODOS os pacientes ordenados
Ativo>Inativo>Arquivado, nao-Ativos com Tag colorida + disabled +
tooltip. selectPaciente bloqueia non-Ativo (defesa em camadas, 3
specs novas).
OUTROS: services nome unico por owner (case-insensitive); FC touch
defaults centralizados em src/features/agenda/utils/fcDefaults.js
aplicado em 4 calendars; props hideViewListButton em
ComponentCadastroRapido + PatientCadastroDialog pra uso in-flow.
Database backup gerado: backups/2026-05-11/ (138 tabelas, 141 FKs).
Dashboard regenerado.
+2
View File
@@ -14,6 +14,8 @@ _(people, places, organizations, products — pages that describe a thing)_
_(ideas, frameworks, patterns, principles — pages that describe a concept)_ _(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 ## Sources
_(summaries of specific sources you've ingested)_ _(summaries of specific sources you've ingested)_
+91
View File
@@ -0,0 +1,91 @@
# 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.
## Referências de código
- `src/features/agenda/composables/useRecurrence.js``loadAndExpand`, `expandRules`, `mergeWithStoredSessions`, `buildOccurrence`
- `src/layout/melissa/composables/useMelissaAgenda.js:809``onUpdateSeriesEvent`
- `src/features/agenda/composables/useAgendaEventActions.js:65` — watcher do form.status
- `src/features/patients/composables/usePatientSessions.js:189``updateStatus` com materialização
- `src/layout/melissa/MelissaLayout.vue:655``updateEventoStatus` do `MelissaEventoPanel`
- `src/layout/melissa/MelissaAgenda.vue:244``VIEW_MAP.lista = 'listAll'`
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <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'); @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} :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> <body>
<div class="topbar"> <div class="topbar">
<div class="brand">Agência<span>Psi</span> DB</div> <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)"> <input class="search" id="si" placeholder="Buscar tabela ou coluna..." oninput="search(this.value)">
<div class="pills"> <div class="pills">
<div class="pill"><strong>138</strong> tabelas</div> <div class="pill"><strong>138</strong> tabelas</div>
@@ -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;
$$;
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
-- Extensions -- Extensions
-- Gerado automaticamente em 2026-04-21T23:16:33.041Z -- Gerado automaticamente em 2026-05-11T16:53:49.849Z
-- Total: 10 -- Total: 10
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public; CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
+780 -19
View File
@@ -1,6 +1,6 @@
-- All Functions -- All Functions
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z -- Gerado automaticamente em 2026-05-11T16:53:50.921Z
-- Total: 192 -- Total: 211
CREATE FUNCTION auth.email() RETURNS text CREATE FUNCTION auth.email() RETURNS text
LANGUAGE sql STABLE LANGUAGE sql STABLE
@@ -287,6 +287,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
select 'ok'::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 CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -451,6 +513,95 @@ BEGIN
END; 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 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 LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public' SET search_path TO 'public'
@@ -1053,9 +1204,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
IF NEW.status IN ('cancelado', 'excluido') IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
AND OLD.status NOT IN ('cancelado', 'excluido')
THEN
PERFORM public.cancel_patient_pending_notifications( PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id NEW.patient_id, NULL, NEW.id
); );
@@ -1429,6 +1578,101 @@ BEGIN
END; 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 CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -3032,6 +3276,84 @@ BEGIN
END; 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 CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -3138,6 +3460,146 @@ BEGIN
END; 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 CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public' SET search_path TO 'public'
@@ -3456,6 +3918,48 @@ BEGIN
END; 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 CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
AS $$ AS $$
@@ -4689,6 +5193,120 @@ begin
end; 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 CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -5006,7 +5624,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
declare declare
v_id uuid; v_id uuid;
begin begin
-- Sess??o (locked + sempre ativa) -- Sessão (locked + sempre ativa)
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session' where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
@@ -5014,7 +5632,7 @@ begin
insert into public.determined_commitments insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description) (tenant_id, is_native, native_key, is_locked, active, name, description)
values 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; end if;
-- Leitura -- Leitura
@@ -5028,7 +5646,7 @@ begin
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura'); (p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if; end if;
-- Supervis??o -- Supervisão
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision' where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -5036,10 +5654,10 @@ begin
insert into public.determined_commitments insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description) (tenant_id, is_native, native_key, is_locked, active, name, description)
values 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; end if;
-- Aula ??? (corrigido) -- Aula
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class' 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'); (p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if; end if;
-- An??lise pessoal -- Análise pessoal
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis' where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -5058,13 +5676,26 @@ begin
insert into public.determined_commitments insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description) (tenant_id, is_native, native_key, is_locked, active, name, description)
values 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; 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 -- Leitura
select id into v_id select id into v_id
from public.determined_commitments 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 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) 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 if; end if;
-- Supervis??o -- Supervisão
select id into v_id select id into v_id
from public.determined_commitments from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision' 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 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) 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 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 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) 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 if; end if;
-- An??lise -- Análise
select id into v_id select id into v_id
from public.determined_commitments from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis' 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 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) 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 if; end if;
end; end;
@@ -5335,6 +5966,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
BEGIN NEW.updated_at = now(); RETURN NEW; END; 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 CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public' SET search_path TO 'public'
@@ -6698,6 +7378,87 @@ BEGIN
END; 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) CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE LANGUAGE sql STABLE
AS $$ AS $$
+1 -1
View File
@@ -1,5 +1,5 @@
-- Functions: auth -- Functions: auth
-- Gerado automaticamente em 2026-04-21T23:16:34.941Z -- Gerado automaticamente em 2026-05-11T16:53:50.917Z
-- Total: 4 -- Total: 4
CREATE FUNCTION auth.email() RETURNS text CREATE FUNCTION auth.email() RETURNS text
@@ -1,5 +1,5 @@
-- Functions: extensions -- Functions: extensions
-- Gerado automaticamente em 2026-04-21T23:16:34.942Z -- Gerado automaticamente em 2026-05-11T16:53:50.917Z
-- Total: 6 -- Total: 6
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
@@ -1,5 +1,5 @@
-- Functions: pgbouncer -- Functions: pgbouncer
-- Gerado automaticamente em 2026-04-21T23:16:34.943Z -- Gerado automaticamente em 2026-05-11T16:53:50.917Z
-- Total: 1 -- Total: 1
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text) CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
+780 -19
View File
@@ -1,6 +1,6 @@
-- Functions: public -- Functions: public
-- Gerado automaticamente em 2026-04-21T23:16:34.944Z -- Gerado automaticamente em 2026-05-11T16:53:50.918Z
-- Total: 153 -- Total: 172
CREATE FUNCTION public.__rls_ping() RETURNS text CREATE FUNCTION public.__rls_ping() RETURNS text
LANGUAGE sql STABLE LANGUAGE sql STABLE
@@ -8,6 +8,68 @@ CREATE FUNCTION public.__rls_ping() RETURNS text
select 'ok'::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 CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -172,6 +234,95 @@ BEGIN
END; 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 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 LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public' SET search_path TO 'public'
@@ -774,9 +925,7 @@ CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
BEGIN BEGIN
IF NEW.status IN ('cancelado', 'excluido') IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
AND OLD.status NOT IN ('cancelado', 'excluido')
THEN
PERFORM public.cancel_patient_pending_notifications( PERFORM public.cancel_patient_pending_notifications(
NEW.patient_id, NULL, NEW.id NEW.patient_id, NULL, NEW.id
); );
@@ -1150,6 +1299,101 @@ BEGIN
END; 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 CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -2753,6 +2997,84 @@ BEGIN
END; 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 CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -2859,6 +3181,146 @@ BEGIN
END; 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 CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public' SET search_path TO 'public'
@@ -3177,6 +3639,48 @@ BEGIN
END; 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 CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
LANGUAGE plpgsql LANGUAGE plpgsql
AS $$ AS $$
@@ -4410,6 +4914,120 @@ begin
end; 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 CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
AS $$ AS $$
@@ -4727,7 +5345,7 @@ CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS voi
declare declare
v_id uuid; v_id uuid;
begin begin
-- Sess??o (locked + sempre ativa) -- Sessão (locked + sempre ativa)
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'session' where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
@@ -4735,7 +5353,7 @@ begin
insert into public.determined_commitments insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description) (tenant_id, is_native, native_key, is_locked, active, name, description)
values 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; end if;
-- Leitura -- Leitura
@@ -4749,7 +5367,7 @@ begin
(p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura'); (p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
end if; end if;
-- Supervis??o -- Supervisão
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision' where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
@@ -4757,10 +5375,10 @@ begin
insert into public.determined_commitments insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description) (tenant_id, is_native, native_key, is_locked, active, name, description)
values 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; end if;
-- Aula ??? (corrigido) -- Aula
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'class' 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'); (p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
end if; end if;
-- An??lise pessoal -- Análise pessoal
if not exists ( if not exists (
select 1 from public.determined_commitments select 1 from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis' where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
@@ -4779,13 +5397,26 @@ begin
insert into public.determined_commitments insert into public.determined_commitments
(tenant_id, is_native, native_key, is_locked, active, name, description) (tenant_id, is_native, native_key, is_locked, active, name, description)
values 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; 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 -- Leitura
select id into v_id select id into v_id
from public.determined_commitments 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 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) 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 if; end if;
-- Supervis??o -- Supervisão
select id into v_id select id into v_id
from public.determined_commitments from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision' 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 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) 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 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 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) 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 if; end if;
-- An??lise -- Análise
select id into v_id select id into v_id
from public.determined_commitments from public.determined_commitments
where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis' 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 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) 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 if; end if;
end; end;
@@ -5056,6 +5687,55 @@ CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
BEGIN NEW.updated_at = now(); RETURN NEW; END; 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 CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER LANGUAGE plpgsql SECURITY DEFINER
SET search_path TO 'public' SET search_path TO 'public'
@@ -6419,6 +7099,87 @@ BEGIN
END; 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) CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
LANGUAGE sql STABLE LANGUAGE sql STABLE
AS $$ AS $$
@@ -1,5 +1,5 @@
-- Functions: realtime -- Functions: realtime
-- Gerado automaticamente em 2026-04-21T23:16:34.949Z -- Gerado automaticamente em 2026-05-11T16:53:50.919Z
-- Total: 12 -- Total: 12
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls 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 -- Functions: storage
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z -- Gerado automaticamente em 2026-05-11T16:53:50.920Z
-- Total: 15 -- Total: 15
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
@@ -1,5 +1,5 @@
-- Functions: supabase_functions -- Functions: supabase_functions
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z -- Gerado automaticamente em 2026-05-11T16:53:50.920Z
-- Total: 1 -- Total: 1
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
@@ -1,5 +1,5 @@
-- Tables: Addons / Créditos -- Tables: Addons / Créditos
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z -- Gerado automaticamente em 2026-05-11T16:53:50.927Z
-- Total: 7 -- Total: 7
CREATE TABLE public.addon_credits ( CREATE TABLE public.addon_credits (
@@ -1,5 +1,5 @@
-- Tables: Agenda / Agendamento -- Tables: Agenda / Agendamento
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z -- Gerado automaticamente em 2026-05-11T16:53:50.927Z
-- Total: 10 -- Total: 10
CREATE TABLE public.agenda_bloqueios ( CREATE TABLE public.agenda_bloqueios (
@@ -1,5 +1,5 @@
-- Tables: Central SaaS (docs/FAQ) -- 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 -- Total: 4
CREATE TABLE public.saas_doc_votos ( CREATE TABLE public.saas_doc_votos (
@@ -1,6 +1,6 @@
-- Tables: Comunicação / Notificações -- Tables: Comunicação / Notificações
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z -- Gerado automaticamente em 2026-05-11T16:53:50.929Z
-- Total: 14 -- Total: 15
CREATE TABLE public.notification_logs ( CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL, id uuid DEFAULT gen_random_uuid() NOT NULL,
@@ -260,7 +260,23 @@ CREATE TABLE public.notifications (
read_at timestamp with time zone, read_at timestamp with time zone,
archived boolean DEFAULT false NOT NULL, archived boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() 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 ( CREATE TABLE public.twilio_subaccount_usage (
@@ -1,6 +1,18 @@
-- Tables: CRM Conversas (WhatsApp) -- Tables: CRM Conversas (WhatsApp)
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z -- Gerado automaticamente em 2026-05-11T16:53:50.930Z
-- Total: 10 -- 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 ( CREATE TABLE public.conversation_autoreply_log (
id bigint NOT NULL, 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]))) 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 ( CREATE TABLE public.conversation_messages (
id bigint NOT NULL, id bigint NOT NULL,
tenant_id uuid 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]))) 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 ( CREATE TABLE public.conversation_tags (
id uuid DEFAULT gen_random_uuid() NOT NULL, id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid, tenant_id uuid,
@@ -134,7 +212,7 @@ CREATE TABLE public.session_reminder_logs (
to_phone text, to_phone text,
provider_message_id text, provider_message_id text,
conversation_message_id bigint, 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 ( 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_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))) 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 -- Tables: Documentos
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z -- Gerado automaticamente em 2026-05-11T16:53:50.928Z
-- Total: 6 -- Total: 6
CREATE TABLE public.document_access_logs ( CREATE TABLE public.document_access_logs (
@@ -1,5 +1,5 @@
-- Tables: Estrutura / Calendário -- Tables: Estrutura / Calendário
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z -- Gerado automaticamente em 2026-05-11T16:53:50.930Z
-- Total: 1 -- Total: 1
CREATE TABLE public.feriados ( CREATE TABLE public.feriados (
@@ -1,5 +1,5 @@
-- Tables: Financeiro -- Tables: Financeiro
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z -- Gerado automaticamente em 2026-05-11T16:53:50.926Z
-- Total: 10 -- Total: 10
CREATE TABLE public.financial_records ( CREATE TABLE public.financial_records (
+2 -258
View File
@@ -1,6 +1,6 @@
-- Tables: outros -- Tables: outros
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z -- Gerado automaticamente em 2026-05-11T16:53:50.926Z
-- Total: 17 -- Total: 1
CREATE TABLE public._db_migrations ( CREATE TABLE public._db_migrations (
id integer NOT NULL, id integer NOT NULL,
@@ -9,259 +9,3 @@ CREATE TABLE public._db_migrations (
category text DEFAULT 'migration'::text NOT NULL, category text DEFAULT 'migration'::text NOT NULL,
applied_at timestamp with time zone DEFAULT now() 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)))
);
+4 -2
View File
@@ -1,5 +1,5 @@
-- Tables: Pacientes -- Tables: Pacientes
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z -- Gerado automaticamente em 2026-05-11T16:53:50.929Z
-- Total: 16 -- Total: 16
CREATE TABLE public.patient_status_history ( CREATE TABLE public.patient_status_history (
@@ -247,7 +247,9 @@ CREATE TABLE public.patient_intake_requests (
nacionalidade text, nacionalidade text,
avatar_url text, avatar_url text,
tenant_id uuid, 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 ( CREATE TABLE public.patient_invites (
@@ -1,5 +1,5 @@
-- Tables: SaaS / Planos -- Tables: SaaS / Planos
-- Gerado automaticamente em 2026-04-21T23:16:34.953Z -- Gerado automaticamente em 2026-05-11T16:53:50.925Z
-- Total: 18 -- Total: 18
CREATE TABLE public.subscriptions ( 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 -- 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 -- Total: 8
CREATE TABLE public.commitment_services ( CREATE TABLE public.commitment_services (
@@ -1,5 +1,5 @@
-- Tables: Tenants / Multi-tenant -- Tables: Tenants / Multi-tenant
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z -- Gerado automaticamente em 2026-05-11T16:53:50.926Z
-- Total: 10 -- Total: 10
CREATE TABLE public.tenant_members ( CREATE TABLE public.tenant_members (
@@ -119,6 +119,8 @@ CREATE TABLE public.tenants (
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
kind text DEFAULT 'saas'::text 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, 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]))) 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])))
); );
+7 -4
View File
@@ -1,5 +1,5 @@
-- Views -- Views
-- Gerado automaticamente em 2026-04-21T23:16:34.958Z -- Gerado automaticamente em 2026-05-11T16:53:50.932Z
-- Total: 29 -- Total: 29
CREATE VIEW public.audit_log_unified WITH (security_invoker='true') AS 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_at,
l.last_message_body, l.last_message_body,
l.last_message_direction, l.last_message_direction,
l.kanban_status l.kanban_status,
FROM ((latest l 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)))) 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 CREATE VIEW public.current_tenant_id AS
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting; SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
+24 -2
View File
@@ -1,6 +1,6 @@
-- Indexes -- Indexes
-- Gerado automaticamente em 2026-04-21T23:16:34.961Z -- Gerado automaticamente em 2026-05-11T16:53:50.934Z
-- Total: 361 -- Total: 372
CREATE INDEX agenda_bloqueios_owner_data_idx ON public.agenda_bloqueios USING btree (owner_id, data_inicio, data_fim); 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_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_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); 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_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_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); 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_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_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); 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_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_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); 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_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_owner_idx ON public.insurance_plans USING btree (owner_id);
CREATE INDEX insurance_plans_tenant_idx ON public.insurance_plans USING btree (tenant_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 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_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); 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_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_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)); 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_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_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)); 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) -- Constraints (PK, FK, UNIQUE, CHECK)
-- Gerado automaticamente em 2026-04-21T23:16:34.963Z -- Gerado automaticamente em 2026-05-11T16:53:50.935Z
-- Total: 353 -- Total: 369
ALTER TABLE ONLY public._db_migrations ALTER TABLE ONLY public._db_migrations
ADD CONSTRAINT _db_migrations_filename_key UNIQUE (filename); ADD CONSTRAINT _db_migrations_filename_key UNIQUE (filename);
@@ -95,12 +95,21 @@ ALTER TABLE ONLY public.contact_phones
ALTER TABLE ONLY public.contact_types ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_pkey PRIMARY KEY (id); 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 ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_pkey PRIMARY KEY (id); ADD CONSTRAINT conversation_autoreply_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_autoreply_settings ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_pkey PRIMARY KEY (tenant_id); 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 ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_pkey PRIMARY KEY (id); 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 ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_pkey PRIMARY KEY (id); 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 ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_pkey PRIMARY KEY (id); ADD CONSTRAINT conversation_tags_pkey PRIMARY KEY (id);
@@ -500,6 +515,9 @@ ALTER TABLE ONLY public.notification_templates
ALTER TABLE ONLY public.user_settings ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id); 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 ALTER TABLE ONLY public.whatsapp_credit_packages
ADD CONSTRAINT whatsapp_credit_packages_pkey PRIMARY KEY (id); 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 ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; 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 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; 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 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; 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 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; 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 ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; 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 ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE; 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 ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; 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 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; ADD CONSTRAINT whatsapp_credit_purchases_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
+20 -2
View File
@@ -1,6 +1,6 @@
-- Triggers -- Triggers
-- Gerado automaticamente em 2026-04-21T23:16:34.965Z -- Gerado automaticamente em 2026-05-11T16:53:50.937Z
-- Total: 111 -- Total: 120
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); 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_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_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(); 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_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_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(); 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_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_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_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(); 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_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_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(); 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_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 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(); 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();
+65 -5
View File
@@ -1,7 +1,7 @@
-- RLS Policies -- RLS Policies
-- Gerado automaticamente em 2026-04-21T23:16:34.967Z -- Gerado automaticamente em 2026-05-11T16:53:50.939Z
-- Enable RLS: 131 tabelas -- Enable RLS: 137 tabelas
-- Policies: 344 -- Policies: 357
-- Enable RLS -- Enable RLS
ALTER TABLE public.addon_credits ENABLE ROW LEVEL SECURITY; 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_emails ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_phones 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.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_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_settings 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_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_notes 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_optout_keywords ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optouts 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_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_thread_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; 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.therapist_payouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.twilio_subaccount_usage 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.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_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY; ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_balance 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 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 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 FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text]))))))); 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 FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text)))))); 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 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); 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_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 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())); 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 "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 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()); 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 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)))))); 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: 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())); CREATE POLICY "wa_packages: select active" ON public.whatsapp_credit_packages FOR SELECT TO authenticated USING (((is_active = true) OR public.is_saas_admin()));
@@ -15,7 +15,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
--> -->
<script setup> <script setup>
import { computed, ref, watch, nextTick } from 'vue'; import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink'; import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Select from 'primevue/select'; import Select from 'primevue/select';
@@ -888,6 +888,47 @@ const anyChildDialogOpen = computed(() =>
serviceQuickDlgOpen.value || serviceQuickDlgOpen.value ||
insuranceQuickDlgOpen.value insuranceQuickDlgOpen.value
); );
// Sincronização do Resumo flutuante com a posição do Dialog
// O Dialog do PrimeVue centra vertical quando o conteudo cabe no viewport
// (commitment "Bloqueio" / "Atividade" sao baixos), e ancora ~5vh do topo
// quando o conteudo é alto (Sessao com paciente+frequencia). O Resumo
// flutuante estava com top:5vh fixo, então em dialog baixo ficava lá em
// cima desalinhado. ResizeObserver mede o .p-dialog e atualiza o style do
// aside pra acompanhar top + altura funciona em qualquer cenario.
const resumoStyle = ref({ top: '5vh', maxHeight: '90vh' });
let _dialogObserver = null;
function _syncResumoToDialog() {
const dialogEl = document.querySelector('.p-dialog.agenda-event-composer');
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
resumoStyle.value = {
top: `${Math.round(rect.top)}px`,
maxHeight: `${Math.round(rect.height)}px`
};
}
watch([visible, () => step.value], async ([open, stp]) => {
if (_dialogObserver) {
_dialogObserver.disconnect();
_dialogObserver = null;
}
if (!open || stp !== 2) return;
await nextTick();
const dialogEl = document.querySelector('.p-dialog.agenda-event-composer');
if (!dialogEl || typeof ResizeObserver === 'undefined') return;
_syncResumoToDialog();
_dialogObserver = new ResizeObserver(() => _syncResumoToDialog());
_dialogObserver.observe(dialogEl);
}, { immediate: true });
onBeforeUnmount(() => {
if (_dialogObserver) {
_dialogObserver.disconnect();
_dialogObserver = null;
}
});
</script> </script>
<template> <template>
@@ -1002,6 +1043,10 @@ const anyChildDialogOpen = computed(() =>
<i class="pi pi-ban mr-1" /> <i class="pi pi-ban mr-1" />
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada. <b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
</Message> </Message>
<Message v-if="isEdit && form.paciente_status === 'Arquivado' && isSessionFuture" severity="info" class="mb-3" :closable="false">
<i class="pi pi-info-circle mr-1" />
<b>Paciente arquivado.</b> Sessão futura pode ser editada, mas novos agendamentos e recorrências estão bloqueados.
</Message>
<Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false"> <Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" /> <i class="pi pi-ban mr-1" />
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b> <b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
@@ -1126,6 +1171,32 @@ const anyChildDialogOpen = computed(() =>
</div> </div>
</div> </div>
<!-- CAMPOS EXTRAS (herdados do compromisso determinado)
'notes' é caso especial: bindamos em form.observacoes pra
manter compat com a coluna nativa agenda_eventos.observacoes
(consumida por relatórios/prontuário). Outros campos vão pra
agenda_eventos.extra_fields (JSONB) via form.extra_fields. -->
<div v-if="selectedCommitmentFields.length" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-list" />
<span>Campos Extras (compromisso)</span>
</div>
<div class="field-card__body aed-extras-body">
<div class="fields-grid">
<div v-for="f in selectedCommitmentFields" :key="f.key" :class="{ 'col-span-full': f.field_type === 'textarea' }">
<FloatLabel variant="on">
<template v-if="f.field_type === 'textarea' && f.key === 'notes'">
<Textarea :id="`aed-extra-${f.key}`" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize :disabled="isArchivedPastEdit" />
</template>
<Textarea v-else-if="f.field_type === 'textarea'" :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" rows="2" autoResize />
<InputText v-else :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" />
<label :for="`aed-extra-${f.key}`">{{ f.label }}{{ f.required ? ' *' : '' }}</label>
</FloatLabel>
</div>
</div>
</div>
</div>
<!-- DEMAIS CAMPOS --> <!-- DEMAIS CAMPOS -->
<div class="fields-grid"> <div class="fields-grid">
<!-- Título (apenas para não-sessão) --> <!-- Título (apenas para não-sessão) -->
@@ -1150,24 +1221,9 @@ const anyChildDialogOpen = computed(() =>
</FloatLabel> </FloatLabel>
</div> </div>
<!-- Campos extras do compromisso --> <!-- Observação nativa removida em 2026-05-11. Agora vem como
<template v-if="selectedCommitmentFields.length"> campo extra do compromisso determinado (key='notes').
<div v-for="f in selectedCommitmentFields" :key="f.key"> Migration: 20260511000001_session_default_notes_field.sql. -->
<FloatLabel variant="on">
<Textarea v-if="f.field_type === 'textarea'" :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" rows="2" autoResize />
<InputText v-else :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" />
<label :for="`aed-extra-${f.key}`">{{ f.label }}{{ f.required ? ' *' : '' }}</label>
</FloatLabel>
</div>
</template>
<!-- Observação (somente quando não é sessão para sessões fica no card direito) -->
<div v-if="!isSessionEvent" class="col-span-full">
<FloatLabel variant="on">
<Textarea id="aed-observacoes" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize />
<label for="aed-observacoes">Observação</label>
</FloatLabel>
</div>
</div> </div>
<!-- RECORRÊNCIAS APLICADAS --> <!-- RECORRÊNCIAS APLICADAS -->
@@ -1230,7 +1286,7 @@ const anyChildDialogOpen = computed(() =>
<div v-if="isSessionEvent" class="field-card mb-4"> <div v-if="isSessionEvent" class="field-card mb-4">
<div class="field-card__header"> <div class="field-card__header">
<i class="pi pi-wallet" /> <i class="pi pi-wallet" />
<span>Pagamento</span> <span>Sessão / Honorários</span>
<div class="ml-auto"> <div class="ml-auto">
<SelectButton <SelectButton
v-model="billingType" v-model="billingType"
@@ -1418,13 +1474,9 @@ const anyChildDialogOpen = computed(() =>
</div> </div>
</div> </div>
<!-- Observação --> <!-- Observação nativa removida em 2026-05-11. Agora vem
<div class="mb-3"> como campo extra do compromisso determinado (key='notes').
<FloatLabel variant="on"> Migration: 20260511000001_session_default_notes_field.sql. -->
<Textarea id="aed-observacoes-side" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize :disabled="isArchivedPastEdit" />
<label for="aed-observacoes-side">Observação</label>
</FloatLabel>
</div>
<!-- COBRANÇA DA SESSÃO --> <!-- COBRANÇA DA SESSÃO -->
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="mb-3" /> <AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="mb-3" />
@@ -1935,7 +1987,7 @@ const anyChildDialogOpen = computed(() =>
<960px (la o card inline aparece). --> <960px (la o card inline aparece). -->
<Teleport to="body"> <Teleport to="body">
<Transition name="resumo-fade"> <Transition name="resumo-fade">
<aside v-if="visible && step === 2 && !anyChildDialogOpen" class="side-card agenda-resumo agenda-resumo--floating" style="z-index: 100000"> <aside v-if="visible && step === 2 && !anyChildDialogOpen" class="side-card agenda-resumo agenda-resumo--floating" :style="{ ...resumoStyle, zIndex: 100000 }">
<div class="side-card__title">Resumo</div> <div class="side-card__title">Resumo</div>
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
@@ -2082,6 +2134,13 @@ const anyChildDialogOpen = computed(() =>
padding: 0.65rem 0.75rem !important; padding: 0.65rem 0.75rem !important;
} }
/* Padding interno do card "Campos Extras (compromisso)" mesmo
tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas
bordas. */
.aed-extras-body {
padding: 0.85rem 0.85rem 0.65rem !important;
}
/* InputGroup do Particular/Convenio botoes "+" e "?" grudam /* InputGroup do Particular/Convenio botoes "+" e "?" grudam
no select sem o gap separado de antes. Os addons herdam altura no select sem o gap separado de antes. Os addons herdam altura
automaticamente do select via PrimeVue. */ automaticamente do select via PrimeVue. */
@@ -2299,7 +2358,7 @@ const anyChildDialogOpen = computed(() =>
.composer-grid { .composer-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0;
margin-top: 2px; margin-top: 2px;
} }
@@ -2512,7 +2571,7 @@ const anyChildDialogOpen = computed(() =>
/* layout single-col agora — sticky removido (nao faz sentido) */ /* layout single-col agora — sticky removido (nao faz sentido) */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0;
} }
/* ── Resumo: mobile inline / desktop flutuante via Teleport /* ── Resumo: mobile inline / desktop flutuante via Teleport
@@ -2533,13 +2592,12 @@ const anyChildDialogOpen = computed(() =>
.agenda-resumo--floating { .agenda-resumo--floating {
display: block; display: block;
position: fixed; position: fixed;
/* Ancora no topo (igual ao Dialog do PrimeVue, que com conteudo alto fica /* top + max-height sao injetados via :style reativo (resumoStyle no
~5vh do topo). Sem `top: 50%; translateY(-50%)` pra evitar desalinhar script), sincronizados com o .p-dialog via ResizeObserver. Sem isso,
quando o dialog fica alto e ancorado no topo do viewport. */ dialog baixo (Bloqueio/Atividade) centra vertical e o resumo ficava
top: 5vh; preso em 5vh, desalinhado. */
left: calc(50% + 314px); left: calc(50% + 314px);
width: 280px; width: 280px;
max-height: 90vh;
overflow-y: auto; overflow-y: auto;
z-index: 100000; /* PrimeVue incrementa z-index dinamicamente; valor alto pra garantir que fique acima da mask em qualquer ordem de overlay */ z-index: 100000; /* PrimeVue incrementa z-index dinamicamente; valor alto pra garantir que fique acima da mask em qualquer ordem de overlay */
background: color-mix(in srgb, var(--surface-card) 88%, transparent); background: color-mix(in srgb, var(--surface-card) 88%, transparent);
@@ -525,7 +525,7 @@ const heroDateText = computed(() => {
<!-- STEP 2 3 zonas --> <!-- STEP 2 3 zonas -->
<div v-else class="aev2-zones"> <div v-else class="aev2-zones">
<!-- Avisos topo (mantido do V1) --> <!-- Avisos topo (mantido do V1) -->
<div v-if="form.conflito || isDayBlocked || jornadaDialog || isDiaFolga || isArchivedPastEdit || (isEdit && form.paciente_status === 'Inativo' && isSessionFuture) || solicitacaoPendente" class="aev2-alerts"> <div v-if="form.conflito || isDayBlocked || jornadaDialog || isDiaFolga || isArchivedPastEdit || (isEdit && form.paciente_status === 'Inativo' && isSessionFuture) || (isEdit && form.paciente_status === 'Arquivado' && isSessionFuture) || solicitacaoPendente" class="aev2-alerts">
<Message v-if="form.conflito" severity="warn" :closable="false"> <Message v-if="form.conflito" severity="warn" :closable="false">
<span class="font-semibold">Conflito:</span> {{ form.conflito }} <span class="font-semibold">Conflito:</span> {{ form.conflito }}
</Message> </Message>
@@ -535,6 +535,9 @@ const heroDateText = computed(() => {
<Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" :closable="false"> <Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" :closable="false">
<i class="pi pi-ban mr-1" /> <b>Paciente inativo.</b> Remarcação bloqueada. <i class="pi pi-ban mr-1" /> <b>Paciente inativo.</b> Remarcação bloqueada.
</Message> </Message>
<Message v-if="isEdit && form.paciente_status === 'Arquivado' && isSessionFuture" severity="info" :closable="false">
<i class="pi pi-info-circle mr-1" /> <b>Paciente arquivado.</b> Sessão futura editável; novos agendamentos e recorrências bloqueados.
</Message>
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" :closable="false"> <Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" :closable="false">
<div class="flex items-center justify-between gap-3 w-full flex-wrap"> <div class="flex items-center justify-between gap-3 w-full flex-wrap">
<span><i class="pi pi-inbox mr-1" /> Solicitação pendente de <b>{{ solicitacaoPendente.paciente_nome }} {{ solicitacaoPendente.paciente_sobrenome }}</b></span> <span><i class="pi pi-inbox mr-1" /> Solicitação pendente de <b>{{ solicitacaoPendente.paciente_nome }} {{ solicitacaoPendente.paciente_sobrenome }}</b></span>
@@ -365,6 +365,12 @@ export function useAgendaEventComposer(props, emit, extras = {}) {
if (!isEdit.value && !perms.canCreateSession) return false; if (!isEdit.value && !perms.canCreateSession) return false;
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false; if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false;
if (isArchivedPastEdit.value) return false; if (isArchivedPastEdit.value) return false;
// Edição de sessão FUTURA: bloqueia se status não permite remarcação.
// Pra Inativo => canReschedule=false => bloqueia (antes só tinha aviso
// visual "Remarcação bloqueada" que mentia: o save acontecia mesmo
// assim, pq canReschedule era dead code). Pra Arquivado canReschedule
// continua true (futura editável, vide aviso info no template).
if (isEdit.value && isSessionFuture.value && !perms.canReschedule) return false;
} }
return true; return true;
}); });