CRM WhatsApp Grupo 3 completo + Marco A/B (Asaas) + admin SaaS + refactors polimórficos

Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Stores: conversationDrawerStore

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

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

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 07:05:24 -03:00
parent 037ba3721f
commit 2644e60bb6
191 changed files with 38629 additions and 3756 deletions
+12 -1
View File
@@ -5,7 +5,9 @@ coverage
.nitro
.cache
.output
# .env
.env
.env.local
.env.*.local
dist/
dist-*/
.DS_Store
@@ -16,6 +18,10 @@ api-generator/typedoc.json
Dev-documentacao/
supabase/*
!supabase/functions/
# Mas os .env dentro de functions NÃO vão pro git (sobrescreve a negação acima)
supabase/functions/.env
supabase/functions/.env.local
supabase/functions/.env.*
evolution-api/
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
@@ -27,3 +33,8 @@ playwright-report/
# Config local do Claude Code (cada dev tem o seu)
.claude/settings.local.json
# Notas locais do dev e rascunhos de commit — não subir
informacoes Gerais.txt
pasteds.txt
commit.txt
Binary file not shown.
Binary file not shown.
+139 -69
View File
@@ -1,4 +1,4 @@
# HANDOFF — 2026-04-19 (após Sessões 1-10)
# HANDOFF — 2026-04-21 (CRM WhatsApp Grupo 3 + Marco B/credits + Asaas + polimento)
Documento de continuidade. **Quando voltar, comece lendo esta página.**
Todo o estado vive no banco (`/saas/desenvolvimento` → Auditoria/Verificações/Testes).
@@ -9,107 +9,165 @@ Todo o estado vive no banco (`/saas/desenvolvimento` → Auditoria/Verificaçõe
| | |
|---|---|
| **A# auditoria** abertos | **1** (A#31 — reformular pra "Preparação pra deploy") |
| **V# verificações** abertos | 14 (todos médios/baixos adiados, plano completo no DB) |
| **🔴 Críticos** | **0** ✅ |
| **🟠 Altos** | **0** ✅ |
| **Áreas auditadas** | **15** (todas as principais do SaaS) |
| 🟡 Médios adiados | 8 |
| 🟢 Baixos adiados | 7 |
| Vitest | 208/208 |
| SQL integration | 33/33 |
| E2E (Playwright) | 5/5 |
| Migrations totais | 18 |
| Último commit | `d6eb992` (pushed ao Gitea) |
| Migrations totais | **36** (23 → 36) |
| Edge functions | **20** |
| Fases Opção C concluídas | **5 + 5b + Grupo 3 inteiro + Marco A + Marco B (Asaas) + admin SaaS** |
---
## 🎯 Próxima sessão — A#31-rev (Preparação pra deploy)
## 🎯 O que rolou hoje (2026-04-21)
**Contexto:** A#31 era "Deploy real" mas você não tem cloud Supabase nem
secrets reais ainda (MVP). Precisa virar **preparação** — deixar tudo
pronto pra quando criar a cloud você executar sozinho com mínimo atrito.
### ✅ Grupo 3 completo — Workflow / CRM
**Tarefas (~2-3h, zero risco):**
- **3.1 Tags** — migration `conversation_tags` + 5 system tags seed · composable `useConversationTags.js` · popover + pills no drawer · pills nos cards do Kanban
- **3.2 Atribuição de conversa a terapeuta** (HOJE de tarde) — migration `conversation_assignments` (PK `(tenant_id, thread_key)`, UPSERT, RLS membro-ativo + valida assignee como membro do mesmo tenant) + view `conversation_threads` expandida com `assigned_to` · composable `useConversationAssignment.js` · drawer com Select filtrável + "Assumir" · inbox com filtro aside "Todas/Minhas/Não atribuídas" + chip no card
- **3.3 Notas internas** — `conversation_notes` + composable + seção colapsável no drawer
- **3.5 Converter número desconhecido em paciente** — botão + dialog quick-cadastro e "Vincular existente" com Select filter + 500 pacientes
- **3.6 Histórico de conversa no prontuário** (HOJE) — nova aba "Conversas" no `PatientProntuario.vue` com `PatientConversationsTab.vue` (stats + filter + timeline + mídia + "Abrir no CRM")
1. **`DEPLOY.md`** na raiz — checklist de 8 passos com comandos exatos +
diagnóstico de erros comuns + ordem de execução
2. **Validar migrations num container limpo** — recriar banco do zero,
aplicar as 18 migrations + seeds em ordem, garantir zero erro
3. **`.env.example`** completo — todas VITE_ vars + cada secret de edge
function listado com instrução
4. **Auditoria das edge functions** — CORS, fallback de env var ausente,
error handling. Documentar quais env cada uma precisa
5. **Script `db.cjs deploy-check`** — comando que valida pré-condições
antes de deploy (ordena migrations, verifica diffs, lista secrets)
6. **Atualizar HANDOFF.md** com seção "Pra deployar"
### ✅ Marco A — Unificação WhatsApp (dois providers)
**Quando voltar, é só dizer "começa A#31-rev" e eu sigo o plano.**
- **Evolution (pessoal, free)** + **Twilio (AgenciaPSI Oficial, créditos)** — mutuamente exclusivos por tenant
- Página chooser `ConfiguracoesWhatsappChooserPage.vue` com 2 cards + deactivate via edge `deactivate-notification-channel`
- `send-whatsapp-message` refatorada — roteamento por provider; Twilio deduz crédito ANTES e refunda em falha
- **Paridade Twilio** (HOJE) — módulo compartilhado `supabase/functions/_shared/whatsapp-hooks.ts` (provider-agnóstico) consumido por Evolution **e** Twilio inbound. Hooks: opt-in/opt-out/auto-reply + schedule helpers + `makeTwilioCreditedSendFn` (dedução + rollback). Evolution refatorado (~290 linhas duplicadas removidas). Twilio agora roda mesmo pipeline de hooks (antes só inseria a mensagem e saía)
### ✅ Marco B — Sistema de créditos WhatsApp + Asaas
- Migration `whatsapp_credits` (4 tabelas: balance, transactions, packages, purchases) + 2 RPCs atômicas (`add_whatsapp_credits`, `deduct_whatsapp_credits`)
- Edge `create-whatsapp-credit-charge` — integração Asaas v3 (PIX sandbox + prod); `getOrCreateAsaasCustomer` patcha customer existente com CPF quando falta
- Edge `asaas-webhook` — recebe `PAYMENT_RECEIVED/CONFIRMED` e credita balance
- Página tenant `/configuracoes/creditos-whatsapp` — saldo + loja + histórico + dialog PIX com QR code
- **CPF/CNPJ no dialog de compra** (HOJE) — migration `20260421000013_tenant_cpf_cnpj.sql` (coluna + CHECK 11/14 dígitos), dialog de confirmação com validação (`isValidCPF`/`isValidCNPJ` de `utils/validators`), formatação on-blur, pré-fill de `tenants.cpf_cnpj`, persiste automaticamente no primeiro uso. Fallback sandbox removido
### ✅ Admin SaaS — gestão de créditos (HOJE)
Integrado em `/saas/addons` (reuso do pattern existente, não criou página paralela):
- **Aba 4 "Pacotes WhatsApp"** — CRUD `whatsapp_credit_packages` com DataTable (destaque, posição, preço + BRL/msg), toggle `is_active` inline, dialog de edição com validação
- **Aba 5 "Topup WhatsApp"** — tenant Select filtrável com saldo ao vivo; RPC `add_whatsapp_credits` com `p_admin_id = auth.uid()` (auditoria); histórico das últimas 20 transações topup/adjustment/refund
### ✅ Grupo 2 Automação
- **2.3 Auto-reply** — `conversation_autoreply_settings` + `conversation_autoreply_log`; schedule por modo (`agenda` / `business_hours` / `custom`); cooldown por thread; respeita opt-out; agora funciona em **ambos** providers
- **2.4 Lembretes de sessão** — `conversation_session_reminders_*`; edge `send-session-reminders` (cron); já trata Twilio com dedução + rollback
### ✅ Grupo 5 Compliance (LGPD Art. 18 §2)
- **5.2 Opt-out** — `conversation_optouts` + `conversation_optout_keywords` (10 keywords system + custom); detecção por regex word-boundary; ack automático; opt-in via "voltar/retornar/reativar/restart"
- Página `/configuracoes/conversas-optouts`
### ✅ Refactor polimórfico — telefones + emails
- `contact_types` + `contact_phones` (polimórfico: `entity_type` + `entity_id`) — migration 20260421000008
- `contact_email_types` + `contact_emails` — migration 20260421000011
- Componentes `ContactPhonesEditor.vue` + `ContactEmailsEditor.vue`
- Trocado em `PatientsCadastroPage.vue`, `MedicosPage.vue`
- Migration retroativa v2: detecta conversas e cria/atualiza phone como WhatsApp (vinculado)
### ✅ Polimento visual — sessão manhã
- Skeletons simplificados no dashboard terapeuta · animações fade-up com stagger por `[--delay:Xms]` (fix specificity sobre `.dash-card`)
- ConfirmDialog com `group="conversation-drawer"` (evita duplicata)
- Image preview PrimeVue com botão **download** injetado via MutationObserver (fetch + blob pra cross-origin)
- Audio/video com `preload="metadata"`, controles de velocidade herdados do browser
- `friendlySendError()` no drawer store — mapeia códigos pt-BR via `error.context.json()`
- Teleport `#cfg-page-actions` pra ações globais da Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
### ✅ Infra geral
- Bucket privado `whatsapp-media` + decrypt via Evolution `getBase64FromMediaMessage` + upload + signed URLs on-demand
- Realtime em `conversation_messages` via publication `supabase_realtime`
- `AppLayout` consolidado (removido duplicatas por área) + `RouterPassthrough`
- `console.trace` debug removido (watch router/Supabase) que degradava perf
---
## 📚 Memória persistente (carregada automaticamente)
## 🎯 Próxima sessão (começar por aqui)
Já saved no memory system (`MEMORY.md` — não precisa lembrar):
- **Sanitização sempre** — trim, length, regex em toda entrada/saída
- **Priorização por severidade** — críticos+altos imediatos, médios/baixos adiam com plano
- **Self-hosted > provider externo** — LGPD/clínico
- **Gotcha supabase_admin** — `psql -U supabase_admin -h localhost` direto pra ALTER POLICY em tabelas owned
- **Tracking dev_*_items** — A#/V#/T# vivem no DB, UI `/saas/desenvolvimento`
- **Project Overview** + **MVP Assessment**
Do CRM WhatsApp ainda restam:
### Grupo 3 (resto)
- **3.4 SLA / alerta** — conversa sem resposta > X min. Trigger `conversation_sla_rules` + worker cron
- **3.7 Bot auto-triagem** — pergunta nome/horário antes de sair pro humano
### Grupo 6 — Conexão resiliente
- **6.1 Heartbeat** — cron verifica Evolution; dispara incident se desconectado > N min
- **6.2 Alerta** quando celular desconecta (notification + e-mail admin tenant)
- **6.3 Reconnect automático** (tentar re-init da instância)
### Grupo 7 — Analytics
- **7.1 Tempo médio de primeira resposta** (card no ClinicDashboard + filtro por terapeuta)
### Grupo 8 — Integrações
- **8.2 Botão na agenda** "Lembrar paciente da sessão" — dispara `send-whatsapp-message` com template `lembrete_sessao`
- **8.3 Status sessão dispara mensagem** (ex: cancelada → aviso auto)
- **8.4 Link agendador cria lead** — quando paciente preenche intake mas não finaliza, aparece no CRM como thread
### Outros blocos
- **Notificação de saldo baixo WhatsApp** — trigger em `whatsapp_credits_balance` quando `balance < low_balance_threshold`; e-mail + toast
- **Dashboard saas de receita créditos** — total arrecadado Asaas por mês, pacotes mais vendidos
- **Retention policy 5.1** — apagar/anonimizar conversas > X dias (configurável por tenant)
- **5.4** — seção de conversas no LGPD export do paciente
---
## 📦 Commits relevantes
## 🔧 Setup Evolution/WhatsApp / Asaas
```
d6eb992 Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS ← último
7c20b51 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
d088a89 (commit anterior do projeto)
```
Tudo em **`WHATSAPP_SETUP.md`**. Resumo crítico:
1. `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env` em terminal separado
2. `.env` do functions tem: `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `ASAAS_API_KEY`, `ASAAS_API_URL=https://api-sandbox.asaas.com/v3`
3. Evolution: `/saas/whatsapp` cadastra creds global → `/configuracoes/whatsapp-pessoal` conecta QR
4. Twilio: `/saas/twilio-whatsapp` provisiona subconta → tenant ativa em `/configuracoes/whatsapp-oficial` (usa créditos)
⚠️ Após editar qualquer `supabase/functions/**` precisa reiniciar o `supabase functions serve` — ele não tem hot reload.
---
## 🗂️ Áreas auditadas (15)
## 🌲 Deploy options (guardadas pra depois)
| Área | Estado |
|---|---|
| auth, router, stores, agenda, seguranca, saas | 100% fechado/ok |
| **pacientes** ✨ | **100% fechado** (V#9 — script extraído; template breakdown adiado pra quando houver E2E) |
| **documentos** ✨ | 100% fechado |
| **calendario** ✨ | 100% fechado |
| **servicos** ✨ | 100% fechado |
| financeiro | 5 fechados, 6 médios/baixos adiados |
| comunicacao | 5 fechados, 5 médios/baixos adiados |
| tenants | 6 fechados, 2 baixos adiados |
| addons | 3 resolvidos, 1 médio adiado |
| central_saas | 1 alto fechado, 2 médios adiados |
✨ = áreas 100% fechadas (zero pendência).
- **(a)** Smoke test de infra — subir pra Supabase cloud + hospedagem só pra testar sozinho. ~2-3h.
- **(b)** Beta fechado com clínicas — precisa: 3.4 SLA, 6.1 heartbeat, 7.1 analytics, retention policy, tour/onboarding refinamento.
- **(c)** [em andamento] Fechar gaps funcionais.
---
## ⚠️ Pendências documentadas no DB (14 V# adiados)
## 📦 Commits
Todos médios/baixos com plano completo em `dev_verificacoes_items.acao_sugerida`.
**Não esquecer.** Sprint dedicado de polimento depois do deploy.
Tem um **`commit.txt`** pronto na raiz com mensagem consolidada pra um commit único de tudo que está pendente. `git status` mostra ~160 arquivos modificados/criados. Conferir `commit.txt` antes de usar.
- **financeiro** (6): parcelamento CHECK, payouts flow, recurrence DELETE,
composables, máscara PIX, dashboard inadimplência
- **comunicacao** (5): notifications/schedules silos, email_templates_global
filtros, retention notification_logs, dashboard health, audit dismissals
- **tenants** (2): owner_users policies, company_profiles + dev_user_credentials
- **central_saas** (2): rate limit voto, valores tipo_acesso
- **addons** (1): UI de extrato
Plus (não V#):
- **PatientsCadastroPage template breakdown** — 1951 linhas. Esperar E2E
- **Sprint de polimento** dos 14 médios/baixos juntos
Se preferir quebrar em commits menores, os grupos lógicos são:
1. Migrations CRM + créditos + polimorfismo (pasta `database-novo/migrations/20260420*` + `20260421*`)
2. Edge functions (pasta `supabase/functions/`)
3. Frontend CRM (`src/components/conversations/`, `src/features/conversations/`, `src/features/patients/prontuario/PatientConversationsTab.vue`)
4. Composables novos (`src/composables/useConversation*.js`, `useWhatsappCredits.js`, `useContact*.js`, `useAutoReplySettings.js`, `useSessionReminders.js`)
5. Páginas config novas (`src/layout/configuracoes/*`)
6. Admin SaaS (`SaasAddonsPage.vue` com 2 tabs novas)
7. Refactors (PatientsCadastro/Medicos trocando pra ContactEditors; AppLayout; router)
---
## 🛠 Stack lembretes (caso precise)
## Pendências conhecidas
- **15 V# adiados** (8 médios + 7 baixos) — sprint de polimento depois do beta
- **Tour guiado / onboarding wizard** — refino deixado pro fim
- **Dashboard SaaS de receita Asaas** — falta página
- **Rotação de credenciais Twilio** (segurança) — se subconta vazar, precisa de flow pra regenerar
---
## 🛠️ Stack lembretes
- **DB local:** `docker exec -i supabase_db_agenciapsi-primesakai psql -U postgres -d postgres`
- **DB local como supabase_admin (pra ALTER POLICY em tabelas owned):**
- **DB como supabase_admin (ALTER POLICY em tabelas owned):**
```bash
docker exec -i -e PGPASSWORD=postgres -e PGCLIENTENCODING=UTF8 \
supabase_db_agenciapsi-primesakai \
@@ -117,7 +175,19 @@ Plus (não V#):
```
- **Vitest:** `npx vitest run`
- **SQL integration:** `node database-novo/tests/run.cjs`
- **E2E:** `npx playwright test` (precisa dev server: `npm run dev`)
- **Edge functions serve:** `supabase functions serve --no-verify-jwt --env-file supabase/functions/.env`
- **Evolution Manager:** `http://localhost:8080/manager/`
- **Supabase Studio:** `http://localhost:54323`
- **Asaas sandbox:** `https://sandbox.asaas.com` (login separado do prod)
---
## 📚 Memória persistente (carregada automaticamente)
Já saved em `MEMORY.md`:
- Project overview · MVP Assessment · Deploy options
- Sanitização sempre · Priorização por severidade · Self-hosted > provider externo
- Gotcha supabase_admin · Tracking dev_*_items
---
+518
View File
@@ -0,0 +1,518 @@
# WhatsApp Setup — CRM de Conversas + Créditos + Automações
Guia end-to-end do subsistema de WhatsApp do AgenciaPSI. Cobre **WhatsApp Pessoal** (Evolution, gratuito), **WhatsApp Oficial AgenciaPSI** (Twilio com créditos), **Asaas** (gateway de pagamento), e todas as automações (auto-reply, lembretes, opt-out, tags, notas).
---
## 🎯 Arquitetura
### Dois provedores, escolha exclusiva por tenant
```
┌─────────────────────────────────────────────────┐
│ Tenant escolhe 1 canal em /configuracoes/whatsapp │
└─────────────────────────────────────────────────┘
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ WhatsApp Pessoal │ │ WhatsApp Oficial │
│ (Evolution) │ │ AgenciaPSI (Twilio) │
│ │ │ │
│ • Gratuito │ │ • Consome créditos │
│ • QR code │ │ • API oficial Meta │
│ • Celular real │ │ • Zero ban risk │
│ • Docker self-host │ │ • Cloud gerenciado │
│ • Tier free do SaaS │ │ • Tier pago do SaaS │
└────────────────────┘ └──────────────────────┘
│ │
└───────────┬────────────────┘
┌─────────────────────────┐
│ Edge Functions │
│ │
│ • send-whatsapp-message │ ← rota por provider
│ • send-session-reminders │ ← idem
│ • evolution-whatsapp-inbound (auto-reply, opt-out)
│ • twilio-whatsapp-inbound (⚠ sem auto-reply ainda)
│ • create-whatsapp-credit-charge (Asaas PIX)
│ • asaas-webhook (credita saldo)
└─────────────────────────┘
┌─────────────────────────┐
│ PostgreSQL │
│ │
│ conversation_messages │
│ conversation_notes │
│ conversation_tags │
│ conversation_optouts │
│ conversation_autoreply_* │
│ session_reminder_* │
│ whatsapp_credits_* │
│ whatsapp_credit_packages │
└─────────────────────────┘
```
### Dedução de créditos
```
Usuário envia pelo drawer / lembrete dispara / auto-reply
Edge function detecta provider do canal ativo
Evolution? Twilio?
↓ ↓
Envia direto deduct_whatsapp_credits(1) ← atômico, lock, valida saldo
↓ ↓
Registra msg ┌──── OK ────┐ ┌── insufficient ──┐
↓ ↓
send Twilio return 402
┌── ok ──┐ ┌── fail ──┐
↓ ↓
Registra add_whatsapp_credits(1, 'refund')
msg
```
---
## 🔧 Setup completo (dev local)
### 1. Supabase local + edge functions
```bash
# Subir stack Supabase local (Postgres + Auth + Storage + etc)
npx supabase start
# Em outro terminal: rodar edge functions
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```
**Funções carregadas:**
| Função | URL | Uso |
|---|---|---|
| `evolution-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Evolution: inbound msgs + auto-reply + opt-out |
| `evolution-webhook-provision` | — | Configura webhook na Evolution |
| `twilio-whatsapp-inbound` | `?tenant_id=<uuid>` | Webhook Twilio (inbound only; sem auto-reply ainda) |
| `send-whatsapp-message` | — | Envio unificado: detecta provider, deduz crédito se Twilio |
| `send-session-reminders` | — | Cron/manual: dispara lembretes 24h e 2h antes |
| `create-whatsapp-credit-charge` | — | Cria PIX Asaas pra compra de créditos |
| `asaas-webhook` | — | Recebe eventos Asaas e credita saldo |
| `deactivate-notification-channel` | — | Desativa canal (usado ao trocar provider) |
**Flag `--no-verify-jwt`:** necessária porque webhooks externos (Twilio, Evolution, Asaas) não mandam JWT.
### 2. Evolution API (WhatsApp Pessoal — tier gratuito)
```bash
# Subir Evolution + Postgres + Redis
docker compose -f evolution-api/docker-compose.yml up -d
# Verificar status
docker ps --filter name=evolution_api
```
Evolution roda em `http://localhost:8080` com API key `minha_chave_123` (ver `evolution-api/docker-compose.yml`).
### 3. Asaas (pagamentos — tier pago)
Ativa só em prod ou quando quiser testar compra de créditos end-to-end.
**Passo 1 — Criar conta sandbox:**
1. https://sandbox.asaas.com (gratuito, CPF qualquer)
2. Menu → Integrações → Integrações Avançadas → API → copia a API key (começa com `$aact_...`)
**Passo 2 — Configurar env:**
Edita `supabase/functions/.env`:
```env
ASAAS_API_KEY=$aact_sua_chave_aqui
ASAAS_API_URL=https://sandbox.asaas.com/api/v3
ASAAS_WEBHOOK_TOKEN= # opcional, pra autenticar webhook
```
**Passo 3 — Reiniciar functions serve:**
```bash
# Ctrl+C no terminal do serve
supabase functions serve --no-verify-jwt --env-file supabase/functions/.env
```
**Passo 4 — (Opcional) Expor webhook via ngrok pro Asaas alcançar:**
```bash
# Outro terminal
ngrok http 54321
# Copia a URL (ex: https://abc123.ngrok.app)
```
Configura no Asaas:
- Dashboard → Integrações → Webhooks → **Adicionar**
- URL: `https://abc123.ngrok.app/functions/v1/asaas-webhook`
- Eventos: marca **Cobranças** (PAYMENT_RECEIVED, PAYMENT_CONFIRMED, PAYMENT_OVERDUE, PAYMENT_DELETED, PAYMENT_REFUNDED)
- Token (opcional): cadastra o mesmo valor de `ASAAS_WEBHOOK_TOKEN`
**Em produção:**
```bash
supabase secrets set ASAAS_API_KEY="$aact_prod_key"
supabase secrets set ASAAS_API_URL="https://api.asaas.com/v3"
supabase secrets set ASAAS_WEBHOOK_TOKEN="token_seguro"
```
Webhook da prod aponta pro URL real do Supabase cloud (sem ngrok).
---
## 📋 Features & como testar
### A. Envio manual via drawer
**Onde:** drawer de qualquer conversa (clica no card do Kanban em `/therapist/conversas`)
**Fluxo:**
1. Compose no drawer → `store.sendMessage()` → chama `send-whatsapp-message` edge function
2. Function detecta provider ativo em `notification_channels`
3. Evolution: envia direto via `/message/sendText/{instance}`
4. Twilio: `deduct_whatsapp_credits(1)` → se OK envia via Twilio API → se falhar, refunda
5. Registra em `conversation_messages` (direction=outbound, delivery_status=sent/queued)
**Testar sem Twilio real** (valida dedução + rollback):
- Topup 100 créditos via SQL (ver seção 🧪 mais abaixo)
- Criar canal Twilio fake via SQL
- Enviar msg → deduz, tenta enviar, falha com 401, refunda → saldo volta ao original
### B. Lembretes automáticos de sessão (2.4)
**Onde:** `/configuracoes/lembretes-sessao`
**Config:**
- Toggle on/off
- Ativa lembretes 24h e/ou 2h antes da sessão
- Templates com variáveis: `{{nome_paciente}}`, `{{data_sessao}}`, `{{hora_sessao}}`, `{{modalidade}}`, `{{nome_clinica}}`
- Quiet hours (default 22h-8h SP)
- Respeitar opt-out (LGPD — recomendado ON)
**Como dispara:**
- Cron hit `send-session-reminders` a cada 15min (pg_cron comentado na migration; em prod configure via Supabase Dashboard → Database → Cron Jobs)
- Em dev: botão **"Testar agora"** na página dispara manualmente
**Query do worker:** busca `agenda_eventos` com `status='agendado'` dentro de:
- Janela 24h: `inicio_em` entre `now+23h45min` e `now+24h15min`
- Janela 2h: `inicio_em` entre `now+1h45min` e `now+2h15min`
**Anti-dup:** UNIQUE `(event_id, reminder_type)` no `session_reminder_logs`.
**Testar:**
```sql
-- Cria evento daqui a ~2h (no horário de SP)
-- Pelo UI da agenda é mais fácil; via SQL:
INSERT INTO agenda_eventos (tenant_id, owner_id, patient_id, inicio_em, fim_em, status, modalidade, tipo, titulo)
VALUES (
'<tenant>',
'<user>',
(SELECT id FROM patients WHERE tenant_id='<tenant>' LIMIT 1),
now() + interval '2 hours',
now() + interval '3 hours',
'agendado',
'presencial',
'session',
'Teste lembrete'
);
```
Depois clica **"Testar agora"** em `/configuracoes/lembretes-sessao`.
### C. Auto-reply fora do horário (2.3)
**Onde:** `/configuracoes/conversas-autoreply`
**Config:**
- Toggle on/off + mensagem + cooldown (minutos entre auto-replies pra mesma thread)
- 3 modos:
- **Seguir agenda** — usa `agenda_regras_semanais` dos membros ativos do tenant
- **Horário de funcionamento** — janela semanal editável (armazena em JSONB `business_hours`)
- **Custom** — janela específica pro auto-reply (`custom_window`)
**Como dispara:**
- Webhook Evolution `evolution-whatsapp-inbound` recebe msg
- Depois de inserir msg, chama `maybeSendAutoReply()`
- Checa: enabled, não está em horário útil, não está em cooldown
- Se OK → envia via Evolution (futuro: rotear pra Twilio se provider='twilio')
**⚠ Limitação:** atualmente **só funciona com Evolution**. Pra Twilio precisa implementar a mesma lógica em `twilio-whatsapp-inbound` (dívida técnica).
**Testar:**
- Ativa feature + define janela custom (ex: seg-sex 9h-18h)
- Fora dessa janela, paciente manda msg → chega no inbox + auto-reply é enviado de volta em ~1s
### D. Opt-out LGPD (5.2)
**Onde:** `/configuracoes/conversas-optouts`
**Como funciona:**
- Paciente envia "PARAR", "SAIR", "CANCELAR", "STOP", etc (keyword match case-insensitive sem acentos)
- Edge function `evolution-whatsapp-inbound` detecta → registra em `conversation_optouts` → envia msg de confirmação
- Paciente envia "VOLTAR" / "RETORNAR" → reativa (opted_back_in_at preenchido)
- Auto-reply e lembretes **respeitam opt-out** automaticamente (skip + log `opted_out`)
- Envio manual do terapeuta NÃO é bloqueado (relação terapêutica existe)
**Keywords padrão:** 10 palavras (configuráveis na página — pode adicionar custom do tenant).
**Testar:**
- Manda mensagem com "parar" pelo WhatsApp conectado
- Volta em `/configuracoes/conversas-optouts` → número aparece na lista
- Nova mensagem que dispararia auto-reply → não dispara mais
### E. Notas internas (3.3)
**Onde:** dentro do drawer de conversa, seção "Notas internas" collapsible
**Como funciona:**
- CRUD simples por thread
- Visível apenas pra membros ativos do tenant
- Edição/remoção só pelo criador (ou SaaS admin)
- Soft delete (`deleted_at`)
- **NÃO vai pro paciente** — apenas anotação interna da equipe
### F. Tags na conversa (3.1)
**Onde:**
- **Gestão:** `/configuracoes/conversas-tags` (CRUD de tags custom; system tags são read-only)
- **Aplicação:** dentro do drawer de conversa + pills visíveis nos cards do Kanban
**Tags system (seedadas):** Urgente (🔴), Primeira consulta (🔵), Remarcação (🟡), Confirmada (🟢), Follow-up (🟣)
**Custom:** tenant cria suas próprias com nome + slug + cor + ícone (primeicons).
### G. Mídia (áudio / imagem / vídeo / documento)
**Arquitetura:**
- Evolution manda URLs encriptadas do Meta CDN (não tocam direto)
- Edge function `evolution-whatsapp-inbound` chama `/chat/getBase64FromMediaMessage/{instance}` do Evolution → decripta
- Decoda base64 → faz upload no bucket **privado `whatsapp-media`**
- Path: `<tenant_id>/<yyyy>/<mm>/<msg_id>_<timestamp>.<ext>`
- Salva apenas o PATH em `media_url`, NÃO URL pública
- Frontend (`ConversationDrawer`) gera **signed URL on-demand** (1h TTL) ao renderizar
**LGPD:** bucket privado, RLS só permite membros ativos do tenant; path tenant-scoped; signed URLs expiram.
**Player de áudio:** `<audio min-w-[260px] controls>` + `preload="metadata"` pra mostrar duração sem baixar tudo.
**Preview de imagem:** `<Image preview>` do PrimeVue (fullscreen com zoom/rotate) + **botão download injetado via MutationObserver** (fetch blob → download attr → force download com nome do arquivo).
### H. Créditos WhatsApp (Marco B)
**Onde:** `/configuracoes/creditos-whatsapp`
**Fluxo de compra:**
1. User clica "Comprar" num pacote → `create-whatsapp-credit-charge` cria:
- Customer Asaas (ou reutiliza via `externalReference=tenant_id`)
- Pagamento PIX (`billingType=PIX`, value, dueDate)
- Busca QR Code em `/payments/{id}/pixQrCode`
2. Dialog mostra QR + copia-cola + link cartão
3. User paga
4. Asaas → webhook `asaas-webhook` recebe `PAYMENT_RECEIVED`/`CONFIRMED`
5. Webhook chama `add_whatsapp_credits(tenant, credits, 'purchase')` → saldo atualiza
**Pacotes seedados:** Iniciante (100/R$49,90), Profissional (500/R$199,90, ⭐ featured), Clínica (1500/R$499,90), Enterprise (5000/R$1499,90) — editáveis via DB.
**RPCs atômicas (SECURITY DEFINER):**
- `add_whatsapp_credits(tenant, amount, kind, purchase_id, admin_id, note)` → retorna novo saldo
- `deduct_whatsapp_credits(tenant, amount, msg_id, note)` → atômico com `SELECT FOR UPDATE`; lança `insufficient_credits` se saldo < amount
**CPF:** fallback hardcoded `24971563792` em sandbox. Em produção, frontend deve coletar CPF real do user (TODO — adicionar input no dialog + salvar em profile/tenant).
---
## 🧪 Testar sem provedor real
### Pré-requisito: creditar saldo
No Supabase Studio (`http://localhost:54323`) → SQL Editor (**desativa LIMIT 100 no dropdown**):
```sql
SELECT public.add_whatsapp_credits(
tm.tenant_id,
100,
'topup_manual',
NULL,
auth.uid(),
'Topup de teste'
) AS novo_saldo
FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL'
AND tm.status = 'active'
LIMIT 1;
```
### Simular canal Twilio fake (pra testar dedução)
```sql
-- Desativa Evolution
UPDATE public.notification_channels
SET is_active = false, deleted_at = now()
WHERE tenant_id = (
SELECT tm.tenant_id FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active' LIMIT 1
)
AND channel = 'whatsapp' AND deleted_at IS NULL;
-- Cria Twilio fake
INSERT INTO public.notification_channels (
tenant_id, owner_id, channel, provider, is_active,
twilio_subaccount_sid, twilio_phone_number, credentials
)
SELECT
tm.tenant_id, u.id, 'whatsapp', 'twilio', true,
'ACfake0000000000000000000000000000',
'+15557775555',
'{"subaccount_auth_token": "fake_token"}'::jsonb
FROM public.tenant_members tm
JOIN auth.users u ON u.id = tm.user_id
WHERE u.email = 'SEU_EMAIL' AND tm.status = 'active'
LIMIT 1;
```
Agora qualquer envio:
- Deduz 1 crédito (`usage -1`)
- Twilio API retorna 401 (creds fake)
- Refunda automaticamente (`refund +1`)
- Saldo final: igual ao inicial
Resultado esperado no extrato:
```
+1 Estorno — Refund envio falhou: Twilio 401: ...
-1 Uso — Envio manual WhatsApp
+100 Topup manual — Topup de teste
```
### Simular mensagem inbound via curl
```bash
curl -X POST "http://localhost:54321/functions/v1/evolution-whatsapp-inbound?tenant_id=<uuid>" \
-H "Content-Type: application/json" \
-d '{
"event": "messages.upsert",
"data": {
"key": { "remoteJid": "5516912345678@s.whatsapp.net", "fromMe": false, "id": "MSG_TEST" },
"message": { "conversation": "parar" },
"messageTimestamp": '"$(date +%s)"'
}
}'
```
Isso vai testar detecção de opt-out ("parar" é keyword) → registra na lista + envia ACK.
### Testar Twilio sandbox real (opcional)
1. Cria conta trial em https://www.twilio.com/try-twilio (US$15 grátis)
2. Dashboard → Messaging → Try it out → **Send a WhatsApp Message**
3. Segue instruções pra join sandbox (`join <frase>` do seu celular pro número Twilio)
4. Pega Account SID + Auth Token + From number do sandbox
5. Atualiza o canal Twilio no SQL com valores reais
6. Envia mensagem do drawer → chega no seu celular
---
## 🆘 Troubleshooting
| Sintoma | Causa provável | Fix |
|---|---|---|
| 404 no webhook | `supabase functions serve` não rodando OU function não carregada | Reinicia serve com `--env-file` |
| 502 edge function | Crash antes do `console.error` — erro de sintaxe, env var faltando, ou API externa timeout | Olha logs do serve; adiciona `console.log` em cada etapa |
| Asaas 400 "CPF obrigatório" | Customer existente sem CPF | Fix em `getOrCreateAsaasCustomer` faz PATCH com CPF quando falta |
| QR code PIX não aparece | Asaas sandbox sem PIX habilitado OU endpoint `/pixQrCode` falhou | Ativa PIX em Asaas → Recebimentos → Chaves PIX |
| Webhook Asaas não chega | URL não pública (localhost) | Usa ngrok OU deploy em prod |
| "insufficient_credits" no envio | Saldo zerado | Topup via `/configuracoes/creditos-whatsapp` ou SQL manual |
| Auto-reply não dispara | Tenant fora do modo horário configurado OU em cooldown OU opted-out | Checa banner de status em `/configuracoes/conversas-autoreply`; olha `conversation_autoreply_log` |
| Lembrete duplicado | Não acontece — UNIQUE `(event_id, reminder_type)` previne | — |
| Audio não toca (era o ponto inicial do Marco A-I) | URL encriptada do Meta salva direto | Fix: edge function agora decripta via `getBase64FromMediaMessage` + upload pro bucket |
| Mime `audio/ogg; codecs=opus` rejeitado pelo bucket | `allowed_mime_types` faz match exato | Fix: strip `;codecs=...` antes do upload |
| Dialog de confirmação aparece 2x | Dois `<ConfirmDialog>` montados (página + drawer global) | Fix: drawer usa `group="conversation-drawer"` pra isolar |
---
## 🔒 Segurança & Compliance
### RLS de `conversation_messages`
- **SELECT:** tenant members ativos OU saas_admin
- **INSERT direto:** bloqueado (só service_role via edge function)
- **UPDATE:** tenant members podem mudar `kanban_status`, `read_at`
- **DELETE:** bloqueado
### RLS de `whatsapp-media` bucket
- Privado
- Read: membros ativos do tenant cujo id é o primeiro segmento do path
- Write: apenas service_role
- Delete: apenas saas_admin
### RLS de `whatsapp_credits_*`
- Balance/transactions: tenant members leem
- Escrita: via RPCs `add_whatsapp_credits` / `deduct_whatsapp_credits` (SECURITY DEFINER)
- Packages: tenant members leem os ativos; saas_admin gerencia
### LGPD
- **Art. 18 §2 (direito de oposição):** opt-out implementado. Auto-reply + lembretes respeitam.
- **Dados clínicos:** canal Oficial (Twilio) recomendado em prod. Pessoal (Evolution) é uso informal, sem SLA, tenant assume risco.
- **Audit log:** toda transação de crédito fica em `whatsapp_credits_transactions` (append-only).
### Bot defense / Rate limiting
- `public_submission_attempts`, `submission_rate_limits` e `math_challenges` são tabelas do sistema de bot defense pro cadastro externo. Não relacionado ao WhatsApp, mas protege fluxo paralelo.
---
## 📚 Referência de arquivos
### Edge functions (`supabase/functions/`)
- `evolution-whatsapp-inbound/` — webhook Evolution (msgs inbound + auto-reply + opt-out + media)
- `evolution-webhook-provision/` — configura webhook na Evolution
- `twilio-whatsapp-inbound/` — webhook Twilio (inbound only, sem automações ainda)
- `twilio-whatsapp-provision/` — provisiona subconta Twilio (SaaS admin only)
- `send-whatsapp-message/` — envio unificado (Evolution ou Twilio com dedução)
- `send-session-reminders/` — worker de lembretes (chamado por cron)
- `create-whatsapp-credit-charge/` — cria PIX Asaas
- `asaas-webhook/` — recebe eventos Asaas e credita saldo
- `deactivate-notification-channel/` — soft-delete de canal (via service_role)
### Composables (`src/composables/`)
- `useConversations.js` — Kanban threads
- `useConversationNotes.js` — notas internas
- `useConversationTags.js` — tags CRUD + apply
- `useConversationOptouts.js` — opt-outs + keywords
- `useAutoReplySettings.js` — config auto-reply
- `useSessionReminders.js` — config lembretes + logs
- `useWhatsappCredits.js` — saldo + loja + extrato
### Páginas principais (`src/layout/configuracoes/`)
- `ConfiguracoesWhatsappChooserPage.vue` — landing/chooser
- `ConfiguracoesWhatsappPage.vue` — setup Evolution (Pessoal)
- `ConfiguracoesTwilioWhatsappPage.vue` — setup Twilio (Oficial, rebrandeado)
- `ConfiguracoesConversasTagsPage.vue` — CRUD tags
- `ConfiguracoesConversasOptoutsPage.vue` — lista opt-outs + keywords
- `ConfiguracoesConversasAutoreplyPage.vue` — auto-reply
- `ConfiguracoesLembretesSessaoPage.vue` — lembretes
- `ConfiguracoesCreditosWhatsappPage.vue` — saldo + loja + histórico
### Tabelas principais (database-novo/schema/)
Ver dashboard interativo: `database-novo/agenciapsi-db-dashboard.html` (regenerado via `node db.cjs dashboard`).
Domínios no dashboard:
- **CRM Conversas (WhatsApp)** — todas as tabelas de conversas, notas, tags, opt-outs, auto-reply, lembretes
- **Addons / Créditos** — créditos WhatsApp (balance, transactions, packages, purchases)
- **Comunicação / Notificações** — canais, templates, queue
---
## 📌 Dívidas técnicas conhecidas
1. **Auto-reply via Twilio** — hoje só funciona com Evolution. Precisa portar lógica pra `twilio-whatsapp-inbound`.
2. **Opt-out via Twilio** — idem (keyword detection no inbound Twilio).
3. **Admin SaaS UI de créditos** — topup manual + gestão de pacotes (hoje via SQL).
4. **Input de CPF real** no dialog de compra (hoje fallback hardcoded em sandbox).
5. **Alerta de saldo baixo** — estrutura DB pronta (`low_balance_threshold`, `low_balance_alerted_at`), falta trigger/notification.
6. **Reconnect Evolution automático** — Grupo 6.3 do roadmap (heartbeat + reconnect).
7. **pg_cron em prod** — migration tem bloco comentado; ativar via Supabase Dashboard → Database → Cron Jobs ou descomentar após setar `app.settings.service_role_key`.
---
**Última atualização:** 2026-04-21 (sessão de features CRM WhatsApp + Marco B Créditos)
File diff suppressed because one or more lines are too long
+43 -7
View File
@@ -86,7 +86,9 @@
"billing_contracts", "entitlements_invalidation"
],
"Addons / Créditos": [
"addon_products", "addon_credits", "addon_transactions"
"addon_products", "addon_credits", "addon_transactions",
"whatsapp_credits_balance", "whatsapp_credits_transactions",
"whatsapp_credit_packages", "whatsapp_credit_purchases"
],
"Tenants / Multi-tenant": [
"tenants", "profiles", "user_settings",
@@ -99,7 +101,9 @@
"patient_groups", "patient_group_patient",
"patient_tags", "patient_patient_tag",
"patient_discounts", "patient_intake_requests", "patient_invites",
"patient_status_history", "patient_timeline"
"patient_status_history", "patient_timeline",
"contact_types", "contact_phones",
"contact_email_types", "contact_emails"
],
"Agenda / Agendamento": [
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
@@ -130,6 +134,17 @@
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
"twilio_subaccount_usage"
],
"CRM Conversas (WhatsApp)": [
"conversation_messages", "conversation_threads",
"conversation_notes",
"conversation_tags", "conversation_thread_tags",
"conversation_optouts", "conversation_optout_keywords",
"conversation_autoreply_settings", "conversation_autoreply_log",
"session_reminder_settings", "session_reminder_logs"
],
"Segurança / Rate limiting": [
"submission_rate_limits"
],
"Central SaaS (docs/FAQ)": [
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
],
@@ -147,6 +162,8 @@
"Serviços / Prontuários": "#34d399",
"Documentos": "#0ea5e9",
"Comunicação / Notificações": "#fbbf24",
"CRM Conversas (WhatsApp)": "#25d366",
"Segurança / Rate limiting": "#ef4444",
"Central SaaS (docs/FAQ)": "#c084fc",
"Estrutura / Calendário": "#fb923c"
},
@@ -201,17 +218,36 @@
"items": [
{
"name": "Evolution API",
"role": "Integração WhatsApp Business (envio/recebimento)",
"role": "WhatsApp self-hosted via Baileys (tier gratuito do SaaS — 'WhatsApp Pessoal')",
"env": "Local (Docker)",
"status": "ativo",
"notes": "Container via evolution-api/. whatsapp_instances e notification_channels já cadastrados. Integração real está sendo costurada."
"notes": "Container via evolution-api/docker-compose.yml. Uso do usuário conecta via QR code no celular real. Sem SLA, Meta pode banir número. Envio sem custo. Edge functions: evolution-whatsapp-inbound, evolution-webhook-provision, send-whatsapp-message."
},
{
"name": "Twilio (SMS/Voz)",
"role": "Provedor de SMS e voz para notificações",
"name": "Twilio WhatsApp Business API",
"role": "WhatsApp oficial (tier pago rebrandeado como 'WhatsApp Oficial AgenciaPSI')",
"env": "Cloud",
"status": "ativo",
"notes": "twilio_subaccount_usage rastreia consumo por tenant. SaasTwilioWhatsappPage gerencia contas."
"notes": "API oficial Meta, zero risco de ban. Credenciais em notification_channels (twilio_subaccount_sid + credentials.subaccount_auth_token). Envio consome 1 crédito via RPC deduct_whatsapp_credits (atômico + rollback em falha). Provisionamento: supabase/functions/twilio-whatsapp-provision/."
}
]
},
"Pagamentos / Billing": {
"color": "#fb923c",
"items": [
{
"name": "Asaas (gateway PIX/cartão/boleto)",
"role": "Processamento de pagamentos pra compra de créditos WhatsApp (Marco B)",
"env": "Cloud — sandbox.asaas.com em dev, api.asaas.com em prod",
"status": "ativo",
"notes": "API key em ASAAS_API_KEY (env secret). URL em ASAAS_API_URL. Webhook token opcional em ASAAS_WEBHOOK_TOKEN. Edge functions: create-whatsapp-credit-charge (cria customer + PIX), asaas-webhook (recebe PAYMENT_RECEIVED/CONFIRMED e credita saldo via add_whatsapp_credits)."
},
{
"name": "ngrok (dev only — tunnel pro webhook)",
"role": "Expõe edge functions locais pra Asaas alcançar via internet",
"env": "Local (dev)",
"status": "opcional",
"notes": "Uso: ngrok http 54321 → copia URL e cadastra em Asaas → Integrações → Webhooks → /functions/v1/asaas-webhook. Necessário só pra testar fluxo completo local incluindo confirmação de pagamento."
}
]
},
@@ -0,0 +1,163 @@
-- ============================================================
-- Fix: Remove templates com keys em inglês (WhatsApp/SMS)
-- Agência PSI — 2026-04-22
-- ============================================================
-- Ambiente de desenvolvimento sem dados reais: DELETE físico.
-- Mantém apenas as keys canônicas em português definidas
-- pelo seed_014_global_data.sql. Se alguma key PT estiver
-- faltando após rodar esta migration, rode o Step 3 de reseed.
--
-- Idempotente: rodar de novo não causa erro (DELETE simples
-- encontra 0 linhas).
-- ============================================================
BEGIN;
-- ── Step 1: Snapshot ANTES ────────────────────────────────────────────
-- Útil pra conferir o que vai ser apagado
SELECT
'BEFORE' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active,
deleted_at
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 2: DELETE físico de todas as keys em inglês ─────────────────
DELETE FROM public.notification_templates
WHERE key IN (
-- WhatsApp — variantes em inglês
'session.reminder.whatsapp',
'session.reminder_2h.whatsapp',
'session.confirmation.whatsapp',
'session.cancellation.whatsapp',
'session.reschedule.whatsapp',
'session.rescheduled.whatsapp',
'billing.pending.whatsapp',
'system.welcome.whatsapp',
-- SMS — variantes em inglês
'session.reminder.sms',
'session.reminder_2h.sms',
'session.confirmation.sms',
'session.cancellation.sms',
'session.reschedule.sms',
'session.rescheduled.sms',
'billing.pending.sms',
'system.welcome.sms'
);
-- ── Step 3: Re-seed (inserção idempotente) das keys PT canônicas ─────
-- Garante que todas as keys esperadas existem como globais ativas.
-- Usa INSERT … ON CONFLICT DO UPDATE para ser idempotente.
-- Os body_text são placeholders padrão; se quiser textos diferentes,
-- edite depois via /configuracoes/whatsapp-templates ou /saas/notification-templates.
INSERT INTO public.notification_templates
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES
-- ── WhatsApp ─────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Lembrete: sua sessão com {{therapist_name}} é amanhã às {{session_time}}. Até lá!',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} começa em 2 horas ({{session_time}}). Até já!',
'["patient_name","therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.whatsapp', 'session', 'whatsapp', 'confirmacao_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} foi confirmada para {{session_date}} às {{session_time}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.whatsapp', 'session', 'whatsapp', 'cancelamento_sessao',
'Olá {{patient_name}}. Sua sessão de {{session_date}} às {{session_time}} foi cancelada. Entre em contato para remarcar.',
'["patient_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.whatsapp', 'session', 'whatsapp', 'reagendamento',
'Olá {{patient_name}}! Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.whatsapp', 'billing', 'whatsapp', 'cobranca_pendente',
'Olá {{patient_name}}! Identificamos um pagamento pendente de {{valor}} com vencimento em {{vencimento}}. Qualquer dúvida, estou à disposição.',
'["patient_name","valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.whatsapp', 'system', 'whatsapp', 'boas_vindas_paciente',
'Olá {{patient_name}}! Bem-vindo(a) à {{clinic_name}}. Seu terapeuta {{therapist_name}} está à disposição.',
'["patient_name","clinic_name","therapist_name"]'::jsonb, true, true),
-- ── SMS ──────────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.sms', 'session', 'sms', 'lembrete_sessao',
'Lembrete: sua sessao com {{therapist_name}} e amanha as {{session_time}}.',
'["therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.sms', 'session', 'sms', 'lembrete_sessao',
'Sua sessao com {{therapist_name}} comeca em 2h ({{session_time}}).',
'["therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.sms', 'session', 'sms', 'confirmacao_sessao',
'Sua sessao foi confirmada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.sms', 'session', 'sms', 'cancelamento_sessao',
'Sua sessao de {{session_date}} as {{session_time}} foi cancelada.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.sms', 'session', 'sms', 'reagendamento',
'Sua sessao foi reagendada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.sms', 'billing', 'sms', 'cobranca_pendente',
'Pagamento pendente: {{valor}}, venc. {{vencimento}}.',
'["valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.sms', 'system', 'sms', 'boas_vindas_paciente',
'Bem-vindo a {{clinic_name}}! Seu terapeuta e {{therapist_name}}.',
'["clinic_name","therapist_name"]'::jsonb, true, true)
ON CONFLICT (tenant_id, owner_id, key, deleted_at)
DO UPDATE SET
body_text = EXCLUDED.body_text,
variables = EXCLUDED.variables,
is_default = EXCLUDED.is_default,
is_active = EXCLUDED.is_active,
domain = EXCLUDED.domain,
event_type = EXCLUDED.event_type,
updated_at = now();
-- ── Step 4: Snapshot DEPOIS ──────────────────────────────────────────
SELECT
'AFTER' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
AND deleted_at IS NULL
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 5: Verificação — esperado 0 linhas ativas em EN ─────────────
SELECT
count(*) AS remaining_english_keys
FROM public.notification_templates
WHERE deleted_at IS NULL
AND key IN (
'session.reminder.whatsapp', 'session.reminder_2h.whatsapp', 'session.confirmation.whatsapp',
'session.cancellation.whatsapp', 'session.reschedule.whatsapp', 'session.rescheduled.whatsapp',
'billing.pending.whatsapp', 'system.welcome.whatsapp',
'session.reminder.sms', 'session.reminder_2h.sms', 'session.confirmation.sms',
'session.cancellation.sms', 'session.reschedule.sms', 'session.rescheduled.sms',
'billing.pending.sms', 'system.welcome.sms'
);
COMMIT;
@@ -0,0 +1,107 @@
-- =============================================================================
-- Migration: 20260420000001_patient_intake_invite_info_rpc
-- A#31 — RPC read-only de lookup público do terapeuta/clínica a partir do
-- token do patient_invite. Consumida pela edge function get-intake-invite-info
-- para alimentar o "hero header" da página /cadastro/paciente.
--
-- Segurança:
-- • SECURITY DEFINER (ignora RLS de profiles/company_profiles)
-- • Valida token: existe, ativo, não-expirado, dentro do max_uses
-- • Retorna APENAS campos explicitamente seguros (não-sensíveis)
-- • Execute revogado de PUBLIC/anon; grantado só para service_role (edge)
-- e authenticated (usos internos futuros)
--
-- Payload devolvido:
-- { ok: true, info: { therapist: {...}, clinic: {...}|null } }
-- { error: 'invalid-token' } — token inválido/expirado/esgotado
-- { error: 'missing-token' } — input vazio
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_intake_invite_info(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_token_clean text;
v_invite RECORD;
v_result jsonb;
BEGIN
v_token_clean := nullif(trim(coalesce(p_token, '')), '');
IF v_token_clean IS NULL THEN
RETURN jsonb_build_object('error', 'missing-token');
END IF;
SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
INTO v_invite
FROM public.patient_invites pi
WHERE pi.token = v_token_clean
LIMIT 1;
IF v_invite.owner_id IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.active IS DISTINCT FROM true THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
SELECT jsonb_build_object(
'therapist', jsonb_build_object(
'display_name', coalesce(
nullif(trim(p.full_name), ''),
nullif(trim(p.nickname), ''),
'Profissional'
),
'avatar_url', nullif(trim(coalesce(p.avatar_url, '')), ''),
'work_description', nullif(trim(coalesce(p.work_description, '')), ''),
'bio', nullif(trim(coalesce(p.bio, '')), ''),
'phone', nullif(trim(coalesce(p.phone, '')), ''),
'site_url', nullif(trim(coalesce(p.site_url, '')), ''),
'instagram', nullif(trim(coalesce(p.social_instagram, '')), '')
),
'clinic', CASE
WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
'name', nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
'logo_url', nullif(trim(coalesce(cp.logo_url, '')), ''),
'email', nullif(trim(coalesce(cp.email, '')), ''),
'phone', nullif(trim(coalesce(cp.telefone, '')), ''),
'site', nullif(trim(coalesce(cp.site, '')), ''),
'city', nullif(trim(coalesce(cp.cidade, '')), ''),
'state', nullif(trim(coalesce(cp.estado, '')), ''),
'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
'street', nullif(trim(coalesce(cp.logradouro, '')), ''),
'number', nullif(trim(coalesce(cp.numero, '')), ''),
'social', coalesce(cp.redes_sociais, '[]'::jsonb)
)
ELSE NULL
END
)
INTO v_result
FROM public.profiles p
LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
WHERE p.id = v_invite.owner_id
LIMIT 1;
IF v_result IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
RETURN jsonb_build_object('ok', true, 'info', v_result);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) TO authenticated, service_role;
COMMENT ON FUNCTION public.get_patient_intake_invite_info(text) IS
'A#31 — Lookup público read-only (via edge function) dos dados de apresentação do terapeuta/clínica dono do link de cadastro externo. Só retorna campos não-sensíveis.';
@@ -0,0 +1,199 @@
-- =============================================================================
-- Migration: 20260420000002_audit_logs_lgpd
-- Sessao 11 - Fase 2a (Opcao C).
--
-- Resolve: LGPD Art. 37 - registro das operacoes de tratamento.
-- Projeto ja tinha logs pontuais (document_access_logs, patient_status_history,
-- notification_logs, addon_transactions) mas nao registrava:
-- - Edicao de dados do paciente (nome/CPF/endereco)
-- - CRUD de sessoes na agenda
-- - CRUD de registros financeiros
-- - CRUD de documentos (metadata)
-- - Mudancas de permissao / members do tenant
--
-- Cria tabela audit_logs imutavel + funcao trigger generica + triggers nas
-- tabelas criticas. RLS: tenant member le; ninguem INSERT/UPDATE/DELETE direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela audit_logs
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
action TEXT NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
ON public.audit_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
ON public.audit_logs (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
ON public.audit_logs (user_id, created_at DESC) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_fields
ON public.audit_logs USING GIN (changed_fields);
COMMENT ON TABLE public.audit_logs IS
'Registro imutavel de operacoes de tratamento (LGPD Art. 37). INSERT apenas via trigger SECURITY DEFINER.';
-- ---------------------------------------------------------------------------
-- Funcao trigger generica
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_tenant_id UUID;
v_entity_id TEXT;
v_old JSONB;
v_new JSONB;
v_changed TEXT[];
v_heavy_fields TEXT[] := ARRAY[
'content', 'content_html', 'content_json', 'raw_data',
'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
];
v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
BEGIN
IF TG_OP = 'DELETE' THEN
v_tenant_id := OLD.tenant_id;
v_entity_id := OLD.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := NULL;
v_new := to_jsonb(NEW) - v_heavy_fields;
ELSE -- UPDATE
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := to_jsonb(NEW) - v_heavy_fields;
-- calcular campos realmente alterados
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
-- se nada mudou, ignora
IF v_changed IS NULL THEN
RETURN NEW;
END IF;
-- se mudou apenas campos de ruido (ex: updated_at), ignora
IF v_changed <@ v_noise_fields THEN
RETURN NEW;
END IF;
END IF;
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields
) VALUES (
v_tenant_id,
auth.uid(),
TG_TABLE_NAME,
v_entity_id,
lower(TG_OP),
v_old,
v_new,
v_changed
);
RETURN COALESCE(NEW, OLD);
END;
$$;
COMMENT ON FUNCTION public.log_audit_change() IS
'Trigger generica de audit. Filtra campos pesados (content, signature_data) e ruido (updated_at).';
-- ---------------------------------------------------------------------------
-- Triggers nas tabelas criticas
-- ---------------------------------------------------------------------------
-- patients
DROP TRIGGER IF EXISTS trg_audit_patients ON public.patients;
CREATE TRIGGER trg_audit_patients
AFTER INSERT OR UPDATE OR DELETE ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- agenda_eventos
DROP TRIGGER IF EXISTS trg_audit_agenda_eventos ON public.agenda_eventos;
CREATE TRIGGER trg_audit_agenda_eventos
AFTER INSERT OR UPDATE OR DELETE ON public.agenda_eventos
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- financial_records
DROP TRIGGER IF EXISTS trg_audit_financial_records ON public.financial_records;
CREATE TRIGGER trg_audit_financial_records
AFTER INSERT OR UPDATE OR DELETE ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- documents
DROP TRIGGER IF EXISTS trg_audit_documents ON public.documents;
CREATE TRIGGER trg_audit_documents
AFTER INSERT OR UPDATE OR DELETE ON public.documents
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- tenant_members (mudanca de permissao)
DROP TRIGGER IF EXISTS trg_audit_tenant_members ON public.tenant_members;
CREATE TRIGGER trg_audit_tenant_members
AFTER INSERT OR UPDATE OR DELETE ON public.tenant_members
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; saas_admin le tudo; ninguem escreve direto
-- ---------------------------------------------------------------------------
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "audit_logs: select tenant" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: saas_admin all" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct insert" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct update" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct delete" ON public.audit_logs;
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Explicitamente NEGA insert/update/delete via API
-- (SECURITY DEFINER na funcao trigger bypassa RLS; app nao consegue escrever direto)
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs
FOR UPDATE TO authenticated
USING (false) WITH CHECK (false);
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs
FOR DELETE TO authenticated
USING (false);
-- ---------------------------------------------------------------------------
-- Marca hardening na auditoria
-- ---------------------------------------------------------------------------
COMMENT ON COLUMN public.audit_logs.old_values IS 'Estado anterior (jsonb); NULL em INSERT; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.new_values IS 'Estado posterior (jsonb); NULL em DELETE; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.changed_fields IS 'Lista de campos alterados em UPDATE (NULL em INSERT/DELETE)';
@@ -0,0 +1,148 @@
-- =============================================================================
-- Migration: 20260420000003_audit_logs_unified_view
-- Sessao 11 - Fase 2a (Opcao C).
--
-- View audit_log_unified que junta:
-- - audit_logs (nova, trigger generico em patients/agenda/etc)
-- - document_access_logs (visualizacao/download/impressao de documento)
-- - patient_status_history (mudancas de status de paciente)
-- - notification_logs (envio de SMS/email/WhatsApp)
-- - addon_transactions (compras/consumos de recursos extras)
--
-- RLS: aplica-se das tabelas base. View usa security_invoker para herdar.
-- =============================================================================
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
CREATE VIEW public.audit_log_unified
WITH (security_invoker = true)
AS
-- 1) audit_logs (trigger generico)
SELECT
'audit:' || al.id::text AS uid,
al.tenant_id AS tenant_id,
al.user_id AS user_id,
al.entity_type AS entity_type,
al.entity_id AS entity_id,
al.action AS action,
CASE al.action
WHEN 'insert' THEN 'Criou ' || al.entity_type
WHEN 'update' THEN 'Alterou ' || al.entity_type
|| COALESCE(' (' || array_to_string(al.changed_fields, ', ') || ')', '')
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
END AS description,
al.created_at AS occurred_at,
'audit_logs' AS source,
jsonb_build_object(
'old_values', al.old_values,
'new_values', al.new_values,
'changed_fields', al.changed_fields
) AS details
FROM public.audit_logs al
UNION ALL
-- 2) document_access_logs
SELECT
'doc_access:' || dal.id::text,
dal.tenant_id,
dal.user_id,
'document' AS entity_type,
dal.documento_id::text AS entity_id,
dal.acao AS action,
CASE dal.acao
WHEN 'visualizou' THEN 'Visualizou documento'
WHEN 'baixou' THEN 'Baixou documento'
WHEN 'imprimiu' THEN 'Imprimiu documento'
WHEN 'compartilhou' THEN 'Compartilhou documento'
WHEN 'assinou' THEN 'Assinou documento'
ELSE dal.acao
END AS description,
dal.acessado_em AS occurred_at,
'document_access_logs' AS source,
jsonb_build_object(
'ip', dal.ip::text,
'user_agent', dal.user_agent
) AS details
FROM public.document_access_logs dal
UNION ALL
-- 3) patient_status_history
SELECT
'psh:' || psh.id::text,
psh.tenant_id,
psh.alterado_por,
'patient_status' AS entity_type,
psh.patient_id::text AS entity_id,
'status_change' AS action,
'Status do paciente: '
|| COALESCE(psh.status_anterior, '') || '' || psh.status_novo
|| COALESCE(' (' || psh.motivo || ')', '') AS description,
psh.alterado_em AS occurred_at,
'patient_status_history' AS source,
jsonb_build_object(
'status_anterior', psh.status_anterior,
'status_novo', psh.status_novo,
'motivo', psh.motivo,
'encaminhado_para', psh.encaminhado_para,
'data_saida', psh.data_saida
) AS details
FROM public.patient_status_history psh
UNION ALL
-- 4) notification_logs
SELECT
'notif:' || nl.id::text,
nl.tenant_id,
nl.owner_id AS user_id,
'notification' AS entity_type,
nl.patient_id::text AS entity_id,
nl.status AS action,
'Notificação ' || nl.channel || ' '
|| nl.status
|| COALESCE(' para ' || nl.recipient_address, '') AS description,
nl.created_at AS occurred_at,
'notification_logs' AS source,
jsonb_build_object(
'channel', nl.channel,
'template_key', nl.template_key,
'status', nl.status,
'provider', nl.provider,
'failure_reason', nl.failure_reason
) AS details
FROM public.notification_logs nl
UNION ALL
-- 5) addon_transactions
SELECT
'addon:' || at.id::text,
at.tenant_id,
at.admin_user_id AS user_id,
'addon_transaction' AS entity_type,
at.id::text AS entity_id,
at.type AS action,
CASE at.type
WHEN 'purchase' THEN 'Compra de ' || at.amount || ' créditos de ' || at.addon_type
WHEN 'consumption' THEN 'Consumo de ' || abs(at.amount) || ' crédito(s) ' || at.addon_type
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
WHEN 'refund' THEN 'Reembolso de ' || abs(at.amount) || ' créditos ' || at.addon_type
ELSE at.type || ' ' || at.addon_type
END AS description,
at.created_at AS occurred_at,
'addon_transactions' AS source,
jsonb_build_object(
'addon_type', at.addon_type,
'amount', at.amount,
'balance_after', at.balance_after,
'price_cents', at.price_cents,
'payment_reference', at.payment_reference
) AS details
FROM public.addon_transactions at;
COMMENT ON VIEW public.audit_log_unified IS
'Timeline unificada de eventos auditaveis (LGPD). Herda RLS das tabelas base via security_invoker.';
GRANT SELECT ON public.audit_log_unified TO authenticated;
@@ -0,0 +1,225 @@
-- =============================================================================
-- Migration: 20260420000004_lgpd_export_patient_rpc
-- Sessao 11 - Fase 2b (Opcao C).
--
-- Implementa LGPD Art. 18 - direito de portabilidade do titular.
-- RPC export_patient_data(p_patient_id uuid) retorna jsonb com todos os dados
-- relacionados ao paciente. Registra o evento em audit_logs para rastreabilidade.
--
-- Seguranca:
-- - SECURITY DEFINER permite agregar tabelas diversas bypassando RLS
-- - Verificacao explicita: caller deve ser tenant_member ativo da clinica do paciente
-- - Proibido acesso cross-tenant
-- =============================================================================
CREATE OR REPLACE FUNCTION public.export_patient_data(p_patient_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_patient RECORD;
v_tenant_id UUID;
v_caller UUID;
v_is_member BOOLEAN;
v_result JSONB;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
END IF;
-- carrega paciente
SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
END IF;
v_tenant_id := v_patient.tenant_id;
-- verifica se caller e membro ativo do tenant do paciente
SELECT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
) OR public.is_saas_admin() INTO v_is_member;
IF NOT v_is_member THEN
RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
END IF;
-- monta o payload
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object(
'generated_at', now(),
'generated_by', v_caller,
'tenant_id', v_tenant_id,
'patient_id', p_patient_id,
'lgpd_basis', 'Art. 18, II - portabilidade dos dados do titular',
'controller', 'AgenciaPSI - Clinica responsavel',
'format_version', '1.0'
),
'paciente', to_jsonb(v_patient),
'contatos', COALESCE((
SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at)
FROM public.patient_contacts pc
WHERE pc.patient_id = p_patient_id
), '[]'::jsonb),
'contatos_apoio', COALESCE((
SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at)
FROM public.patient_support_contacts psc
WHERE psc.patient_id = p_patient_id
), '[]'::jsonb),
'historico_status', COALESCE((
SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em)
FROM public.patient_status_history psh
WHERE psh.patient_id = p_patient_id
), '[]'::jsonb),
'timeline', COALESCE((
SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em)
FROM public.patient_timeline pt
WHERE pt.patient_id = p_patient_id
), '[]'::jsonb),
'descontos', COALESCE((
SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at)
FROM public.patient_discounts pd
WHERE pd.patient_id = p_patient_id
), '[]'::jsonb),
'eventos_agenda', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', ae.id,
'tipo', ae.tipo,
'inicio_em', ae.inicio_em,
'fim_em', ae.fim_em,
'status', ae.status,
'observacoes', ae.observacoes,
'created_at', ae.created_at
) ORDER BY ae.inicio_em
)
FROM public.agenda_eventos ae
WHERE ae.patient_id = p_patient_id
), '[]'::jsonb),
'registros_financeiros', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', fr.id,
'amount', fr.amount,
'discount_amount', fr.discount_amount,
'final_amount', fr.final_amount,
'status', fr.status,
'due_date', fr.due_date,
'paid_at', fr.paid_at,
'payment_method', fr.payment_method,
'notes', fr.notes,
'created_at', fr.created_at
) ORDER BY fr.created_at
)
FROM public.financial_records fr
WHERE fr.patient_id = p_patient_id
), '[]'::jsonb),
'documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', d.id,
'nome_original', d.nome_original,
'tipo_documento', d.tipo_documento,
'categoria', d.categoria,
'descricao', d.descricao,
'mime_type', d.mime_type,
'tamanho_bytes', d.tamanho_bytes,
'status_revisao', d.status_revisao,
'visibilidade', d.visibilidade,
'uploaded_at', d.uploaded_at,
'created_at', d.created_at
) ORDER BY d.created_at
)
FROM public.documents d
WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
), '[]'::jsonb),
'notificacoes_enviadas', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', nl.id,
'channel', nl.channel,
'template_key', nl.template_key,
'recipient_address', nl.recipient_address,
'status', nl.status,
'sent_at', nl.sent_at,
'delivered_at', nl.delivered_at,
'read_at', nl.read_at,
'failure_reason', nl.failure_reason,
'created_at', nl.created_at
) ORDER BY nl.created_at
)
FROM public.notification_logs nl
WHERE nl.patient_id = p_patient_id
), '[]'::jsonb),
'audit_trail', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', al.id,
'action', al.action,
'entity_type', al.entity_type,
'changed_fields', al.changed_fields,
'user_id', al.user_id,
'created_at', al.created_at
) ORDER BY al.created_at
)
FROM public.audit_logs al
WHERE al.tenant_id = v_tenant_id
AND al.entity_type = 'patients'
AND al.entity_id = p_patient_id::text
), '[]'::jsonb),
'acessos_a_documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', dal.id,
'documento_id', dal.documento_id,
'acao', dal.acao,
'user_id', dal.user_id,
'acessado_em', dal.acessado_em
) ORDER BY dal.acessado_em
)
FROM public.document_access_logs dal
WHERE dal.documento_id IN (
SELECT id FROM public.documents WHERE patient_id = p_patient_id
)
), '[]'::jsonb),
'grupos', COALESCE((
SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
FROM public.patient_group_patient pgp
WHERE pgp.patient_id = p_patient_id
), '[]'::jsonb),
'tags', COALESCE((
SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
FROM public.patient_patient_tag ppt
WHERE ppt.patient_id = p_patient_id
), '[]'::jsonb)
);
-- registra o export como evento auditavel
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields, metadata
) VALUES (
v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
NULL, NULL, ARRAY['__lgpd_export__'],
jsonb_build_object(
'action_kind', 'lgpd_export',
'lgpd_basis', 'Art. 18, II',
'patient_name', v_patient.nome_completo
)
);
RETURN v_result;
END;
$$;
COMMENT ON FUNCTION public.export_patient_data(UUID) IS
'LGPD Art. 18, II - exporta todos os dados do paciente em jsonb portavel. Registra evento em audit_logs.';
REVOKE ALL ON FUNCTION public.export_patient_data(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.export_patient_data(UUID) TO authenticated;
@@ -0,0 +1,263 @@
-- =============================================================================
-- Migration: 20260420000005_conversation_messages
-- Sessao 11 - Fase 5a (CRM de WhatsApp / inbox).
--
-- Cria infraestrutura para receber mensagens inbound de WhatsApp (Twilio e
-- Evolution API) e exibir num Kanban de conversas.
--
-- - conversation_messages — todas as mensagens (in/out) com link opcional
-- ao paciente via telefone matching
-- - function match_patient_by_phone(tenant_id, phone) — encontra paciente
-- - view conversation_threads — agregado por paciente/numero pra UI Kanban
--
-- RLS: tenant members leem; service_role (edge function) escreve via SECURITY
-- DEFINER match_and_insert. App nao escreve direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela de mensagens
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_messages (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
channel TEXT NOT NULL CHECK (channel IN ('whatsapp', 'sms', 'email')),
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
from_number TEXT,
to_number TEXT,
body TEXT,
media_url TEXT,
media_mime TEXT,
provider TEXT NOT NULL CHECK (provider IN ('twilio', 'evolution', 'manual')),
provider_message_id TEXT,
provider_raw JSONB,
-- estado Kanban
kanban_status TEXT NOT NULL DEFAULT 'awaiting_us'
CHECK (kanban_status IN ('urgent', 'awaiting_us', 'awaiting_patient', 'resolved')),
priority INT NOT NULL DEFAULT 0,
read_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_msg_tenant_created
ON public.conversation_messages (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_patient
ON public.conversation_messages (patient_id, created_at DESC) WHERE patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_msg_from_number
ON public.conversation_messages (tenant_id, from_number);
CREATE INDEX IF NOT EXISTS idx_conv_msg_kanban
ON public.conversation_messages (tenant_id, kanban_status, priority DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_provider_msg_id
ON public.conversation_messages (provider_message_id) WHERE provider_message_id IS NOT NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_messages_updated_at ON public.conversation_messages;
CREATE TRIGGER trg_conv_messages_updated_at
BEFORE UPDATE ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_messages IS
'Mensagens in/out de WhatsApp/SMS/email. Timeline de conversas do tenant com pacientes.';
-- ---------------------------------------------------------------------------
-- Funcao: normaliza telefone BR (remove tudo que nao seja digito, tira DDI 55)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.normalize_phone_br(p_phone TEXT)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_digits TEXT;
BEGIN
IF p_phone IS NULL THEN RETURN NULL; END IF;
-- remove tudo que nao seja digito
v_digits := regexp_replace(p_phone, '\D', '', 'g');
-- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
v_digits := substr(v_digits, 3);
END IF;
-- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
IF length(v_digits) > 11 THEN
v_digits := right(v_digits, 11);
END IF;
RETURN v_digits;
END;
$$;
COMMENT ON FUNCTION public.normalize_phone_br(TEXT) IS
'Normaliza telefone BR para os ultimos 11 digitos (DDD+numero), removendo DDI +55 e formatacao.';
-- ---------------------------------------------------------------------------
-- Funcao: match paciente por telefone dentro de um tenant
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id UUID, p_phone TEXT)
RETURNS UUID
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_normalized TEXT;
v_patient_id UUID;
BEGIN
v_normalized := public.normalize_phone_br(p_phone);
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN
RETURN NULL;
END IF;
-- prioridade: telefone principal, depois alternativo, depois responsavel
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_alternativo) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_responsavel) = v_normalized
LIMIT 1;
RETURN v_patient_id;
END;
$$;
COMMENT ON FUNCTION public.match_patient_by_phone(UUID, TEXT) IS
'Encontra patient_id do tenant cujo telefone (principal/alternativo/responsavel) bate com o numero.';
-- ---------------------------------------------------------------------------
-- View: threads agrupadas por paciente ou numero anonimo
-- ---------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown'
)) AS thread_key
FROM public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status
FROM latest l
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
LEFT JOIN public.patients p ON p.id = l.patient_id;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou por numero anonimo. Base do Kanban.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; ninguem escreve direto (so via edge function service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_msg: select tenant" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: update kanban" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct insert" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct delete" ON public.conversation_messages;
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- tenant member pode atualizar apenas kanban_status/read_at/responded_at/resolved_at
-- (nao pode mexer em body, provider, etc)
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages
FOR UPDATE TO authenticated
USING (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages
FOR DELETE TO authenticated
USING (false);
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260420000005_search_global_rpc
-- Busca global do topbar — RPC única que retorna resultados agrupados por
-- entidade (pacientes, agendamentos, documentos, serviços).
--
-- Segurança:
-- • SECURITY INVOKER → respeita RLS do chamador (terapeuta vê só os dele,
-- clínica vê do tenant, saas_admin vê global). Sem reinvenção de permissão.
-- • GRANT apenas para `authenticated` (paciente anônimo não tem busca global).
--
-- Índices trigram:
-- • patients(nome_completo, email_principal, cpf)
-- • services(name)
-- • agenda_eventos(titulo, titulo_custom)
-- • documents(nome_original) — já existe em 06_indexes/indexes.sql (skip)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Índices trigram (GIN) pra ILIKE/similarity performarem
-- pg_trgm instalado em schema `extensions`; ops class vive em `public`.
-- -----------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_patients_nome_trgm
ON public.patients USING gin (nome_completo public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_patients_email_trgm
ON public.patients USING gin (email_principal public.gin_trgm_ops)
WHERE email_principal IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patients_cpf_trgm
ON public.patients USING gin (cpf public.gin_trgm_ops)
WHERE cpf IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_services_name_trgm
ON public.services USING gin (name public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_trgm
ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops)
WHERE titulo IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_custom_trgm
ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops)
WHERE titulo_custom IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patient_intake_requests_nome_trgm
ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops)
WHERE status = 'new';
-- -----------------------------------------------------------------------------
-- RPC principal
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.search_global(
p_q text,
p_scope text[] DEFAULT NULL,
p_limit int DEFAULT 8
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
STABLE
SET search_path = public, pg_temp
AS $$
DECLARE
v_q text;
v_pattern text;
v_limit int;
v_patients jsonb := '[]'::jsonb;
v_appointments jsonb := '[]'::jsonb;
v_documents jsonb := '[]'::jsonb;
v_services jsonb := '[]'::jsonb;
v_intakes jsonb := '[]'::jsonb;
BEGIN
-- Sanitize + length guards
v_q := nullif(btrim(coalesce(p_q, '')), '');
IF v_q IS NULL OR length(v_q) < 2 THEN
RETURN jsonb_build_object(
'patients', '[]'::jsonb,
'appointments', '[]'::jsonb,
'documents', '[]'::jsonb,
'services', '[]'::jsonb,
'intakes', '[]'::jsonb
);
END IF;
v_q := left(v_q, 80);
v_pattern := '%' || v_q || '%';
v_limit := GREATEST(1, LEAST(coalesce(p_limit, 8), 20));
-- ─────────────────────────────────────────────────────────────────────
-- Pacientes
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
p.id,
p.nome_completo,
p.email_principal,
p.telefone,
p.avatar_url,
GREATEST(
similarity(coalesce(p.nome_completo, ''), v_q),
similarity(coalesce(p.email_principal, ''), v_q) * 0.7,
similarity(coalesce(p.telefone, ''), v_q) * 0.5,
similarity(coalesce(p.cpf, ''), v_q) * 0.6
) AS score
FROM public.patients p
WHERE p.nome_completo ILIKE v_pattern
OR p.email_principal ILIKE v_pattern
OR p.telefone ILIKE v_pattern
OR p.cpf ILIKE v_pattern
ORDER BY score DESC, p.nome_completo ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_completo,
'sublabel', coalesce(nullif(email_principal, ''), nullif(telefone, ''), ''),
'avatar_url', avatar_url,
'deeplink', '/therapist/patients/cadastro/' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_patients
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Agendamentos (com nome do paciente via join)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
e.id,
coalesce(nullif(e.titulo_custom, ''), nullif(e.titulo, ''), 'Sessão') AS label,
e.inicio_em,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(e.titulo, ''), v_q),
similarity(coalesce(e.titulo_custom, ''), v_q),
similarity(coalesce(pat.nome_completo, ''), v_q) * 0.9
) AS score
FROM public.agenda_eventos e
LEFT JOIN public.patients pat ON pat.id = e.patient_id
WHERE e.titulo ILIKE v_pattern
OR e.titulo_custom ILIKE v_pattern
OR pat.nome_completo ILIKE v_pattern
ORDER BY score DESC, e.inicio_em DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', label,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| to_char(inicio_em, 'DD/MM/YYYY HH24:MI')),
'deeplink', '/therapist/agenda?event=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_appointments
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Documentos
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
d.id,
d.patient_id,
d.nome_original,
d.tipo_documento,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(d.nome_original, ''), v_q),
similarity(coalesce(d.descricao, ''), v_q) * 0.7
) AS score
FROM public.documents d
LEFT JOIN public.patients pat ON pat.id = d.patient_id
WHERE d.nome_original ILIKE v_pattern
OR d.descricao ILIKE v_pattern
ORDER BY score DESC, d.nome_original ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_original,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| coalesce(tipo_documento, '')),
'deeplink', '/therapist/patients/' || patient_id::text || '/documents',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_documents
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Serviços (ativos)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
s.id,
s.name,
s.price,
s.duration_min,
GREATEST(
similarity(coalesce(s.name, ''), v_q),
similarity(coalesce(s.description, ''), v_q) * 0.7
) AS score
FROM public.services s
WHERE s.active IS TRUE
AND (s.name ILIKE v_pattern
OR s.description ILIKE v_pattern)
ORDER BY score DESC, s.name ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', name,
'sublabel', trim(both ' · ' from
'R$ ' || to_char(price, 'FM999G999G990D00') || ' · '
|| coalesce(duration_min::text || ' min', '')),
'deeplink', '/configuracoes/precificacao',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_services
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Intakes pendentes (patient_intake_requests com status='new')
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
r.id,
r.nome_completo,
r.email_principal,
r.telefone,
r.created_at,
GREATEST(
similarity(coalesce(r.nome_completo, ''), v_q),
similarity(coalesce(r.email_principal, ''), v_q) * 0.7,
similarity(coalesce(r.telefone, ''), v_q) * 0.5
) AS score
FROM public.patient_intake_requests r
WHERE r.status = 'new'
AND (r.nome_completo ILIKE v_pattern
OR r.email_principal ILIKE v_pattern
OR r.telefone ILIKE v_pattern)
ORDER BY score DESC, r.created_at DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', coalesce(nullif(trim(nome_completo), ''), '(sem nome)'),
'sublabel', trim(both ' · ' from
coalesce(nullif(email_principal, ''), nullif(telefone, ''), '') || ' · '
|| 'recebido ' || to_char(created_at, 'DD/MM/YYYY')),
'deeplink', '/therapist/patients/cadastro/recebidos?id=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_intakes
FROM ranked;
END IF;
RETURN jsonb_build_object(
'patients', v_patients,
'appointments', v_appointments,
'documents', v_documents,
'services', v_services,
'intakes', v_intakes
);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.search_global(text, text[], int) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.search_global(text, text[], int) TO authenticated;
COMMENT ON FUNCTION public.search_global(text, text[], int) IS
'Busca global do topbar — retorna jsonb agrupado por entidade. SECURITY INVOKER (RLS do chamador aplica).';
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260420000006_conv_messages_notifications
-- Sessao 11 - Fase 5a (extensao).
--
-- Integra conversation_messages ao sistema de notifications existente:
-- - Adiciona 'inbound_message' ao CHECK do notifications.type
-- - Trigger em conversation_messages: quando chega inbound, fan-out para
-- members do tenant apropriados (responsible_member do paciente, ou
-- todos tenant_admin/clinic_admin/therapist ativos se sem vinculo)
-- =============================================================================
-- Ajusta CHECK do type
ALTER TABLE public.notifications
DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE public.notifications
ADD CONSTRAINT notifications_type_check
CHECK (type = ANY (ARRAY[
'new_scheduling',
'new_patient',
'recurrence_alert',
'session_status',
'inbound_message'
]));
-- ---------------------------------------------------------------------------
-- Trigger function: fan-out mensagem inbound para notifications dos members
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_target_user UUID;
v_title TEXT;
v_detail TEXT;
v_initials TEXT;
v_deeplink TEXT;
v_patient_name TEXT;
v_payload JSONB;
BEGIN
-- so processa inbound
IF NEW.direction <> 'inbound' THEN
RETURN NEW;
END IF;
-- busca nome do paciente (se vinculado)
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
END IF;
-- titulo e detalhes
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
-- iniciais
IF v_patient_name IS NOT NULL THEN
v_initials := upper(left(v_patient_name, 1)) ||
COALESCE(upper(left(split_part(v_patient_name, ' ', 2), 1)), '');
ELSE
v_initials := '?';
END IF;
-- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
v_deeplink := '/admin/conversas';
v_payload := jsonb_build_object(
'title', v_title,
'detail', v_detail,
'avatar_initials', v_initials,
'deeplink', v_deeplink,
'channel', NEW.channel,
'conversation_message_id', NEW.id,
'patient_id', NEW.patient_id,
'from_number', NEW.from_number
);
-- ─── decide destinatarios ─────────────────────────────────────────────
-- Caso 1: paciente vinculado e tem responsible_member_id
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM public.patients p
JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
WHERE p.id = NEW.patient_id
AND tm.status = 'active'
LIMIT 1;
IF v_target_user IS NOT NULL THEN
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = NEW.tenant_id
AND tm.status = 'active'
AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');
RETURN NEW;
END;
$$;
-- Trigger
DROP TRIGGER IF EXISTS trg_fanout_inbound_to_notifications ON public.conversation_messages;
CREATE TRIGGER trg_fanout_inbound_to_notifications
AFTER INSERT ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
COMMENT ON FUNCTION public.fanout_inbound_message_to_notifications() IS
'Cria registros em notifications pra members apropriados quando chega mensagem inbound. Respeita responsible_member do paciente.';
@@ -0,0 +1,26 @@
-- =============================================================================
-- Migration: 20260420000007_notif_channels_saas_admin_insert
--
-- Fix: SaaS admin nao conseguia INSERT em notification_channels via /saas/whatsapp
-- porque a policy de insert exigia owner_id = auth.uid() e o saas_admin esta
-- inserindo em nome do tenant_admin (outro user). As policies de update/delete
-- ja tinham OR is_saas_admin() — o insert foi esquecido.
-- =============================================================================
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
CREATE POLICY "notif_channels_insert" ON public.notification_channels
FOR INSERT TO authenticated
WITH CHECK (
public.is_saas_admin()
OR (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
COMMENT ON POLICY "notif_channels_insert" ON public.notification_channels IS
'SaaS admin pode inserir em nome de qualquer tenant; tenant_member insere pra si mesmo.';
@@ -0,0 +1,12 @@
-- =============================================================================
-- Migration: 20260420000008_conv_messages_realtime
--
-- Adiciona conversation_messages na publicacao supabase_realtime para que
-- INSERT/UPDATE sejam entregues ao subscribe do frontend.
-- =============================================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_messages;
-- REPLICA IDENTITY FULL permite que o payload do Realtime traga a row completa
-- (necessario pra usar filters e receber old/new em UPDATEs)
ALTER TABLE public.conversation_messages REPLICA IDENTITY FULL;
@@ -0,0 +1,17 @@
-- =============================================================================
-- Migration: 20260420000009_conv_messages_delivery_status
--
-- Adiciona colunas para rastrear status de entrega/leitura das mensagens
-- outbound (envio pelo sistema). Evolution dispara evento messages.update
-- com status = SENT | DELIVERED | READ que vamos capturar.
-- =============================================================================
ALTER TABLE public.conversation_messages
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS read_by_recipient_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS delivery_status TEXT
CHECK (delivery_status IS NULL OR delivery_status IN ('pending','sent','delivered','read','failed'));
CREATE INDEX IF NOT EXISTS idx_conv_msg_delivery_status
ON public.conversation_messages (tenant_id, delivery_status)
WHERE direction = 'outbound';
@@ -0,0 +1,91 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Bucket para midia de WhatsApp
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Cria bucket privado `whatsapp-media` para armazenar audio/imagem/video/
-- documentos recebidos via Evolution API. URLs do WhatsApp sao encriptadas
-- com mediaKey da Meta — precisamos decriptar via Evolution getBase64 e
-- subir pro nosso storage para playback no browser.
--
-- Privacidade LGPD:
-- - Bucket privado (public=false)
-- - Upload apenas via service_role (edge function)
-- - Leitura via signed URLs gerados on-demand pelo frontend (expiracao curta)
-- - Paths tenant-scoped: <tenant_id>/<yyyy>/<mm>/<uuid>.<ext>
-- ==========================================================================
-- Bucket whatsapp-media
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'whatsapp-media',
'whatsapp-media',
false,
26214400, -- 25 MB (WhatsApp aceita ate 16MB audio/video, margem extra)
ARRAY[
-- Audio
'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/wav', 'audio/webm',
-- Imagem
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
-- Video
'video/mp4', 'video/3gpp', 'video/quicktime', 'video/webm',
-- Documento
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'application/zip',
'application/octet-stream'
]
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — whatsapp-media
-- --------------------------------------------------------------------------
-- Politica: APENAS service_role faz upload (edge function).
-- Usuarios autenticados leem se forem membros ativos do tenant no path[0].
-- --------------------------------------------------------------------------
-- Read: membro ativo do tenant cujo id e o primeiro segmento do path
CREATE POLICY "whatsapp-media: read tenant members"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND (
-- SaaS admins leem qualquer tenant
public.is_saas_admin()
OR
-- Membros ativos do tenant (tenant_id e o primeiro segmento do path)
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND (storage.foldername(name))[1] = tm.tenant_id::text
)
)
);
-- Insert: bloqueado para authenticated (apenas service_role sobe)
-- Sem policy de INSERT para authenticated = bloqueado por default no RLS.
-- Delete: SaaS admin pode deletar (retention policy futura)
CREATE POLICY "whatsapp-media: delete saas admin"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND public.is_saas_admin()
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,116 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Notas internas de conversa (CRM Grupo 3.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Notas internas da equipe em cada thread de conversa (WhatsApp/SMS/etc).
-- NAO sao enviadas ao paciente — apenas visiveis aos membros do tenant.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - READ/CREATE: qualquer membro ativo do tenant
-- - UPDATE/DELETE: apenas o criador da nota OU saas_admin
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
body TEXT NOT NULL CHECK (length(body) > 0 AND length(body) <= 4000),
created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_conv_notes_tenant_thread
ON public.conversation_notes (tenant_id, thread_key, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_patient
ON public.conversation_notes (patient_id, created_at DESC)
WHERE deleted_at IS NULL AND patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_created_by
ON public.conversation_notes (created_by, created_at DESC)
WHERE deleted_at IS NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_notes_updated_at ON public.conversation_notes;
CREATE TRIGGER trg_conv_notes_updated_at
BEFORE UPDATE ON public.conversation_notes
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_notes IS
'Notas internas por thread de conversa. Visiveis apenas aos membros do tenant; nao enviadas ao paciente.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
-- SELECT: membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_notes: select tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: select tenant members"
ON public.conversation_notes
FOR SELECT
TO authenticated
USING (
deleted_at IS NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- INSERT: membro ativo do tenant, created_by deve ser o proprio usuario
DROP POLICY IF EXISTS "conv_notes: insert tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: insert tenant members"
ON public.conversation_notes
FOR INSERT
TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: apenas criador OU saas_admin
DROP POLICY IF EXISTS "conv_notes: update creator or saas" ON public.conversation_notes;
CREATE POLICY "conv_notes: update creator or saas"
ON public.conversation_notes
FOR UPDATE
TO authenticated
USING (
deleted_at IS NULL
AND (created_by = auth.uid() OR public.is_saas_admin())
)
WITH CHECK (
created_by = (SELECT created_by FROM public.conversation_notes WHERE id = conversation_notes.id)
);
-- DELETE: soft delete via UPDATE deleted_at (nao permite hard delete)
-- Mantemos politica de DELETE bloqueada por default (sem policy = nao permitido)
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Tags de conversa (CRM Grupo 3.1)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Tags aplicaveis a uma thread de conversa (urgente, primeira consulta,
-- remarcacao, etc). Cada tenant pode criar tags custom alem das padrao.
--
-- Tabelas:
-- - conversation_tags — definicoes (system com tenant_id NULL + custom)
-- - conversation_thread_tags — join (tenant_id, thread_key, tag_id)
--
-- thread_key: mesma logica de conversation_threads
-- - '<uuid>' → thread de paciente
-- - 'anon:<phone>' → thread anonima
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: conversation_tags (definicoes)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system tag
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
color TEXT NOT NULL DEFAULT '#6366f1' CHECK (color ~ '^#[0-9a-fA-F]{6}$'),
icon TEXT, -- classe de icone primeicons (ex: 'pi pi-exclamation-triangle')
position INT NOT NULL DEFAULT 100,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: (tenant_id, slug). Para tenant_id NULL (system), um indice parcial separado.
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_tenant_slug
ON public.conversation_tags (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_system_slug
ON public.conversation_tags (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_tags_tenant
ON public.conversation_tags (tenant_id, position);
DROP TRIGGER IF EXISTS trg_conv_tags_updated_at ON public.conversation_tags;
CREATE TRIGGER trg_conv_tags_updated_at
BEFORE UPDATE ON public.conversation_tags
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_tags IS
'Definicoes de tags aplicaveis a threads. tenant_id NULL = tag do sistema (todos veem).';
-- ---------------------------------------------------------------------------
-- Tabela: conversation_thread_tags (join many-to-many)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_thread_tags (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
tag_id UUID NOT NULL REFERENCES public.conversation_tags(id) ON DELETE CASCADE,
tagged_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
tagged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tenant_thread
ON public.conversation_thread_tags (tenant_id, thread_key);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tag
ON public.conversation_thread_tags (tag_id);
COMMENT ON TABLE public.conversation_thread_tags IS
'Join de tags aplicadas a cada thread de conversa.';
-- ---------------------------------------------------------------------------
-- Seed de tags padrao (system)
-- ---------------------------------------------------------------------------
INSERT INTO public.conversation_tags (tenant_id, name, slug, color, icon, position, is_system)
VALUES
(NULL, 'Urgente', 'urgente', '#ef4444', 'pi pi-exclamation-triangle', 10, true),
(NULL, 'Primeira consulta','primeira-consulta','#0ea5e9', 'pi pi-user-plus', 20, true),
(NULL, 'Remarcação', 'remarcacao', '#f59e0b', 'pi pi-calendar-times', 30, true),
(NULL, 'Confirmada', 'confirmada', '#22c55e', 'pi pi-check-circle', 40, true),
(NULL, 'Follow-up', 'follow-up', '#a855f7', 'pi pi-reply', 50, true)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- RLS: conversation_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
-- SELECT: system tags (tenant_id NULL) = todos; custom = membros ativos do tenant
DROP POLICY IF EXISTS "conv_tags: select" ON public.conversation_tags;
CREATE POLICY "conv_tags: select"
ON public.conversation_tags
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membros ativos do tenant criam custom. Nao podem criar system (tenant_id NULL)
DROP POLICY IF EXISTS "conv_tags: insert custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: insert custom"
ON public.conversation_tags
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: tenant members para tags proprias (custom). Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: update custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: update custom"
ON public.conversation_tags
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
)
WITH CHECK (is_system = false);
-- DELETE: tenant members removem tags custom. Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: delete custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: delete custom"
ON public.conversation_tags
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: conversation_thread_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_thread_tags: select" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: select"
ON public.conversation_thread_tags
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "conv_thread_tags: insert" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: insert"
ON public.conversation_thread_tags
FOR INSERT
TO authenticated
WITH CHECK (
tagged_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "conv_thread_tags: delete" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: delete"
ON public.conversation_thread_tags
FOR DELETE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,143 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Auto-reply fora do horario (CRM Grupo 2.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente manda mensagem fora do horario de atendimento, dispara
-- resposta automatica configuravel. Anti-spam via cooldown por thread.
--
-- Modos de horario:
-- - 'agenda' → usa agenda_regras_semanais dos membros do tenant
-- - 'business_hours' → janela semanal do tenant (clinica inteira)
-- - 'custom' → janela semanal especifica deste auto-reply
--
-- business_hours e custom_window usam mesma estrutura JSONB:
-- [{ "dow": 0-6, "start": "HH:MM", "end": "HH:MM" }, ...]
-- Multiplas entradas por dia permitidas (ex: 08:00-12:00 + 13:00-18:00)
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
message TEXT NOT NULL DEFAULT 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!'
CHECK (length(message) > 0 AND length(message) <= 2000),
cooldown_minutes INT NOT NULL DEFAULT 180
CHECK (cooldown_minutes >= 0 AND cooldown_minutes <= 43200), -- 0 min a 30 dias
schedule_mode TEXT NOT NULL DEFAULT 'agenda'
CHECK (schedule_mode IN ('agenda', 'business_hours', 'custom')),
-- Janela de funcionamento da clinica (reutilizavel por outras features)
-- Ex: [{ "dow": 1, "start": "08:00", "end": "18:00" }, ...]
business_hours JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Janela especifica deste auto-reply (quando schedule_mode = 'custom')
custom_window JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_conv_autoreply_settings_updated_at ON public.conversation_autoreply_settings;
CREATE TRIGGER trg_conv_autoreply_settings_updated_at
BEFORE UPDATE ON public.conversation_autoreply_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_autoreply_settings IS
'Configuracao por tenant do auto-reply fora do horario.';
-- ---------------------------------------------------------------------------
-- Log (anti-spam: uma resposta auto por thread por cooldown)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
message_id UUID -- referencia opcional pra message na tabela conversation_messages
);
CREATE INDEX IF NOT EXISTS idx_autoreply_log_cooldown
ON public.conversation_autoreply_log (tenant_id, thread_key, sent_at DESC);
COMMENT ON TABLE public.conversation_autoreply_log IS
'Log de auto-replies enviados. Usado pra respeitar cooldown por thread.';
-- ---------------------------------------------------------------------------
-- RLS: settings
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_settings: select" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: select"
ON public.conversation_autoreply_settings
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: insert" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: insert"
ON public.conversation_autoreply_settings
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: update" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: update"
ON public.conversation_autoreply_settings
FOR UPDATE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: log (read-only pra tenant members; escrita via service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_log: select" ON public.conversation_autoreply_log;
CREATE POLICY "autoreply_log: select"
ON public.conversation_autoreply_log
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_log.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Opt-out de conversas (CRM Grupo 5.2, LGPD)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente envia keyword de opt-out (PARAR, SAIR, CANCELAR, STOP...),
-- bloqueia envios automaticos (auto-reply + futuros lembretes).
--
-- LGPD: direito de oposicao (Art. 18, §2). Pedido de interrupcao deve ser
-- respeitado. Mensagens manuais do terapeuta nao sao bloqueadas — relacao
-- terapeutica existe.
--
-- Phone e normalizado (apenas digitos) pra matching consistente.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_optouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
phone TEXT NOT NULL CHECK (phone ~ '^\d{6,15}$'),
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
-- 'keyword' = detectado automaticamente por palavra-chave
-- 'manual' = adicionado manualmente pelo terapeuta/admin
source TEXT NOT NULL DEFAULT 'keyword'
CHECK (source IN ('keyword', 'manual')),
keyword_matched TEXT, -- palavra/frase que disparou (quando source='keyword')
original_message TEXT, -- texto completo da msg original (truncado)
notes TEXT, -- observacao do terapeuta (quando manual)
blocked_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- quem adicionou manual
opted_out_at TIMESTAMPTZ NOT NULL DEFAULT now(),
opted_back_in_at TIMESTAMPTZ, -- quando usuario restaurou (opt-in)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: um registro ativo por tenant+phone. Permite historico se fizer opt-in e depois opt-out de novo.
-- Active = opted_back_in_at IS NULL
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_optouts_active
ON public.conversation_optouts (tenant_id, phone)
WHERE opted_back_in_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_optouts_tenant_phone
ON public.conversation_optouts (tenant_id, phone);
CREATE INDEX IF NOT EXISTS idx_conv_optouts_patient
ON public.conversation_optouts (patient_id)
WHERE patient_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_conv_optouts_updated_at ON public.conversation_optouts;
CREATE TRIGGER trg_conv_optouts_updated_at
BEFORE UPDATE ON public.conversation_optouts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_optouts IS
'Numeros que pediram pra nao receber mensagens automaticas. LGPD Art. 18 Sec.2.';
-- ---------------------------------------------------------------------------
-- Keywords de opt-out — lista do tenant (reutilizavel)
-- Cada tenant pode customizar suas palavras-chave. Default aplicado via seed.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_optout_keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system (todos)
keyword TEXT NOT NULL CHECK (length(keyword) > 0 AND length(keyword) <= 100),
enabled BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_optout_kw_tenant
ON public.conversation_optout_keywords (tenant_id)
WHERE enabled = true;
-- Seed keywords padrao (system, tenant_id NULL, todos veem)
INSERT INTO public.conversation_optout_keywords (tenant_id, keyword, is_system, enabled) VALUES
(NULL, 'parar', true, true),
(NULL, 'sair', true, true),
(NULL, 'cancelar', true, true),
(NULL, 'stop', true, true),
(NULL, 'descadastrar', true, true),
(NULL, 'remover', true, true),
(NULL, 'nao quero mais', true, true),
(NULL, 'não quero mais', true, true),
(NULL, 'desinscrever', true, true),
(NULL, 'unsubscribe', true, true)
ON CONFLICT DO NOTHING;
COMMENT ON TABLE public.conversation_optout_keywords IS
'Palavras-chave que disparam opt-out quando paciente envia. Sistema (tenant_id NULL) + custom do tenant.';
-- ---------------------------------------------------------------------------
-- RLS: optouts
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optouts: select" ON public.conversation_optouts;
CREATE POLICY "optouts: select"
ON public.conversation_optouts
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: insert" ON public.conversation_optouts;
CREATE POLICY "optouts: insert"
ON public.conversation_optouts
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: update" ON public.conversation_optouts;
CREATE POLICY "optouts: update"
ON public.conversation_optouts
FOR UPDATE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: keywords
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optout_kw: select" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: select"
ON public.conversation_optout_keywords
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optout_kw: insert custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: insert custom"
ON public.conversation_optout_keywords
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: update/delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: update/delete custom"
ON public.conversation_optout_keywords
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: delete custom"
ON public.conversation_optout_keywords
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,152 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Lembretes automáticos de sessão (CRM Grupo 2.4)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Envia WhatsApp automatico antes das sessoes agendadas (24h e 2h antes).
-- Respeita opt-out (LGPD), quiet hours, canal ativo do tenant.
--
-- Arquitetura:
-- - pg_cron agenda edge function `send-session-reminders` a cada 15 min
-- - edge function busca eventos na janela de lembretes, envia + registra log
-- - UNIQUE (event_id, reminder_type) no log impede envio duplicado
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
-- Lead times (quais lembretes enviar)
send_24h BOOLEAN NOT NULL DEFAULT true,
send_2h BOOLEAN NOT NULL DEFAULT true,
-- Templates com variaveis: {{nome_paciente}}, {{data_sessao}}, {{hora_sessao}}, {{nome_clinica}}, {{modalidade}}
template_24h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!'
CHECK (length(template_24h) > 0 AND length(template_24h) <= 2000),
template_2h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊'
CHECK (length(template_2h) > 0 AND length(template_2h) <= 2000),
-- Quiet hours (não envia lembretes nessa janela, mesmo se a sessão estiver na janela)
-- Format: 'HH:MM'. Se start > end, janela atravessa a meia-noite (ex: 22:00 → 08:00).
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT true,
quiet_hours_start TIME NOT NULL DEFAULT '22:00',
quiet_hours_end TIME NOT NULL DEFAULT '08:00',
-- Respeita opt-out (LGPD)? default true, mas expomos pra caso haja tenant com regra especifica.
respect_opt_out BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_session_reminder_settings_updated_at ON public.session_reminder_settings;
CREATE TRIGGER trg_session_reminder_settings_updated_at
BEFORE UPDATE ON public.session_reminder_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.session_reminder_settings IS
'Configuracao por tenant dos lembretes automaticos de sessao via WhatsApp.';
-- ---------------------------------------------------------------------------
-- Log (anti-duplicata + auditoria)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_logs (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES public.agenda_eventos(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
reminder_type TEXT NOT NULL CHECK (reminder_type IN ('24h', '2h')),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
provider TEXT, -- 'evolution', 'twilio', 'skipped'
skip_reason TEXT, -- quando provider='skipped': opted_out, quiet_hours, no_phone, etc
to_phone TEXT,
provider_message_id TEXT,
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_session_reminder_event_type
ON public.session_reminder_logs (event_id, reminder_type);
CREATE INDEX IF NOT EXISTS idx_session_reminder_tenant_sent
ON public.session_reminder_logs (tenant_id, sent_at DESC);
COMMENT ON TABLE public.session_reminder_logs IS
'Log de lembretes disparados. UNIQUE (event_id, reminder_type) previne duplicata.';
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.session_reminder_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "reminder_settings: tenant members all" ON public.session_reminder_settings;
CREATE POLICY "reminder_settings: tenant members all"
ON public.session_reminder_settings
FOR ALL
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "reminder_logs: tenant members select" ON public.session_reminder_logs;
CREATE POLICY "reminder_logs: tenant members select"
ON public.session_reminder_logs
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_logs.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- pg_cron: agenda send-session-reminders a cada 15 minutos
-- ---------------------------------------------------------------------------
-- Uses pg_net.http_post to hit the edge function. O secret do service_role
-- deve estar configurado em app.settings.service_role_key (ou use Vault).
--
-- Como alternativa mais simples: o user pode configurar um cron externo
-- (ex: Supabase Dashboard → Database → Cron) apontando pra edge function.
-- Deixo o schedule abaixo comentado; descomentar em produção quando
-- app.settings.service_role_key e app.settings.supabase_url estiverem setados.
-- ---------------------------------------------------------------------------
-- SELECT cron.schedule(
-- 'session-reminders-every-15min',
-- '*/15 * * * *',
-- $$
-- SELECT net.http_post(
-- url := current_setting('app.settings.supabase_url') || '/functions/v1/send-session-reminders',
-- headers := jsonb_build_object(
-- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'),
-- 'Content-Type', 'application/json'
-- ),
-- body := '{}'::jsonb
-- );
-- $$
-- );
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,342 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Sistema de créditos WhatsApp (Marco B)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Modelo:
-- - whatsapp_credits_balance → saldo atual por tenant (snapshot)
-- - whatsapp_credits_transactions → extrato (purchase, usage, topup, adj)
-- - whatsapp_credit_packages → pacotes oferecidos (SaaS-managed)
-- - whatsapp_credit_purchases → ordens de compra via Asaas
--
-- Helpers (RPC):
-- - add_whatsapp_credits(tenant, amount, kind, ...) → novo saldo
-- - deduct_whatsapp_credits(tenant, amount, message_id) → boolean
--
-- Creditos so sao deduzidos quando o tenant usa provider='twilio'
-- (Evolution e free).
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Saldo por tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_balance (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
balance INT NOT NULL DEFAULT 0 CHECK (balance >= 0),
lifetime_purchased INT NOT NULL DEFAULT 0,
lifetime_used INT NOT NULL DEFAULT 0,
low_balance_threshold INT NOT NULL DEFAULT 20 CHECK (low_balance_threshold >= 0),
low_balance_alerted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credits_balance_updated_at ON public.whatsapp_credits_balance;
CREATE TRIGGER trg_wa_credits_balance_updated_at
BEFORE UPDATE ON public.whatsapp_credits_balance
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.whatsapp_credits_balance IS
'Saldo atual de creditos WhatsApp por tenant. 1 credito = 1 mensagem Twilio.';
-- ---------------------------------------------------------------------------
-- Extrato (transações)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('purchase', 'usage', 'topup_manual', 'refund', 'adjustment')),
amount INT NOT NULL, -- positivo = credito, negativo = debito
balance_after INT NOT NULL,
-- Referencias opcionais
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL,
purchase_id UUID,
admin_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_tenant_created
ON public.whatsapp_credits_transactions (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_kind
ON public.whatsapp_credits_transactions (tenant_id, kind, created_at DESC);
COMMENT ON TABLE public.whatsapp_credits_transactions IS
'Extrato de creditos WhatsApp. Append-only — nao editar/deletar.';
-- ---------------------------------------------------------------------------
-- Pacotes (global, gerenciado pelo SaaS admin)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 100),
description TEXT,
credits INT NOT NULL CHECK (credits > 0),
price_brl NUMERIC(10,2) NOT NULL CHECK (price_brl > 0),
is_active BOOLEAN NOT NULL DEFAULT true,
is_featured BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_packages_updated_at ON public.whatsapp_credit_packages;
CREATE TRIGGER trg_wa_credit_packages_updated_at
BEFORE UPDATE ON public.whatsapp_credit_packages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_packages_active
ON public.whatsapp_credit_packages (is_active, position, price_brl)
WHERE is_active = true;
COMMENT ON TABLE public.whatsapp_credit_packages IS
'Pacotes de creditos disponiveis pra compra. Gerenciado pelo SaaS admin.';
-- Seed: pacotes padrao
INSERT INTO public.whatsapp_credit_packages (name, description, credits, price_brl, is_featured, position) VALUES
('Iniciante', 'Ideal pra conhecer a plataforma', 100, 49.90, false, 10),
('Profissional', 'Mais vendido pra clínicas pequenas', 500, 199.90, true, 20),
('Clínica', 'Pra clínicas com alto volume', 1500, 499.90, false, 30),
('Enterprise', 'Pacote grande com desconto', 5000, 1499.90, false, 40)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Ordens de compra (Asaas integration)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
package_id UUID REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL,
-- Snapshot do pacote no momento da compra (caso mude de preço/creditos depois)
package_name TEXT NOT NULL,
credits INT NOT NULL CHECK (credits > 0),
amount_brl NUMERIC(10,2) NOT NULL CHECK (amount_brl > 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'refunded', 'cancelled')),
-- Asaas integration
asaas_customer_id TEXT,
asaas_payment_id TEXT,
asaas_payment_link TEXT,
asaas_pix_qrcode TEXT, -- base64 da imagem
asaas_pix_copy_paste TEXT, -- codigo PIX copia-cola
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_purchases_updated_at ON public.whatsapp_credit_purchases;
CREATE TRIGGER trg_wa_credit_purchases_updated_at
BEFORE UPDATE ON public.whatsapp_credit_purchases
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_tenant
ON public.whatsapp_credit_purchases (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_status
ON public.whatsapp_credit_purchases (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_asaas_payment
ON public.whatsapp_credit_purchases (asaas_payment_id)
WHERE asaas_payment_id IS NOT NULL;
-- FK pra transactions.purchase_id (circular, então define depois)
ALTER TABLE public.whatsapp_credits_transactions
DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_purchase_id_fkey;
ALTER TABLE public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey
FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL;
COMMENT ON TABLE public.whatsapp_credit_purchases IS
'Ordens de compra de creditos via Asaas. Webhook atualiza status.';
-- ---------------------------------------------------------------------------
-- RPC: add_whatsapp_credits (SECURITY DEFINER — atualiza saldo + registra tx)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.add_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_kind TEXT,
p_purchase_id UUID DEFAULT NULL,
p_admin_id UUID DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN
RAISE EXCEPTION 'invalid kind for credit: %', p_kind;
END IF;
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased)
VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
lifetime_purchased = whatsapp_credits_balance.lifetime_purchased
+ CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END,
low_balance_alerted_at = NULL -- reset alerta quando recebe creditos
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, purchase_id, admin_id, note)
VALUES
(p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RPC: deduct_whatsapp_credits (atomico, falha se saldo insuficiente)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.deduct_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_conversation_message_id BIGINT DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
v_row RECORD;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
-- Lock a linha e valida saldo
SELECT balance, low_balance_threshold
INTO v_row
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
IF v_row.balance < p_amount THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance - p_amount,
lifetime_used = lifetime_used + p_amount
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, conversation_message_id, note)
VALUES
(p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
-- Balance: members do tenant leem, saas_admin tudo
DROP POLICY IF EXISTS "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: select tenant"
ON public.whatsapp_credits_balance FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Settings update: members do tenant podem alterar low_balance_threshold
DROP POLICY IF EXISTS "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: update tenant"
ON public.whatsapp_credits_balance FOR UPDATE TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Transactions: read-only pra tenant members, write via RPC
DROP POLICY IF EXISTS "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions;
CREATE POLICY "wa_credits_tx: select tenant"
ON public.whatsapp_credits_transactions FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_transactions.tenant_id AND tm.status = 'active'
)
);
-- Packages: todos leem os ativos; saas_admin gerencia
DROP POLICY IF EXISTS "wa_packages: select active" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: select active"
ON public.whatsapp_credit_packages FOR SELECT TO authenticated
USING (is_active = true OR public.is_saas_admin());
DROP POLICY IF EXISTS "wa_packages: manage saas admin" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: manage saas admin"
ON public.whatsapp_credit_packages FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- Purchases: members do tenant leem as proprias
DROP POLICY IF EXISTS "wa_purchases: select tenant" ON public.whatsapp_credit_purchases;
CREATE POLICY "wa_purchases: select tenant"
ON public.whatsapp_credit_purchases FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credit_purchases.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,356 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Telefones polimorficos com tipo + principal
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Substitui campos fixos de telefone (patients.telefone, medicos.telefone_*)
-- por estrutura flexivel:
--
-- - contact_types → tipos configuraveis (Celular, Fixo, WhatsApp, ...)
-- System (tenant_id NULL) + custom por tenant
-- - contact_phones → telefones polimorficos (entity_type + entity_id)
-- Suporta patient, medico, futuramente emergency, etc
--
-- Ate 1 telefone marcado como is_primary por entidade (UNIQUE parcial).
-- Triggers mantem patients.telefone, telefone_alternativo, medicos.telefone_*
-- sincronizados pra nao quebrar codigo legado.
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: contact_types
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
icon TEXT, -- classe primeicons (ex: 'pi pi-mobile')
is_mobile BOOLEAN NOT NULL DEFAULT true, -- true = mascara celular; false = mascara fixo
is_system BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_types_tenant_slug
ON public.contact_types (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_types_system_slug
ON public.contact_types (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_contact_types_tenant
ON public.contact_types (tenant_id, position);
DROP TRIGGER IF EXISTS trg_contact_types_updated_at ON public.contact_types;
CREATE TRIGGER trg_contact_types_updated_at
BEFORE UPDATE ON public.contact_types
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_types IS
'Tipos de contato (Celular, Fixo, WhatsApp, ...). System (tenant_id NULL) visiveis a todos; custom por tenant.';
-- Seed: tipos system padrao
INSERT INTO public.contact_types (tenant_id, name, slug, icon, is_mobile, is_system, position) VALUES
(NULL, 'Celular', 'celular', 'pi pi-mobile', true, true, 10),
(NULL, 'WhatsApp', 'whatsapp', 'pi pi-whatsapp', true, true, 20),
(NULL, 'Fixo', 'fixo', 'pi pi-phone', false, true, 30),
(NULL, 'Residencial', 'residencial', 'pi pi-home', false, true, 40),
(NULL, 'Comercial', 'comercial', 'pi pi-building', true, true, 50),
(NULL, 'Fax', 'fax', 'pi pi-print', false, true, 60)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Tabela: contact_phones (polimorfica)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_phones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('patient', 'medico')),
entity_id UUID NOT NULL,
contact_type_id UUID NOT NULL REFERENCES public.contact_types(id) ON DELETE RESTRICT,
number TEXT NOT NULL CHECK (number ~ '^\d{8,15}$'), -- digits only, 8-15 (DDI+DDD+num)
is_primary BOOLEAN NOT NULL DEFAULT false,
-- Vinculado automaticamente via drawer de conversa (CRM 3.5)
whatsapp_linked_at TIMESTAMPTZ,
notes TEXT,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contact_phones_entity
ON public.contact_phones (tenant_id, entity_type, entity_id, position);
CREATE INDEX IF NOT EXISTS idx_contact_phones_number
ON public.contact_phones (tenant_id, number);
-- Partial unique: apenas 1 primary por entidade
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_phones_primary
ON public.contact_phones (entity_type, entity_id)
WHERE is_primary = true;
DROP TRIGGER IF EXISTS trg_contact_phones_updated_at ON public.contact_phones;
CREATE TRIGGER trg_contact_phones_updated_at
BEFORE UPDATE ON public.contact_phones
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_phones IS
'Telefones polimorficos (patients, medicos, ...). Max 1 primary por entidade. Triggers sincronizam campos legados.';
-- ---------------------------------------------------------------------------
-- Helper: pega o telefone primary (ou primeiro) de uma entidade
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(
p_entity_type TEXT,
p_entity_id UUID
) RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT number FROM public.contact_phones
WHERE entity_type = p_entity_type
AND entity_id = p_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION public.get_entity_primary_phone(TEXT, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_entity_primary_phone(TEXT, UUID) TO authenticated, service_role;
-- ---------------------------------------------------------------------------
-- Trigger: sincroniza campos legados de patients/medicos apos mudanca
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields() RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
v_whatsapp_slug TEXT;
v_whatsapp TEXT;
BEGIN
-- Identifica entidade afetada (pode ser OLD em delete)
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
-- Pega primary (ou primeiro)
SELECT number INTO v_primary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
-- Pega segundo (depois do primary)
SELECT number INTO v_secondary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
-- Sincroniza campos legados
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET telefone = v_primary,
telefone_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
-- Medicos: telefone_profissional = primary; telefone_pessoal = secundario
UPDATE public.medicos
SET telefone_profissional = v_primary,
telefone_pessoal = v_secondary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
DROP TRIGGER IF EXISTS trg_contact_phones_sync_legacy ON public.contact_phones;
CREATE TRIGGER trg_contact_phones_sync_legacy
AFTER INSERT OR UPDATE OR DELETE ON public.contact_phones
FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields();
-- ---------------------------------------------------------------------------
-- Backfill: migra dados existentes pra contact_phones
-- ---------------------------------------------------------------------------
-- Patients: telefone → Celular primary, telefone_alternativo → Fixo
DO $$
DECLARE
v_celular_id UUID;
v_fixo_id UUID;
v_profissional_id UUID;
BEGIN
SELECT id INTO v_celular_id FROM public.contact_types WHERE slug = 'celular' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_fixo_id FROM public.contact_types WHERE slug = 'fixo' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_profissional_id FROM public.contact_types WHERE slug = 'comercial' AND tenant_id IS NULL LIMIT 1;
-- Patients.telefone → Celular primary
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
p.tenant_id,
'patient',
p.id,
v_celular_id,
regexp_replace(p.telefone, '\D', '', 'g'),
true,
10
FROM public.patients p
WHERE p.telefone IS NOT NULL
AND length(regexp_replace(p.telefone, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient' AND cp.entity_id = p.id
)
ON CONFLICT DO NOTHING;
-- Patients.telefone_alternativo → Fixo
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
p.tenant_id,
'patient',
p.id,
v_fixo_id,
regexp_replace(p.telefone_alternativo, '\D', '', 'g'),
false,
20
FROM public.patients p
WHERE p.telefone_alternativo IS NOT NULL
AND length(regexp_replace(p.telefone_alternativo, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient' AND cp.entity_id = p.id
AND cp.number = regexp_replace(p.telefone_alternativo, '\D', '', 'g')
)
ON CONFLICT DO NOTHING;
-- Medicos.telefone_profissional → Comercial primary
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
m.tenant_id,
'medico',
m.id,
v_profissional_id,
regexp_replace(m.telefone_profissional, '\D', '', 'g'),
true,
10
FROM public.medicos m
WHERE m.telefone_profissional IS NOT NULL
AND length(regexp_replace(m.telefone_profissional, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'medico' AND cp.entity_id = m.id
)
ON CONFLICT DO NOTHING;
-- Medicos.telefone_pessoal → Celular
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
m.tenant_id,
'medico',
m.id,
v_celular_id,
regexp_replace(m.telefone_pessoal, '\D', '', 'g'),
false,
20
FROM public.medicos m
WHERE m.telefone_pessoal IS NOT NULL
AND length(regexp_replace(m.telefone_pessoal, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'medico' AND cp.entity_id = m.id
AND cp.number = regexp_replace(m.telefone_pessoal, '\D', '', 'g')
)
ON CONFLICT DO NOTHING;
END $$;
-- ---------------------------------------------------------------------------
-- RLS: contact_types
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_types: select" ON public.contact_types;
CREATE POLICY "contact_types: select"
ON public.contact_types FOR SELECT TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "contact_types: manage custom" ON public.contact_types;
CREATE POLICY "contact_types: manage custom"
ON public.contact_types FOR ALL TO authenticated
USING (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
)
)
WITH CHECK (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: contact_phones
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_phones: all tenant" ON public.contact_phones;
CREATE POLICY "contact_phones: all tenant"
ON public.contact_phones FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_phones.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_phones.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,64 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Retroativa WhatsApp-linked em contact_phones
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Pacientes que foram vinculados via drawer de conversa ANTES do refactor
-- polimorfico de telefones tiveram o numero preservado em patients.telefone
-- mas sem marca "vinculado WhatsApp" (nao existia o conceito).
--
-- Este script:
-- 1. Detecta pacientes com conversation_messages (direction=inbound)
-- 2. Encontra o contact_phone que match o numero da conversa
-- 3. Muda o contact_type_id pra 'whatsapp' + seta whatsapp_linked_at
-- pra first_message_received_at
--
-- Nao destrutivo: so altera phones que claramente foram vinculados via CRM.
-- Se paciente tem multiplos phones, apenas o que match o numero da conversa
-- e afetado.
-- ==========================================================================
DO $$
DECLARE
v_whatsapp_type_id UUID;
BEGIN
SELECT id INTO v_whatsapp_type_id
FROM public.contact_types
WHERE slug = 'whatsapp' AND tenant_id IS NULL
LIMIT 1;
IF v_whatsapp_type_id IS NULL THEN
RAISE NOTICE 'Contact type WhatsApp nao encontrado — pule a migration retroativa';
RETURN;
END IF;
-- Atualiza contact_phones que match conversation_messages inbound
WITH convs AS (
SELECT
cm.patient_id,
regexp_replace(cm.from_number, '\D', '', 'g') AS phone_digits,
MIN(COALESCE(cm.received_at, cm.created_at)) AS first_msg_at
FROM public.conversation_messages cm
WHERE cm.patient_id IS NOT NULL
AND cm.direction = 'inbound'
AND cm.from_number IS NOT NULL
AND cm.channel = 'whatsapp'
GROUP BY cm.patient_id, regexp_replace(cm.from_number, '\D', '', 'g')
)
UPDATE public.contact_phones cp
SET
contact_type_id = v_whatsapp_type_id,
whatsapp_linked_at = COALESCE(cp.whatsapp_linked_at, convs.first_msg_at)
FROM convs
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits;
-- Log
RAISE NOTICE 'Retroactive WhatsApp link complete';
END $$;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,95 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Retroativa WhatsApp-linked v2
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Complementa a migration 20260421000009_retroactive_whatsapp_link:
--
-- Pacientes vinculados via drawer antes do refactor ficaram sem a info
-- de "vinculado WhatsApp". Algumas pecularidades:
--
-- 1. Mensagens podem ser outbound (to_number) ou inbound (from_number)
-- 2. O numero da conversa pode NAO existir ainda em contact_phones
-- (paciente tinha outro telefone cadastrado, CRM WhatsApp linkou um numero diferente)
--
-- Estrategia:
-- - Pra cada paciente com conversa no channel 'whatsapp', coleta TODOS
-- os numeros unicos (from_number + to_number conforme direction)
-- - Pra cada numero:
-- - Se paciente JA tem um contact_phone com esse numero → update pra WhatsApp + linked
-- - Se NAO tem → insere novo com type='whatsapp' + linked
-- ==========================================================================
DO $$
DECLARE
v_whatsapp_type_id UUID;
BEGIN
SELECT id INTO v_whatsapp_type_id
FROM public.contact_types
WHERE slug = 'whatsapp' AND tenant_id IS NULL
LIMIT 1;
IF v_whatsapp_type_id IS NULL THEN
RAISE NOTICE 'Contact type WhatsApp nao encontrado';
RETURN;
END IF;
-- CTE: extrai (patient_id, phone_digits, first_msg_at) de conversation_messages
WITH convs AS (
SELECT
cm.tenant_id,
cm.patient_id,
CASE
WHEN cm.direction = 'inbound' THEN regexp_replace(cm.from_number, '\D', '', 'g')
WHEN cm.direction = 'outbound' THEN regexp_replace(cm.to_number, '\D', '', 'g')
END AS phone_digits,
MIN(COALESCE(cm.received_at, cm.responded_at, cm.created_at)) AS first_msg_at
FROM public.conversation_messages cm
WHERE cm.patient_id IS NOT NULL
AND cm.channel = 'whatsapp'
AND (cm.from_number IS NOT NULL OR cm.to_number IS NOT NULL)
GROUP BY cm.tenant_id, cm.patient_id, 3
HAVING CASE
WHEN cm.direction = 'inbound' THEN regexp_replace(cm.from_number, '\D', '', 'g')
WHEN cm.direction = 'outbound' THEN regexp_replace(cm.to_number, '\D', '', 'g')
END IS NOT NULL
),
-- Atualiza phones que ja existem
updated AS (
UPDATE public.contact_phones cp
SET
contact_type_id = v_whatsapp_type_id,
whatsapp_linked_at = COALESCE(cp.whatsapp_linked_at, convs.first_msg_at)
FROM convs
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits
RETURNING cp.entity_id, cp.number
)
-- Insere phones que nao existem ainda pro paciente
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, whatsapp_linked_at, position)
SELECT
convs.tenant_id,
'patient',
convs.patient_id,
v_whatsapp_type_id,
convs.phone_digits,
false, -- nao e primary (paciente ja tem outro)
convs.first_msg_at,
100 -- final da lista
FROM convs
WHERE NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits
)
AND length(convs.phone_digits) BETWEEN 8 AND 15;
RAISE NOTICE 'Retroactive WhatsApp link v2 complete';
END $$;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,266 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Emails polimorficos com tipo + principal
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Mesmo padrao dos telefones (migration 20260421000008):
-- - contact_email_types → tipos configuraveis (Principal, Comercial, Pessoal, ...)
-- - contact_emails → emails polimorficos (entity_type + entity_id)
--
-- Triggers mantem patients.email_principal/email_alternativo e medicos.email
-- sincronizados pra nao quebrar codigo legado.
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: contact_email_types
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_email_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
icon TEXT,
is_system BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_email_types_tenant_slug
ON public.contact_email_types (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_email_types_system_slug
ON public.contact_email_types (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_contact_email_types_tenant
ON public.contact_email_types (tenant_id, position);
DROP TRIGGER IF EXISTS trg_contact_email_types_updated_at ON public.contact_email_types;
CREATE TRIGGER trg_contact_email_types_updated_at
BEFORE UPDATE ON public.contact_email_types
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_email_types IS
'Tipos de email (Principal, Comercial, Pessoal, ...). System (tenant_id NULL) + custom.';
-- Seed
INSERT INTO public.contact_email_types (tenant_id, name, slug, icon, is_system, position) VALUES
(NULL, 'Principal', 'principal', 'pi pi-envelope', true, 10),
(NULL, 'Pessoal', 'pessoal', 'pi pi-user', true, 20),
(NULL, 'Comercial', 'comercial', 'pi pi-building', true, 30),
(NULL, 'Faturamento', 'faturamento', 'pi pi-dollar', true, 40),
(NULL, 'Alternativo', 'alternativo', 'pi pi-reply', true, 50)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Tabela: contact_emails (polimorfica)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_emails (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('patient', 'medico')),
entity_id UUID NOT NULL,
contact_email_type_id UUID NOT NULL REFERENCES public.contact_email_types(id) ON DELETE RESTRICT,
email TEXT NOT NULL CHECK (email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'),
is_primary BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contact_emails_entity
ON public.contact_emails (tenant_id, entity_type, entity_id, position);
CREATE INDEX IF NOT EXISTS idx_contact_emails_email
ON public.contact_emails (tenant_id, email);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_emails_primary
ON public.contact_emails (entity_type, entity_id)
WHERE is_primary = true;
DROP TRIGGER IF EXISTS trg_contact_emails_updated_at ON public.contact_emails;
CREATE TRIGGER trg_contact_emails_updated_at
BEFORE UPDATE ON public.contact_emails
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_emails IS
'Emails polimorficos (patients, medicos, ...). Max 1 primary por entidade. Triggers sincronizam campos legados.';
-- ---------------------------------------------------------------------------
-- Trigger: sincroniza campos legados apos mudanca
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields() RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
SELECT email INTO v_primary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
SELECT email INTO v_secondary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET email_principal = v_primary,
email_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE public.medicos
SET email = v_primary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END;
$$;
DROP TRIGGER IF EXISTS trg_contact_emails_sync_legacy ON public.contact_emails;
CREATE TRIGGER trg_contact_emails_sync_legacy
AFTER INSERT OR UPDATE OR DELETE ON public.contact_emails
FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields();
-- ---------------------------------------------------------------------------
-- Backfill: migra emails existentes
-- ---------------------------------------------------------------------------
DO $$
DECLARE
v_principal_id UUID;
v_alternativo_id UUID;
BEGIN
SELECT id INTO v_principal_id FROM public.contact_email_types WHERE slug = 'principal' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_alternativo_id FROM public.contact_email_types WHERE slug = 'alternativo' AND tenant_id IS NULL LIMIT 1;
-- Patients.email_principal → Principal primary
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
p.tenant_id, 'patient', p.id, v_principal_id, lower(trim(p.email_principal)), true, 10
FROM public.patients p
WHERE p.email_principal IS NOT NULL
AND trim(p.email_principal) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'patient' AND ce.entity_id = p.id
)
ON CONFLICT DO NOTHING;
-- Patients.email_alternativo → Alternativo
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
p.tenant_id, 'patient', p.id, v_alternativo_id, lower(trim(p.email_alternativo)), false, 20
FROM public.patients p
WHERE p.email_alternativo IS NOT NULL
AND trim(p.email_alternativo) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'patient' AND ce.entity_id = p.id
AND ce.email = lower(trim(p.email_alternativo))
)
ON CONFLICT DO NOTHING;
-- Medicos.email → Principal primary
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
m.tenant_id, 'medico', m.id, v_principal_id, lower(trim(m.email)), true, 10
FROM public.medicos m
WHERE m.email IS NOT NULL
AND trim(m.email) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'medico' AND ce.entity_id = m.id
)
ON CONFLICT DO NOTHING;
END $$;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_email_types: select" ON public.contact_email_types;
CREATE POLICY "contact_email_types: select"
ON public.contact_email_types FOR SELECT TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "contact_email_types: manage custom" ON public.contact_email_types;
CREATE POLICY "contact_email_types: manage custom"
ON public.contact_email_types FOR ALL TO authenticated
USING (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
)
)
WITH CHECK (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "contact_emails: all tenant" ON public.contact_emails;
CREATE POLICY "contact_emails: all tenant"
ON public.contact_emails FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_emails.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_emails.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,203 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Atribuicao de conversa a terapeuta (CRM Grupo 3.2)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Uma linha por (tenant_id, thread_key) — UPSERT em cada reatribuicao.
-- Historico (quem atribuiu pra quem e quando) pode ser adicionado depois
-- via trigger INSERT em conversation_assignment_history se virar requisito.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - SELECT: qualquer membro ativo do tenant
-- - INSERT/UPDATE: qualquer membro ativo do tenant (self-assign ou delegar)
-- - DELETE: nao permitido (unassign = UPDATE assigned_to=NULL)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_assignments (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key)
);
CREATE INDEX IF NOT EXISTS idx_conv_assign_tenant_user
ON public.conversation_assignments (tenant_id, assigned_to)
WHERE assigned_to IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_assign_patient
ON public.conversation_assignments (patient_id)
WHERE patient_id IS NOT NULL;
-- Trigger de updated_at
DROP TRIGGER IF EXISTS trg_conv_assign_updated_at ON public.conversation_assignments;
CREATE TRIGGER trg_conv_assign_updated_at
BEFORE UPDATE ON public.conversation_assignments
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_assignments IS
'Atribuicao de threads de conversa a membros do tenant. Uma linha por (tenant_id, thread_key). assigned_to=NULL significa nao atribuida.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_assignments ENABLE ROW LEVEL SECURITY;
-- SELECT: qualquer membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_assign: select tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: select tenant"
ON public.conversation_assignments
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membro ativo do tenant. assigned_by deve ser o proprio user.
-- assigned_to deve ser membro ativo do mesmo tenant (ou NULL).
DROP POLICY IF EXISTS "conv_assign: insert tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: insert tenant"
ON public.conversation_assignments
FOR INSERT
TO authenticated
WITH CHECK (
assigned_by = auth.uid()
AND EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
AND (
assigned_to IS NULL
OR EXISTS (
SELECT 1 FROM public.tenant_members tm2
WHERE tm2.user_id = conversation_assignments.assigned_to
AND tm2.tenant_id = conversation_assignments.tenant_id
AND tm2.status = 'active'
)
)
);
-- UPDATE: membro ativo do tenant pode reatribuir. Mesma validacao pra assigned_to.
DROP POLICY IF EXISTS "conv_assign: update tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: update tenant"
ON public.conversation_assignments
FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
assigned_by = auth.uid()
AND (
assigned_to IS NULL
OR EXISTS (
SELECT 1 FROM public.tenant_members tm2
WHERE tm2.user_id = conversation_assignments.assigned_to
AND tm2.tenant_id = conversation_assignments.tenant_id
AND tm2.status = 'active'
)
)
);
-- DELETE: bloqueado (unassign = UPDATE assigned_to=NULL)
-- --------------------------------------------------------------------------
-- Atualiza view conversation_threads pra incluir assignment
-- --------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown'
)) AS thread_key
FROM public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status,
ca.assigned_to,
ca.assigned_at
FROM latest l
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
LEFT JOIN public.patients p ON p.id = l.patient_id
LEFT JOIN public.conversation_assignments ca
ON ca.tenant_id = l.tenant_id AND ca.thread_key = l.thread_key;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou numero anonimo. Base do Kanban. Inclui assignment atual do thread.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,31 @@
-- ==========================================================================
-- Agencia PSI — Migracao: CPF/CNPJ do tenant (fiscal / Asaas)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Adiciona coluna `cpf_cnpj` em tenants para suportar geracao de cobrancas
-- Asaas (ex: credits WhatsApp via PIX) e, no futuro, notas fiscais do SaaS.
-- Armazena apenas digitos (11 pra CPF, 14 pra CNPJ). Frontend formata.
--
-- Permissoes: admin do tenant (tenant_admin) pode ler/atualizar via RLS
-- existente em tenants. Nao adiciona policy extra aqui.
-- ==========================================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cpf_cnpj TEXT;
-- Permite apenas digitos; comprimento 11 (CPF) ou 14 (CNPJ); NULL tambem ok.
ALTER TABLE public.tenants
DROP CONSTRAINT IF EXISTS tenants_cpf_cnpj_format;
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_cpf_cnpj_format
CHECK (cpf_cnpj IS NULL OR cpf_cnpj ~ '^[0-9]{11}$' OR cpf_cnpj ~ '^[0-9]{14}$');
COMMENT ON COLUMN public.tenants.cpf_cnpj IS
'CPF (11 digitos) ou CNPJ (14 digitos) do titular do tenant. Usado por gateways de pagamento (Asaas). Apenas digitos.';
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
-- Extensions
-- Gerado automaticamente em 2026-04-17T12:23:04.148Z
-- Gerado automaticamente em 2026-04-21T23:16:33.041Z
-- Total: 10
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
-- Functions: auth
-- Gerado automaticamente em 2026-04-17T12:23:05.221Z
-- Gerado automaticamente em 2026-04-21T23:16:34.941Z
-- Total: 4
CREATE FUNCTION auth.email() RETURNS text
@@ -1,5 +1,5 @@
-- Functions: extensions
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
-- Gerado automaticamente em 2026-04-21T23:16:34.942Z
-- Total: 6
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
@@ -1,5 +1,5 @@
-- Functions: pgbouncer
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
-- Gerado automaticamente em 2026-04-21T23:16:34.943Z
-- Total: 1
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
-- Functions: realtime
-- Gerado automaticamente em 2026-04-17T12:23:05.223Z
-- Gerado automaticamente em 2026-04-21T23:16:34.949Z
-- Total: 12
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
@@ -1,5 +1,5 @@
-- Functions: storage
-- Gerado automaticamente em 2026-04-17T12:23:05.224Z
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Total: 15
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
@@ -1,5 +1,5 @@
-- Functions: supabase_functions
-- Gerado automaticamente em 2026-04-17T12:23:05.224Z
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Total: 1
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
@@ -1,6 +1,6 @@
-- Tables: Addons / Créditos
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Total: 3
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 7
CREATE TABLE public.addon_credits (
id uuid DEFAULT gen_random_uuid() NOT NULL,
@@ -22,7 +22,10 @@ CREATE TABLE public.addon_credits (
expires_at timestamp with time zone,
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT addon_credits_balance_nonneg_chk CHECK ((balance >= 0)),
CONSTRAINT addon_credits_consumed_nonneg_chk CHECK ((total_consumed >= 0)),
CONSTRAINT addon_credits_purchased_nonneg_chk CHECK ((total_purchased >= 0))
);
CREATE TABLE public.addon_products (
@@ -64,3 +67,70 @@ CREATE TABLE public.addon_transactions (
created_at timestamp with time zone DEFAULT now(),
metadata jsonb DEFAULT '{}'::jsonb
);
CREATE TABLE public.whatsapp_credit_packages (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
description text,
credits integer NOT NULL,
price_brl numeric(10,2) NOT NULL,
is_active boolean DEFAULT true NOT NULL,
is_featured boolean DEFAULT false NOT NULL,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credit_packages_credits_check CHECK ((credits > 0)),
CONSTRAINT whatsapp_credit_packages_name_check CHECK (((length(name) > 0) AND (length(name) <= 100))),
CONSTRAINT whatsapp_credit_packages_price_brl_check CHECK ((price_brl > (0)::numeric))
);
CREATE TABLE public.whatsapp_credit_purchases (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
package_id uuid,
package_name text NOT NULL,
credits integer NOT NULL,
amount_brl numeric(10,2) NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
asaas_customer_id text,
asaas_payment_id text,
asaas_payment_link text,
asaas_pix_qrcode text,
asaas_pix_copy_paste text,
paid_at timestamp with time zone,
expires_at timestamp with time zone,
failed_at timestamp with time zone,
created_by uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credit_purchases_amount_brl_check CHECK ((amount_brl > (0)::numeric)),
CONSTRAINT whatsapp_credit_purchases_credits_check CHECK ((credits > 0)),
CONSTRAINT whatsapp_credit_purchases_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'failed'::text, 'expired'::text, 'refunded'::text, 'cancelled'::text])))
);
CREATE TABLE public.whatsapp_credits_balance (
tenant_id uuid NOT NULL,
balance integer DEFAULT 0 NOT NULL,
lifetime_purchased integer DEFAULT 0 NOT NULL,
lifetime_used integer DEFAULT 0 NOT NULL,
low_balance_threshold integer DEFAULT 20 NOT NULL,
low_balance_alerted_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 whatsapp_credits_balance_balance_check CHECK ((balance >= 0)),
CONSTRAINT whatsapp_credits_balance_low_balance_threshold_check CHECK ((low_balance_threshold >= 0))
);
CREATE TABLE public.whatsapp_credits_transactions (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
kind text NOT NULL,
amount integer NOT NULL,
balance_after integer NOT NULL,
conversation_message_id bigint,
purchase_id uuid,
admin_id uuid,
note text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credits_transactions_kind_check CHECK ((kind = ANY (ARRAY['purchase'::text, 'usage'::text, 'topup_manual'::text, 'refund'::text, 'adjustment'::text])))
);
@@ -1,5 +1,5 @@
-- Tables: Agenda / Agendamento
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 10
CREATE TABLE public.agenda_bloqueios (
@@ -1,5 +1,5 @@
-- Tables: Central SaaS (docs/FAQ)
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
-- Total: 4
CREATE TABLE public.saas_doc_votos (
@@ -1,7 +1,36 @@
-- Tables: Comunicação / Notificações
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 14
CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL,
queue_id uuid,
agenda_evento_id uuid,
patient_id uuid NOT NULL,
channel text NOT NULL,
template_key text NOT NULL,
schedule_key text,
recipient_address text NOT NULL,
resolved_message text,
resolved_vars jsonb,
status text NOT NULL,
provider text,
provider_message_id text,
provider_status text,
provider_response jsonb,
sent_at timestamp with time zone,
delivered_at timestamp with time zone,
read_at timestamp with time zone,
failed_at timestamp with time zone,
failure_reason text,
estimated_cost_brl numeric(8,4) DEFAULT 0,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notification_logs_status_check CHECK ((status = ANY (ARRAY['sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'bounced'::text, 'opted_out'::text])))
);
CREATE TABLE public.email_layout_config (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
@@ -123,35 +152,6 @@ CREATE TABLE public.notification_channels (
CONSTRAINT notification_channels_provider_check CHECK ((provider = ANY (ARRAY['evolution_api'::text, 'meta_official'::text, 'twilio'::text, 'zenvia'::text, 'sendgrid'::text, 'resend'::text, 'smtp'::text, 'zapi'::text])))
);
CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL,
queue_id uuid,
agenda_evento_id uuid,
patient_id uuid NOT NULL,
channel text NOT NULL,
template_key text NOT NULL,
schedule_key text,
recipient_address text NOT NULL,
resolved_message text,
resolved_vars jsonb,
status text NOT NULL,
provider text,
provider_message_id text,
provider_status text,
provider_response jsonb,
sent_at timestamp with time zone,
delivered_at timestamp with time zone,
read_at timestamp with time zone,
failed_at timestamp with time zone,
failure_reason text,
estimated_cost_brl numeric(8,4) DEFAULT 0,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notification_logs_status_check CHECK ((status = ANY (ARRAY['sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'bounced'::text, 'opted_out'::text])))
);
CREATE TABLE public.notification_preferences (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
@@ -260,7 +260,7 @@ CREATE TABLE public.notifications (
read_at timestamp with time zone,
archived boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text])))
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text, 'inbound_message'::text])))
);
CREATE TABLE public.twilio_subaccount_usage (
@@ -0,0 +1,155 @@
-- Tables: CRM Conversas (WhatsApp)
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 10
CREATE TABLE public.conversation_autoreply_log (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
sent_at timestamp with time zone DEFAULT now() NOT NULL,
message_id uuid
);
CREATE TABLE public.conversation_autoreply_settings (
tenant_id uuid NOT NULL,
enabled boolean DEFAULT false NOT NULL,
message text DEFAULT 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!'::text NOT NULL,
cooldown_minutes integer DEFAULT 180 NOT NULL,
schedule_mode text DEFAULT 'agenda'::text NOT NULL,
business_hours jsonb DEFAULT '[]'::jsonb NOT NULL,
custom_window jsonb DEFAULT '[]'::jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_autoreply_settings_cooldown_minutes_check CHECK (((cooldown_minutes >= 0) AND (cooldown_minutes <= 43200))),
CONSTRAINT conversation_autoreply_settings_message_check CHECK (((length(message) > 0) AND (length(message) <= 2000))),
CONSTRAINT conversation_autoreply_settings_schedule_mode_check CHECK ((schedule_mode = ANY (ARRAY['agenda'::text, 'business_hours'::text, 'custom'::text])))
);
CREATE TABLE public.conversation_messages (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
patient_id uuid,
channel text NOT NULL,
direction text NOT NULL,
from_number text,
to_number text,
body text,
media_url text,
media_mime text,
provider text NOT NULL,
provider_message_id text,
provider_raw jsonb,
kanban_status text DEFAULT 'awaiting_us'::text NOT NULL,
priority integer DEFAULT 0 NOT NULL,
read_at timestamp with time zone,
responded_at timestamp with time zone,
resolved_at timestamp with time zone,
received_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,
delivered_at timestamp with time zone,
read_by_recipient_at timestamp with time zone,
delivery_status text,
CONSTRAINT conversation_messages_channel_check CHECK ((channel = ANY (ARRAY['whatsapp'::text, 'sms'::text, 'email'::text]))),
CONSTRAINT conversation_messages_delivery_status_check CHECK (((delivery_status IS NULL) OR (delivery_status = ANY (ARRAY['pending'::text, 'sent'::text, 'delivered'::text, 'read'::text, 'failed'::text])))),
CONSTRAINT conversation_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
CONSTRAINT conversation_messages_kanban_status_check CHECK ((kanban_status = ANY (ARRAY['urgent'::text, 'awaiting_us'::text, 'awaiting_patient'::text, 'resolved'::text]))),
CONSTRAINT conversation_messages_provider_check CHECK ((provider = ANY (ARRAY['twilio'::text, 'evolution'::text, 'manual'::text])))
);
CREATE TABLE public.conversation_notes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
patient_id uuid,
contact_number text,
body text NOT NULL,
created_by uuid NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT conversation_notes_body_check CHECK (((length(body) > 0) AND (length(body) <= 4000)))
);
CREATE TABLE public.conversation_optout_keywords (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
keyword text NOT NULL,
enabled boolean DEFAULT true NOT NULL,
is_system boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_optout_keywords_keyword_check CHECK (((length(keyword) > 0) AND (length(keyword) <= 100)))
);
CREATE TABLE public.conversation_optouts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
phone text NOT NULL,
patient_id uuid,
source text DEFAULT 'keyword'::text NOT NULL,
keyword_matched text,
original_message text,
notes text,
blocked_by uuid,
opted_out_at timestamp with time zone DEFAULT now() NOT NULL,
opted_back_in_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_optouts_phone_check CHECK ((phone ~ '^\d{6,15}$'::text)),
CONSTRAINT conversation_optouts_source_check CHECK ((source = ANY (ARRAY['keyword'::text, 'manual'::text])))
);
CREATE TABLE public.conversation_tags (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
name text NOT NULL,
slug text NOT NULL,
color text DEFAULT '#6366f1'::text NOT NULL,
icon text,
"position" integer DEFAULT 100 NOT NULL,
is_system 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_tags_color_check CHECK ((color ~ '^#[0-9a-fA-F]{6}$'::text)),
CONSTRAINT conversation_tags_name_check CHECK (((length(name) > 0) AND (length(name) <= 40))),
CONSTRAINT conversation_tags_slug_check CHECK ((slug ~ '^[a-z0-9_-]{1,40}$'::text))
);
CREATE TABLE public.conversation_thread_tags (
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
tag_id uuid NOT NULL,
tagged_by uuid,
tagged_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.session_reminder_logs (
id bigint NOT NULL,
event_id uuid NOT NULL,
tenant_id uuid NOT NULL,
reminder_type text NOT NULL,
sent_at timestamp with time zone DEFAULT now() NOT NULL,
provider text,
skip_reason text,
to_phone text,
provider_message_id text,
conversation_message_id bigint,
CONSTRAINT session_reminder_logs_reminder_type_check CHECK ((reminder_type = ANY (ARRAY['24h'::text, '2h'::text])))
);
CREATE TABLE public.session_reminder_settings (
tenant_id uuid NOT NULL,
enabled boolean DEFAULT false NOT NULL,
send_24h boolean DEFAULT true NOT NULL,
send_2h boolean DEFAULT true NOT NULL,
template_24h text DEFAULT 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!'::text NOT NULL,
template_2h text DEFAULT 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊'::text NOT NULL,
quiet_hours_enabled boolean DEFAULT true NOT NULL,
quiet_hours_start time without time zone DEFAULT '22:00:00'::time without time zone NOT NULL,
quiet_hours_end time without time zone DEFAULT '08:00:00'::time without time zone NOT NULL,
respect_opt_out 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 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)))
);
@@ -1,5 +1,5 @@
-- Tables: Documentos
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 6
CREATE TABLE public.document_access_logs (
@@ -111,6 +111,7 @@ CREATE TABLE public.documents (
retencao_ate timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
content_sha256 text,
CONSTRAINT documents_status_revisao_check CHECK ((status_revisao = ANY (ARRAY['pendente'::text, 'aprovado'::text, 'rejeitado'::text]))),
CONSTRAINT documents_tipo_check CHECK ((tipo_documento = ANY (ARRAY['laudo'::text, 'receita'::text, 'exame'::text, 'termo_assinado'::text, 'relatorio_externo'::text, 'identidade'::text, 'convenio'::text, 'declaracao'::text, 'atestado'::text, 'recibo'::text, 'outro'::text]))),
CONSTRAINT documents_visibilidade_check CHECK ((visibilidade = ANY (ARRAY['privado'::text, 'compartilhado_supervisor'::text, 'compartilhado_portal'::text])))
@@ -1,5 +1,5 @@
-- Tables: Estrutura / Calendário
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 1
CREATE TABLE public.feriados (
@@ -1,11 +1,11 @@
-- Tables: Financeiro
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 10
CREATE TABLE public.financial_records (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid,
tenant_id uuid NOT NULL,
type public.financial_record_type DEFAULT 'receita'::public.financial_record_type NOT NULL,
amount numeric(10,2) NOT NULL,
description text,
@@ -35,6 +35,7 @@ CREATE TABLE public.financial_records (
CONSTRAINT financial_records_clinic_fee_amount_check CHECK ((clinic_fee_amount >= (0)::numeric)),
CONSTRAINT financial_records_clinic_fee_pct_check CHECK (((clinic_fee_pct >= (0)::numeric) AND (clinic_fee_pct <= (100)::numeric))),
CONSTRAINT financial_records_discount_amount_check CHECK ((discount_amount >= (0)::numeric)),
CONSTRAINT financial_records_fee_lte_amount_chk CHECK (((clinic_fee_amount IS NULL) OR ((clinic_fee_amount >= (0)::numeric) AND (clinic_fee_amount <= amount)))),
CONSTRAINT financial_records_final_amount_check CHECK ((final_amount >= (0)::numeric)),
CONSTRAINT financial_records_installments_check CHECK ((installments >= 1)),
CONSTRAINT financial_records_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'partial'::text, 'overdue'::text, 'cancelled'::text, 'refunded'::text])))
+258 -2
View File
@@ -1,6 +1,6 @@
-- Tables: outros
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Total: 1
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 17
CREATE TABLE public._db_migrations (
id integer NOT NULL,
@@ -9,3 +9,259 @@ CREATE TABLE public._db_migrations (
category text DEFAULT 'migration'::text NOT NULL,
applied_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.audit_logs (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
user_id uuid,
entity_type text NOT NULL,
entity_id text,
action text NOT NULL,
old_values jsonb,
new_values jsonb,
changed_fields text[],
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT audit_logs_action_check CHECK ((action = ANY (ARRAY['insert'::text, 'update'::text, 'delete'::text])))
);
CREATE TABLE public.dev_auditoria_items (
id bigint NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao_problema text,
solucao text,
severidade character varying(20),
status character varying(20) DEFAULT 'aberto'::character varying NOT NULL,
resolvido_em date,
sessao_resolucao character varying(160),
arquivo_afetado text,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_auditoria_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_auditoria_items_status_check CHECK (((status)::text = ANY ((ARRAY['aberto'::character varying, 'em_analise'::character varying, 'resolvido'::character varying, 'wontfix'::character varying, 'duplicado'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_competitor_status (
id bigint NOT NULL,
comparison_id bigint NOT NULL,
competitor_id bigint NOT NULL,
status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nota text,
fonte character varying(20),
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_competitor_status_fonte_check CHECK (((fonte IS NULL) OR ((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))),
CONSTRAINT dev_comparison_competitor_status_status_check CHECK (((status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_matrix (
id bigint NOT NULL,
dominio character varying(120),
feature text NOT NULL,
nosso_status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nossa_nota text,
importancia character varying(20),
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_matrix_importancia_check CHECK (((importancia IS NULL) OR ((importancia)::text = ANY ((ARRAY['alta'::character varying, 'media'::character varying, 'baixa'::character varying])::text[])))),
CONSTRAINT dev_comparison_matrix_nosso_status_check CHECK (((nosso_status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_competitor_features (
id bigint NOT NULL,
competitor_id bigint NOT NULL,
categoria character varying(120),
nome text NOT NULL,
descricao text,
fonte character varying(20) DEFAULT 'publico'::character varying NOT NULL,
fonte_url text,
data_fonte date,
destaque boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_competitor_features_fonte_check CHECK (((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))
);
CREATE TABLE public.dev_competitors (
id bigint NOT NULL,
slug character varying(80) NOT NULL,
nome character varying(160) NOT NULL,
pais character varying(40),
foco character varying(160),
pricing text,
posicionamento text,
url text,
ultima_pesquisa date,
notas text,
ativo boolean DEFAULT true NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_generation_log (
id bigint NOT NULL,
tipo character varying(40) NOT NULL,
comando text,
sucesso boolean DEFAULT false NOT NULL,
stdout text,
stderr text,
duration_ms integer,
metadata jsonb DEFAULT '{}'::jsonb,
trigger_user_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_roadmap_items (
id bigint NOT NULL,
phase_id bigint NOT NULL,
numero integer,
bloco character varying(160),
feature text NOT NULL,
descricao text,
esforco character varying(4),
prioridade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
notas text,
assignee character varying(120),
data_inicio date,
data_conclusao date,
ordem integer DEFAULT 0 NOT NULL,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_items_esforco_check CHECK (((esforco IS NULL) OR ((esforco)::text = ANY ((ARRAY['S'::character varying, 'M'::character varying, 'L'::character varying, 'XL'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_prioridade_check CHECK (((prioridade IS NULL) OR ((prioridade)::text = ANY ((ARRAY['bloqueador'::character varying, 'alta'::character varying, 'media'::character varying, 'diferencial'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'em_andamento'::character varying, 'concluido'::character varying, 'cancelado'::character varying, 'bloqueado'::character varying])::text[])))
);
CREATE TABLE public.dev_roadmap_phases (
id bigint NOT NULL,
numero integer NOT NULL,
nome character varying(160) NOT NULL,
objetivo text,
timeline_sugerida character varying(160),
criterio_saida text,
status character varying(20) DEFAULT 'planejada'::character varying NOT NULL,
data_inicio date,
data_fim date,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_phases_status_check CHECK (((status)::text = ANY ((ARRAY['planejada'::character varying, 'em_andamento'::character varying, 'concluida'::character varying, 'arquivada'::character varying])::text[])))
);
CREATE TABLE public.dev_test_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
arquivo text,
descricao text,
total_tests integer DEFAULT 0,
passing integer DEFAULT 0,
failing integer DEFAULT 0,
skipped integer DEFAULT 0,
cobertura_pct numeric(5,2),
status character varying(20) DEFAULT 'ok'::character varying NOT NULL,
last_run_at timestamp with time zone,
sessao_criacao character varying(160),
notas text,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_test_items_status_check CHECK (((status)::text = ANY ((ARRAY['ok'::character varying, 'falhando'::character varying, 'pendente'::character varying, 'obsoleto'::character varying, 'a_escrever'::character varying])::text[])))
);
CREATE TABLE public.dev_verificacoes_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao text,
resultado text,
acao_sugerida text,
severidade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
verificado_em date,
sessao_verificacao character varying(160),
arquivo_afetado text,
auditoria_item_id bigint,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_verificacoes_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_verificacoes_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'verificando'::character varying, 'ok'::character varying, 'problema'::character varying, 'corrigido'::character varying, 'wontfix'::character varying])::text[])))
);
CREATE TABLE public.math_challenges (
id uuid DEFAULT gen_random_uuid() NOT NULL,
question text NOT NULL,
answer integer NOT NULL,
used boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '00:05:00'::interval) NOT NULL
);
CREATE TABLE public.patient_invite_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
token text NOT NULL,
ok boolean NOT NULL,
error_code text,
error_msg text,
client_info text,
owner_id uuid,
tenant_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.public_submission_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text,
user_agent text,
metadata jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.saas_security_config (
id boolean DEFAULT true NOT NULL,
honeypot_enabled boolean DEFAULT true NOT NULL,
rate_limit_enabled boolean DEFAULT true NOT NULL,
rate_limit_window_min integer DEFAULT 10 NOT NULL,
rate_limit_max_attempts integer DEFAULT 5 NOT NULL,
captcha_after_failures integer DEFAULT 3 NOT NULL,
captcha_required_globally boolean DEFAULT false NOT NULL,
block_duration_min integer DEFAULT 30 NOT NULL,
captcha_required_window_min integer DEFAULT 60 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK ((id = true))
);
CREATE TABLE public.saas_twilio_config (
id boolean DEFAULT true NOT NULL,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) DEFAULT 5.5 NOT NULL,
margin_multiplier numeric(10,4) DEFAULT 1.4 NOT NULL,
notes text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_twilio_config_mult_chk CHECK (((margin_multiplier >= (1)::numeric) AND (margin_multiplier <= (10)::numeric))),
CONSTRAINT saas_twilio_config_rate_chk CHECK (((usd_brl_rate > (0)::numeric) AND (usd_brl_rate < (100)::numeric))),
CONSTRAINT saas_twilio_config_sid_chk CHECK (((account_sid IS NULL) OR (account_sid ~ '^AC[a-zA-Z0-9]{32}$'::text))),
CONSTRAINT saas_twilio_config_singleton CHECK ((id = true)),
CONSTRAINT saas_twilio_config_url_chk CHECK (((whatsapp_webhook_url IS NULL) OR (whatsapp_webhook_url ~ '^https?://'::text)))
);
+155 -93
View File
@@ -1,6 +1,159 @@
-- Tables: Pacientes
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Total: 12
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 16
CREATE TABLE public.patient_status_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status_anterior text,
status_novo text NOT NULL,
motivo text,
encaminhado_para text,
data_saida date,
alterado_por uuid,
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT psh_status_novo_check CHECK ((status_novo = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
CREATE TABLE public.contact_email_types (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
name text NOT NULL,
slug text NOT NULL,
icon text,
is_system boolean DEFAULT false NOT NULL,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_email_types_name_check CHECK (((length(name) > 0) AND (length(name) <= 40))),
CONSTRAINT contact_email_types_slug_check CHECK ((slug ~ '^[a-z0-9_-]{1,40}$'::text))
);
CREATE TABLE public.contact_emails (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
entity_type text NOT NULL,
entity_id uuid NOT NULL,
contact_email_type_id uuid NOT NULL,
email text NOT NULL,
is_primary boolean DEFAULT false NOT NULL,
notes text,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_emails_email_check CHECK ((email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'::text)),
CONSTRAINT contact_emails_entity_type_check CHECK ((entity_type = ANY (ARRAY['patient'::text, 'medico'::text])))
);
CREATE TABLE public.contact_phones (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
entity_type text NOT NULL,
entity_id uuid NOT NULL,
contact_type_id uuid NOT NULL,
number text NOT NULL,
is_primary boolean DEFAULT false NOT NULL,
whatsapp_linked_at timestamp with time zone,
notes text,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_phones_entity_type_check CHECK ((entity_type = ANY (ARRAY['patient'::text, 'medico'::text]))),
CONSTRAINT contact_phones_number_check CHECK ((number ~ '^\d{8,15}$'::text))
);
CREATE TABLE public.contact_types (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
name text NOT NULL,
slug text NOT NULL,
icon text,
is_mobile boolean DEFAULT true NOT NULL,
is_system boolean DEFAULT false NOT NULL,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_types_name_check CHECK (((length(name) > 0) AND (length(name) <= 40))),
CONSTRAINT contact_types_slug_check CHECK ((slug ~ '^[a-z0-9_-]{1,40}$'::text))
);
CREATE TABLE public.patients (
id uuid DEFAULT gen_random_uuid() NOT NULL,
nome_completo text NOT NULL,
email_principal text,
telefone text,
created_at timestamp with time zone DEFAULT now(),
owner_id uuid,
avatar_url text,
status text DEFAULT 'Ativo'::text,
last_attended_at timestamp with time zone,
is_native boolean DEFAULT false,
naturalidade text,
data_nascimento date,
rg text,
cpf text,
identification_color text,
genero text,
estado_civil text,
email_alternativo text,
pais text DEFAULT 'Brasil'::text,
cep text,
cidade text,
estado text,
endereco text,
numero text,
bairro text,
complemento text,
escolaridade text,
profissao text,
nome_parente text,
grau_parentesco text,
telefone_alternativo text,
onde_nos_conheceu text,
encaminhado_por text,
nome_responsavel text,
telefone_responsavel text,
cpf_responsavel text,
observacao_responsavel text,
cobranca_no_responsavel boolean DEFAULT false,
observacoes text,
notas_internas text,
updated_at timestamp with time zone DEFAULT now(),
telefone_parente text,
tenant_id uuid NOT NULL,
responsible_member_id uuid NOT NULL,
user_id uuid,
patient_scope text DEFAULT 'clinic'::text NOT NULL,
therapist_member_id uuid,
nome_social text,
pronomes text,
etnia text,
religiao text,
faixa_renda text,
canal_preferido text DEFAULT 'whatsapp'::text,
horario_contato_inicio time without time zone DEFAULT '08:00:00'::time without time zone,
horario_contato_fim time without time zone DEFAULT '20:00:00'::time without time zone,
idioma text DEFAULT 'pt-BR'::text,
origem text,
metodo_pagamento_preferido text,
motivo_saida text,
data_saida date,
encaminhado_para text,
risco_elevado boolean DEFAULT false NOT NULL,
risco_nota text,
risco_sinalizado_em timestamp with time zone,
risco_sinalizado_por uuid,
horario_contato text,
convenio text,
convenio_id uuid,
CONSTRAINT cpf_responsavel_format_check CHECK (((cpf_responsavel IS NULL) OR (cpf_responsavel ~ '^\d{11}$'::text))),
CONSTRAINT patients_cpf_format_check CHECK (((cpf IS NULL) OR (cpf ~ '^\d{11}$'::text))),
CONSTRAINT patients_faixa_renda_check CHECK (((faixa_renda IS NULL) OR (faixa_renda = ANY (ARRAY['ate_1sm'::text, '1_3sm'::text, '3_6sm'::text, '6_10sm'::text, 'acima_10sm'::text, 'nao_informado'::text])))),
CONSTRAINT patients_metodo_pagamento_check CHECK (((metodo_pagamento_preferido IS NULL) OR (metodo_pagamento_preferido = ANY (ARRAY['pix'::text, 'cartao'::text, 'dinheiro'::text, 'deposito'::text, 'convenio'::text])))),
CONSTRAINT patients_risco_consistency_check CHECK (((risco_elevado = false) OR ((risco_elevado = true) AND (risco_nota IS NOT NULL) AND (risco_sinalizado_por IS NOT NULL)))),
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Em espera'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
CREATE TABLE public.patient_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
@@ -117,20 +270,6 @@ CREATE TABLE public.patient_patient_tag (
tenant_id uuid NOT NULL
);
CREATE TABLE public.patient_status_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status_anterior text,
status_novo text NOT NULL,
motivo text,
encaminhado_para text,
data_saida date,
alterado_por uuid,
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT psh_status_novo_check CHECK ((status_novo = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
CREATE TABLE public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL,
@@ -172,80 +311,3 @@ CREATE TABLE public.patient_timeline (
CONSTRAINT pt_evento_tipo_check CHECK ((evento_tipo = ANY (ARRAY['primeira_sessao'::text, 'sessao_realizada'::text, 'sessao_cancelada'::text, 'falta'::text, 'status_alterado'::text, 'risco_sinalizado'::text, 'risco_removido'::text, 'documento_assinado'::text, 'documento_adicionado'::text, 'escala_respondida'::text, 'escala_enviada'::text, 'pagamento_vencido'::text, 'pagamento_recebido'::text, 'tarefa_combinada'::text, 'contato_adicionado'::text, 'prontuario_editado'::text, 'nota_adicionada'::text, 'manual'::text]))),
CONSTRAINT pt_icone_cor_check CHECK ((icone_cor = ANY (ARRAY['green'::text, 'blue'::text, 'amber'::text, 'red'::text, 'gray'::text, 'purple'::text])))
);
CREATE TABLE public.patients (
id uuid DEFAULT gen_random_uuid() NOT NULL,
nome_completo text NOT NULL,
email_principal text,
telefone text,
created_at timestamp with time zone DEFAULT now(),
owner_id uuid,
avatar_url text,
status text DEFAULT 'Ativo'::text,
last_attended_at timestamp with time zone,
is_native boolean DEFAULT false,
naturalidade text,
data_nascimento date,
rg text,
cpf text,
identification_color text,
genero text,
estado_civil text,
email_alternativo text,
pais text DEFAULT 'Brasil'::text,
cep text,
cidade text,
estado text,
endereco text,
numero text,
bairro text,
complemento text,
escolaridade text,
profissao text,
nome_parente text,
grau_parentesco text,
telefone_alternativo text,
onde_nos_conheceu text,
encaminhado_por text,
nome_responsavel text,
telefone_responsavel text,
cpf_responsavel text,
observacao_responsavel text,
cobranca_no_responsavel boolean DEFAULT false,
observacoes text,
notas_internas text,
updated_at timestamp with time zone DEFAULT now(),
telefone_parente text,
tenant_id uuid NOT NULL,
responsible_member_id uuid NOT NULL,
user_id uuid,
patient_scope text DEFAULT 'clinic'::text NOT NULL,
therapist_member_id uuid,
nome_social text,
pronomes text,
etnia text,
religiao text,
faixa_renda text,
canal_preferido text DEFAULT 'whatsapp'::text,
horario_contato_inicio time without time zone DEFAULT '08:00:00'::time without time zone,
horario_contato_fim time without time zone DEFAULT '20:00:00'::time without time zone,
idioma text DEFAULT 'pt-BR'::text,
origem text,
metodo_pagamento_preferido text,
motivo_saida text,
data_saida date,
encaminhado_para text,
risco_elevado boolean DEFAULT false NOT NULL,
risco_nota text,
risco_sinalizado_em timestamp with time zone,
risco_sinalizado_por uuid,
horario_contato text,
convenio text,
convenio_id uuid,
CONSTRAINT cpf_responsavel_format_check CHECK (((cpf_responsavel IS NULL) OR (cpf_responsavel ~ '^\d{11}$'::text))),
CONSTRAINT patients_cpf_format_check CHECK (((cpf IS NULL) OR (cpf ~ '^\d{11}$'::text))),
CONSTRAINT patients_faixa_renda_check CHECK (((faixa_renda IS NULL) OR (faixa_renda = ANY (ARRAY['ate_1sm'::text, '1_3sm'::text, '3_6sm'::text, '6_10sm'::text, 'acima_10sm'::text, 'nao_informado'::text])))),
CONSTRAINT patients_metodo_pagamento_check CHECK (((metodo_pagamento_preferido IS NULL) OR (metodo_pagamento_preferido = ANY (ARRAY['pix'::text, 'cartao'::text, 'dinheiro'::text, 'deposito'::text, 'convenio'::text])))),
CONSTRAINT patients_risco_consistency_check CHECK (((risco_elevado = false) OR ((risco_elevado = true) AND (risco_nota IS NOT NULL) AND (risco_sinalizado_por IS NOT NULL)))),
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Em espera'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
@@ -1,5 +1,5 @@
-- Tables: SaaS / Planos
-- Gerado automaticamente em 2026-04-17T12:23:05.227Z
-- Gerado automaticamente em 2026-04-21T23:16:34.953Z
-- Total: 18
CREATE TABLE public.subscriptions (
@@ -66,7 +66,8 @@ CREATE TABLE public.features (
description text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
descricao text DEFAULT ''::text NOT NULL,
name text DEFAULT ''::text NOT NULL
name text DEFAULT ''::text NOT NULL,
is_active boolean DEFAULT true NOT NULL
);
CREATE TABLE public.module_features (
@@ -0,0 +1,15 @@
-- Tables: Segurança / Rate limiting
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
-- Total: 1
CREATE TABLE public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer DEFAULT 0 NOT NULL,
fail_count integer DEFAULT 0 NOT NULL,
window_start timestamp with time zone DEFAULT now() NOT NULL,
blocked_until timestamp with time zone,
requires_captcha_until timestamp with time zone,
last_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
@@ -1,5 +1,5 @@
-- Tables: Serviços / Prontuários
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 8
CREATE TABLE public.commitment_services (
@@ -1,5 +1,5 @@
-- Tables: Tenants / Multi-tenant
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 10
CREATE TABLE public.tenant_members (
+138 -2
View File
@@ -1,6 +1,142 @@
-- Views
-- Gerado automaticamente em 2026-04-17T12:23:05.233Z
-- Total: 27
-- Gerado automaticamente em 2026-04-21T23:16:34.958Z
-- Total: 29
CREATE VIEW public.audit_log_unified WITH (security_invoker='true') AS
SELECT ('audit:'::text || (al.id)::text) AS uid,
al.tenant_id,
al.user_id,
al.entity_type,
al.entity_id,
al.action,
CASE al.action
WHEN 'insert'::text THEN ('Criou '::text || al.entity_type)
WHEN 'update'::text THEN (('Alterou '::text || al.entity_type) || COALESCE(((' ('::text || array_to_string(al.changed_fields, ', '::text)) || ')'::text), ''::text))
WHEN 'delete'::text THEN ('Excluiu '::text || al.entity_type)
ELSE NULL::text
END AS description,
al.created_at AS occurred_at,
'audit_logs'::text AS source,
jsonb_build_object('old_values', al.old_values, 'new_values', al.new_values, 'changed_fields', al.changed_fields) AS details
FROM public.audit_logs al
UNION ALL
SELECT ('doc_access:'::text || (dal.id)::text) AS uid,
dal.tenant_id,
dal.user_id,
'document'::text AS entity_type,
(dal.documento_id)::text AS entity_id,
dal.acao AS action,
CASE dal.acao
WHEN 'visualizou'::text THEN 'Visualizou documento'::text
WHEN 'baixou'::text THEN 'Baixou documento'::text
WHEN 'imprimiu'::text THEN 'Imprimiu documento'::text
WHEN 'compartilhou'::text THEN 'Compartilhou documento'::text
WHEN 'assinou'::text THEN 'Assinou documento'::text
ELSE dal.acao
END AS description,
dal.acessado_em AS occurred_at,
'document_access_logs'::text AS source,
jsonb_build_object('ip', (dal.ip)::text, 'user_agent', dal.user_agent) AS details
FROM public.document_access_logs dal
UNION ALL
SELECT ('psh:'::text || (psh.id)::text) AS uid,
psh.tenant_id,
psh.alterado_por AS user_id,
'patient_status'::text AS entity_type,
(psh.patient_id)::text AS entity_id,
'status_change'::text AS action,
(((('Status do paciente: '::text || COALESCE(psh.status_anterior, ''::text)) || ''::text) || psh.status_novo) || COALESCE(((' ('::text || psh.motivo) || ')'::text), ''::text)) AS description,
psh.alterado_em AS occurred_at,
'patient_status_history'::text AS source,
jsonb_build_object('status_anterior', psh.status_anterior, 'status_novo', psh.status_novo, 'motivo', psh.motivo, 'encaminhado_para', psh.encaminhado_para, 'data_saida', psh.data_saida) AS details
FROM public.patient_status_history psh
UNION ALL
SELECT ('notif:'::text || (nl.id)::text) AS uid,
nl.tenant_id,
nl.owner_id AS user_id,
'notification'::text AS entity_type,
(nl.patient_id)::text AS entity_id,
nl.status AS action,
(((('Notificação '::text || nl.channel) || ' '::text) || nl.status) || COALESCE((' para '::text || nl.recipient_address), ''::text)) AS description,
nl.created_at AS occurred_at,
'notification_logs'::text AS source,
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status, 'provider', nl.provider, 'failure_reason', nl.failure_reason) AS details
FROM public.notification_logs nl
UNION ALL
SELECT ('addon:'::text || (at.id)::text) AS uid,
at.tenant_id,
at.admin_user_id AS user_id,
'addon_transaction'::text AS entity_type,
(at.id)::text AS entity_id,
at.type AS action,
CASE at.type
WHEN 'purchase'::text THEN ((('Compra de '::text || at.amount) || ' créditos de '::text) || at.addon_type)
WHEN 'consumption'::text THEN ((('Consumo de '::text || abs(at.amount)) || ' crédito(s) '::text) || at.addon_type)
WHEN 'adjustment'::text THEN ('Ajuste de créditos '::text || at.addon_type)
WHEN 'refund'::text THEN ((('Reembolso de '::text || abs(at.amount)) || ' créditos '::text) || at.addon_type)
ELSE ((at.type || ' '::text) || at.addon_type)
END AS description,
at.created_at AS occurred_at,
'addon_transactions'::text AS source,
jsonb_build_object('addon_type', at.addon_type, 'amount', at.amount, 'balance_after', at.balance_after, 'price_cents', at.price_cents, 'payment_reference', at.payment_reference) AS details
FROM public.addon_transactions at;
CREATE VIEW public.conversation_threads WITH (security_invoker='true') AS
WITH base AS (
SELECT cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE
WHEN (cm.direction = 'inbound'::text) THEN cm.from_number
ELSE cm.to_number
END AS contact_number,
COALESCE((cm.patient_id)::text, ('anon:'::text || COALESCE(
CASE
WHEN (cm.direction = 'inbound'::text) THEN cm.from_number
ELSE cm.to_number
END, 'unknown'::text))) AS thread_key
FROM public.conversation_messages cm
), latest AS (
SELECT DISTINCT ON (base.tenant_id, base.thread_key) base.tenant_id,
base.thread_key,
base.patient_id,
base.channel,
base.contact_number,
base.body AS last_message_body,
base.direction AS last_message_direction,
base.kanban_status,
base.created_at AS last_message_at
FROM base
ORDER BY base.tenant_id, base.thread_key, base.created_at DESC
), counts AS (
SELECT base.tenant_id,
base.thread_key,
count(*) AS message_count,
count(*) FILTER (WHERE ((base.direction = 'inbound'::text) AND (base.read_at IS NULL))) AS unread_count
FROM base
GROUP BY base.tenant_id, base.thread_key
)
SELECT l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status
FROM ((latest l
JOIN counts c ON (((c.tenant_id = l.tenant_id) AND (c.thread_key = l.thread_key))))
LEFT JOIN public.patients p ON ((p.id = l.patient_id)));
CREATE VIEW public.current_tenant_id AS
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
+184 -2
View File
@@ -1,6 +1,6 @@
-- Indexes
-- Gerado automaticamente em 2026-04-17T12:23:05.235Z
-- Total: 270
-- Gerado automaticamente em 2026-04-21T23:16:34.961Z
-- Total: 361
CREATE INDEX agenda_bloqueios_owner_data_idx ON public.agenda_bloqueios USING btree (owner_id, data_inicio, data_fim);
@@ -166,10 +166,126 @@ CREATE INDEX idx_addon_tx_type ON public.addon_transactions USING btree (type);
CREATE INDEX idx_agenda_eventos_determined_commitment_id ON public.agenda_eventos USING btree (determined_commitment_id);
CREATE INDEX idx_agenda_eventos_titulo_custom_trgm ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops) WHERE (titulo_custom IS NOT NULL);
CREATE INDEX idx_agenda_eventos_titulo_trgm ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops) WHERE (titulo IS NOT NULL);
CREATE INDEX idx_agenda_excecoes_owner_data ON public.agenda_excecoes USING btree (owner_id, data);
CREATE INDEX idx_agenda_slots_regras_owner_dia ON public.agenda_slots_regras USING btree (owner_id, dia_semana);
CREATE INDEX idx_audit_logs_changed_fields ON public.audit_logs USING gin (changed_fields);
CREATE INDEX idx_audit_logs_entity ON public.audit_logs USING btree (entity_type, entity_id);
CREATE INDEX idx_audit_logs_tenant_created ON public.audit_logs USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_audit_logs_user_created ON public.audit_logs USING btree (user_id, created_at DESC) WHERE (user_id IS NOT NULL);
CREATE INDEX idx_autoreply_log_cooldown ON public.conversation_autoreply_log USING btree (tenant_id, thread_key, sent_at DESC);
CREATE INDEX idx_contact_email_types_tenant ON public.contact_email_types USING btree (tenant_id, "position");
CREATE INDEX idx_contact_emails_email ON public.contact_emails USING btree (tenant_id, email);
CREATE INDEX idx_contact_emails_entity ON public.contact_emails USING btree (tenant_id, entity_type, entity_id, "position");
CREATE INDEX idx_contact_phones_entity ON public.contact_phones USING btree (tenant_id, entity_type, entity_id, "position");
CREATE INDEX idx_contact_phones_number ON public.contact_phones USING btree (tenant_id, number);
CREATE INDEX idx_contact_types_tenant ON public.contact_types USING btree (tenant_id, "position");
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_kanban ON public.conversation_messages USING btree (tenant_id, kanban_status, priority DESC, created_at DESC);
CREATE INDEX idx_conv_msg_patient ON public.conversation_messages USING btree (patient_id, created_at DESC) WHERE (patient_id IS NOT NULL);
CREATE INDEX idx_conv_msg_provider_msg_id ON public.conversation_messages USING btree (provider_message_id) WHERE (provider_message_id IS NOT NULL);
CREATE INDEX idx_conv_msg_tenant_created ON public.conversation_messages USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_conv_notes_created_by ON public.conversation_notes USING btree (created_by, created_at DESC) WHERE (deleted_at IS NULL);
CREATE INDEX idx_conv_notes_patient ON public.conversation_notes USING btree (patient_id, created_at DESC) WHERE ((deleted_at IS NULL) AND (patient_id IS NOT NULL));
CREATE INDEX idx_conv_notes_tenant_thread ON public.conversation_notes USING btree (tenant_id, thread_key, created_at DESC) WHERE (deleted_at IS NULL);
CREATE INDEX idx_conv_optout_kw_tenant ON public.conversation_optout_keywords USING btree (tenant_id) WHERE (enabled = true);
CREATE INDEX idx_conv_optouts_patient ON public.conversation_optouts USING btree (patient_id) WHERE (patient_id IS NOT NULL);
CREATE INDEX idx_conv_optouts_tenant_phone ON public.conversation_optouts USING btree (tenant_id, phone);
CREATE INDEX idx_conv_tags_tenant ON public.conversation_tags USING btree (tenant_id, "position");
CREATE INDEX idx_conv_thread_tags_tag ON public.conversation_thread_tags USING btree (tag_id);
CREATE INDEX idx_conv_thread_tags_tenant_thread ON public.conversation_thread_tags USING btree (tenant_id, thread_key);
CREATE INDEX idx_dev_auditoria_items_categoria ON public.dev_auditoria_items USING btree (categoria);
CREATE INDEX idx_dev_auditoria_items_ordem ON public.dev_auditoria_items USING btree (ordem);
CREATE INDEX idx_dev_auditoria_items_severidade ON public.dev_auditoria_items USING btree (severidade);
CREATE INDEX idx_dev_auditoria_items_status ON public.dev_auditoria_items USING btree (status);
CREATE INDEX idx_dev_ccs_comp ON public.dev_comparison_competitor_status USING btree (competitor_id);
CREATE INDEX idx_dev_ccs_comparison ON public.dev_comparison_competitor_status USING btree (comparison_id);
CREATE INDEX idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix USING btree (dominio);
CREATE INDEX idx_dev_comparison_matrix_status ON public.dev_comparison_matrix USING btree (nosso_status);
CREATE INDEX idx_dev_competitor_features_cat ON public.dev_competitor_features USING btree (categoria);
CREATE INDEX idx_dev_competitor_features_comp ON public.dev_competitor_features USING btree (competitor_id);
CREATE INDEX idx_dev_competitor_features_destaque ON public.dev_competitor_features USING btree (destaque);
CREATE INDEX idx_dev_competitor_features_ordem ON public.dev_competitor_features USING btree (competitor_id, ordem);
CREATE INDEX idx_dev_competitors_ativo ON public.dev_competitors USING btree (ativo);
CREATE INDEX idx_dev_competitors_pais ON public.dev_competitors USING btree (pais);
CREATE INDEX idx_dev_generation_log_created ON public.dev_generation_log USING btree (created_at DESC);
CREATE INDEX idx_dev_generation_log_tipo ON public.dev_generation_log USING btree (tipo);
CREATE INDEX idx_dev_roadmap_items_ordem ON public.dev_roadmap_items USING btree (phase_id, ordem);
CREATE INDEX idx_dev_roadmap_items_phase ON public.dev_roadmap_items USING btree (phase_id);
CREATE INDEX idx_dev_roadmap_items_prior ON public.dev_roadmap_items USING btree (prioridade);
CREATE INDEX idx_dev_roadmap_items_status ON public.dev_roadmap_items USING btree (status);
CREATE INDEX idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases USING btree (ordem);
CREATE INDEX idx_dev_roadmap_phases_status ON public.dev_roadmap_phases USING btree (status);
CREATE INDEX idx_dev_test_items_area ON public.dev_test_items USING btree (area);
CREATE INDEX idx_dev_test_items_ordem ON public.dev_test_items USING btree (area, ordem);
CREATE INDEX idx_dev_test_items_status ON public.dev_test_items USING btree (status);
CREATE INDEX idx_dev_verificacoes_area ON public.dev_verificacoes_items USING btree (area);
CREATE INDEX idx_dev_verificacoes_ordem ON public.dev_verificacoes_items USING btree (area, ordem);
CREATE INDEX idx_dev_verificacoes_severidade ON public.dev_verificacoes_items USING btree (severidade);
CREATE INDEX idx_dev_verificacoes_status ON public.dev_verificacoes_items USING btree (status);
CREATE INDEX idx_documents_content_sha256 ON public.documents USING btree (content_sha256) WHERE (content_sha256 IS NOT NULL);
CREATE INDEX idx_email_templates_global_domain ON public.email_templates_global USING btree (domain) WHERE (is_active = true);
CREATE INDEX idx_email_templates_global_key ON public.email_templates_global USING btree (key) WHERE (is_active = true);
@@ -178,6 +294,8 @@ CREATE INDEX idx_email_templates_tenant_lookup ON public.email_templates_tenant
CREATE INDEX idx_email_templates_tenant_owner ON public.email_templates_tenant USING btree (owner_id, template_key) WHERE ((enabled = true) AND (owner_id IS NOT NULL));
CREATE INDEX idx_features_is_active ON public.features USING btree (is_active) WHERE (is_active = false);
CREATE INDEX idx_financial_categories_user_id ON public.financial_categories USING btree (user_id);
CREATE INDEX idx_financial_records_active ON public.financial_records USING btree (owner_id, paid_at DESC) WHERE (deleted_at IS NULL);
@@ -216,6 +334,8 @@ CREATE INDEX idx_intakes_owner_status_created ON public.patient_intake_requests
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_notice_dismissals_user ON public.notice_dismissals USING btree (user_id, notice_id);
CREATE INDEX idx_notif_channels_owner_active ON public.notification_channels USING btree (owner_id, channel) WHERE ((is_active = true) AND (deleted_at IS NULL));
@@ -270,12 +390,28 @@ CREATE INDEX idx_patient_groups_owner ON public.patient_groups USING btree (owne
CREATE INDEX idx_patient_groups_owner_system_nome ON public.patient_groups USING btree (owner_id, is_system, nome);
CREATE INDEX idx_patient_intake_requests_nome_trgm ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops) WHERE (status = 'new'::text);
CREATE INDEX idx_patient_invite_attempts_created ON public.patient_invite_attempts USING btree (created_at DESC);
CREATE INDEX idx_patient_invite_attempts_ok ON public.patient_invite_attempts USING btree (ok) WHERE (ok = false);
CREATE INDEX idx_patient_invite_attempts_owner ON public.patient_invite_attempts USING btree (owner_id);
CREATE INDEX idx_patient_invite_attempts_token ON public.patient_invite_attempts USING btree (token);
CREATE INDEX idx_patient_tags_owner ON public.patient_tags USING btree (owner_id);
CREATE INDEX idx_patients_cpf_trgm ON public.patients USING gin (cpf public.gin_trgm_ops) WHERE (cpf IS NOT NULL);
CREATE INDEX idx_patients_created_at ON public.patients USING btree (created_at DESC);
CREATE INDEX idx_patients_email_trgm ON public.patients USING gin (email_principal public.gin_trgm_ops) WHERE (email_principal IS NOT NULL);
CREATE INDEX idx_patients_last_attended ON public.patients USING btree (last_attended_at DESC);
CREATE INDEX idx_patients_nome_trgm ON public.patients USING gin (nome_completo public.gin_trgm_ops);
CREATE INDEX idx_patients_origem ON public.patients USING btree (tenant_id, origem) WHERE (origem IS NOT NULL);
CREATE INDEX idx_patients_owner_email_principal ON public.patients USING btree (owner_id, email_principal);
@@ -304,6 +440,12 @@ CREATE INDEX idx_ppt_patient ON public.patient_patient_tag USING btree (patient_
CREATE INDEX idx_ppt_tag ON public.patient_patient_tag USING btree (tag_id);
CREATE INDEX idx_psa_endpoint_created ON public.public_submission_attempts USING btree (endpoint, created_at DESC);
CREATE INDEX idx_psa_failed ON public.public_submission_attempts USING btree (created_at DESC) WHERE (success = false);
CREATE INDEX idx_psa_ip_hash_created ON public.public_submission_attempts USING btree (ip_hash, created_at DESC) WHERE (ip_hash IS NOT NULL);
CREATE INDEX idx_psh_patient ON public.patient_status_history USING btree (patient_id, alterado_em DESC);
CREATE INDEX idx_psh_tenant ON public.patient_status_history USING btree (tenant_id, alterado_em DESC);
@@ -314,8 +456,16 @@ CREATE INDEX idx_pt_patient_ocorrido ON public.patient_timeline USING btree (pat
CREATE INDEX idx_pt_tenant ON public.patient_timeline USING btree (tenant_id, ocorrido_em DESC);
CREATE INDEX idx_services_name_trgm ON public.services USING gin (name public.gin_trgm_ops);
CREATE INDEX idx_session_reminder_tenant_sent ON public.session_reminder_logs USING btree (tenant_id, sent_at DESC);
CREATE INDEX idx_slots_bloq_owner_dia ON public.agenda_slots_bloqueados_semanais USING btree (owner_id, dia_semana);
CREATE INDEX idx_srl_blocked_until ON public.submission_rate_limits USING btree (blocked_until) WHERE (blocked_until IS NOT NULL);
CREATE INDEX idx_srl_endpoint ON public.submission_rate_limits USING btree (endpoint, last_attempt_at DESC);
CREATE INDEX idx_subscription_intents_plan_interval ON public.subscription_intents_legacy USING btree (plan_key, "interval");
CREATE INDEX idx_subscription_intents_status ON public.subscription_intents_legacy USING btree (status);
@@ -344,6 +494,18 @@ CREATE INDEX idx_twilio_usage_tenant_period ON public.twilio_subaccount_usage US
CREATE UNIQUE INDEX idx_twilio_usage_unique_period ON public.twilio_subaccount_usage USING btree (channel_id, period_start, period_end);
CREATE INDEX idx_wa_credit_packages_active ON public.whatsapp_credit_packages USING btree (is_active, "position", price_brl) WHERE (is_active = true);
CREATE INDEX idx_wa_credit_purchases_asaas_payment ON public.whatsapp_credit_purchases USING btree (asaas_payment_id) WHERE (asaas_payment_id IS NOT NULL);
CREATE INDEX idx_wa_credit_purchases_status ON public.whatsapp_credit_purchases USING btree (status, created_at DESC);
CREATE INDEX idx_wa_credit_purchases_tenant ON public.whatsapp_credit_purchases USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_wa_credits_tx_kind ON public.whatsapp_credits_transactions USING btree (tenant_id, kind, 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 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);
@@ -522,6 +684,24 @@ CREATE INDEX tenant_modules_owner_idx ON public.tenant_modules USING btree (owne
CREATE UNIQUE INDEX unique_member_per_tenant ON public.tenant_members USING btree (tenant_id, user_id);
CREATE UNIQUE INDEX uq_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_emails_primary ON public.contact_emails USING btree (entity_type, entity_id) WHERE (is_primary = true);
CREATE UNIQUE INDEX uq_contact_phones_primary ON public.contact_phones USING btree (entity_type, entity_id) WHERE (is_primary = true);
CREATE UNIQUE INDEX uq_contact_types_system_slug ON public.contact_types USING btree (slug) WHERE (tenant_id IS NULL);
CREATE UNIQUE INDEX uq_contact_types_tenant_slug ON public.contact_types USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
CREATE UNIQUE INDEX uq_conv_optouts_active ON public.conversation_optouts USING btree (tenant_id, phone) WHERE (opted_back_in_at IS NULL);
CREATE UNIQUE INDEX uq_conv_tags_system_slug ON public.conversation_tags USING btree (slug) WHERE (tenant_id IS NULL);
CREATE UNIQUE INDEX uq_conv_tags_tenant_slug ON public.conversation_tags USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
CREATE UNIQUE INDEX uq_patient_contacts_primario ON public.patient_contacts USING btree (patient_id) WHERE ((is_primario = true) AND (ativo = true));
CREATE UNIQUE INDEX uq_patients_tenant_user ON public.patients USING btree (tenant_id, user_id) WHERE (user_id IS NOT NULL);
@@ -530,6 +710,8 @@ CREATE UNIQUE INDEX uq_plan_price_active ON public.plan_prices USING btree (plan
CREATE UNIQUE INDEX uq_plan_prices_active ON public.plan_prices USING btree (plan_id, "interval") WHERE (is_active = true);
CREATE UNIQUE INDEX uq_session_reminder_event_type ON public.session_reminder_logs USING btree (event_id, reminder_type);
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));
@@ -1,6 +1,6 @@
-- Constraints (PK, FK, UNIQUE, CHECK)
-- Gerado automaticamente em 2026-04-17T12:23:05.237Z
-- Total: 275
-- Gerado automaticamente em 2026-04-21T23:16:34.963Z
-- Total: 353
ALTER TABLE ONLY public._db_migrations
ADD CONSTRAINT _db_migrations_filename_key UNIQUE (filename);
@@ -65,6 +65,9 @@ ALTER TABLE ONLY public.agendador_configuracoes
ALTER TABLE ONLY public.agendador_solicitacoes
ADD CONSTRAINT agendador_solicitacoes_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.audit_logs
ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.billing_contracts
ADD CONSTRAINT billing_contracts_pkey PRIMARY KEY (id);
@@ -80,6 +83,42 @@ ALTER TABLE ONLY public.company_profiles
ALTER TABLE ONLY public.company_profiles
ADD CONSTRAINT company_profiles_tenant_id_key UNIQUE (tenant_id);
ALTER TABLE ONLY public.contact_email_types
ADD CONSTRAINT contact_email_types_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contact_emails
ADD CONSTRAINT contact_emails_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contact_phones
ADD CONSTRAINT contact_phones_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_optout_keywords
ADD CONSTRAINT conversation_optout_keywords_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_pkey PRIMARY KEY (tenant_id, thread_key, tag_id);
ALTER TABLE ONLY public.determined_commitment_fields
ADD CONSTRAINT determined_commitment_fields_pkey PRIMARY KEY (id);
@@ -89,12 +128,51 @@ ALTER TABLE ONLY public.determined_commitments
ALTER TABLE ONLY public.determined_commitments
ADD CONSTRAINT determined_commitments_tenant_native_key_uq UNIQUE (tenant_id, native_key);
ALTER TABLE ONLY public.dev_auditoria_items
ADD CONSTRAINT dev_auditoria_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_statu_comparison_id_competitor_id_key UNIQUE (comparison_id, competitor_id);
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_status_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_comparison_matrix
ADD CONSTRAINT dev_comparison_matrix_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_competitor_features
ADD CONSTRAINT dev_competitor_features_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_competitors
ADD CONSTRAINT dev_competitors_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_competitors
ADD CONSTRAINT dev_competitors_slug_key UNIQUE (slug);
ALTER TABLE ONLY public.dev_generation_log
ADD CONSTRAINT dev_generation_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_roadmap_items
ADD CONSTRAINT dev_roadmap_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_roadmap_phases
ADD CONSTRAINT dev_roadmap_phases_numero_key UNIQUE (numero);
ALTER TABLE ONLY public.dev_roadmap_phases
ADD CONSTRAINT dev_roadmap_phases_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_test_items
ADD CONSTRAINT dev_test_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_user_credentials
ADD CONSTRAINT dev_user_credentials_email_key UNIQUE (email);
ALTER TABLE ONLY public.dev_user_credentials
ADD CONSTRAINT dev_user_credentials_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_verificacoes_items
ADD CONSTRAINT dev_verificacoes_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.document_access_logs
ADD CONSTRAINT document_access_logs_pkey PRIMARY KEY (id);
@@ -170,6 +248,9 @@ ALTER TABLE ONLY public.insurance_plans
ALTER TABLE ONLY public.login_carousel_slides
ADD CONSTRAINT login_carousel_slides_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.math_challenges
ADD CONSTRAINT math_challenges_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.medicos
ADD CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm);
@@ -230,6 +311,9 @@ ALTER TABLE ONLY public.patient_groups
ALTER TABLE ONLY public.patient_intake_requests
ADD CONSTRAINT patient_intake_requests_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.patient_invite_attempts
ADD CONSTRAINT patient_invite_attempts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.patient_invites
ADD CONSTRAINT patient_invites_pkey PRIMARY KEY (id);
@@ -290,6 +374,9 @@ ALTER TABLE ONLY public.professional_pricing
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.public_submission_attempts
ADD CONSTRAINT public_submission_attempts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.recurrence_exceptions
ADD CONSTRAINT recurrence_exceptions_pkey PRIMARY KEY (id);
@@ -320,9 +407,24 @@ ALTER TABLE ONLY public.saas_faq_itens
ALTER TABLE ONLY public.saas_faq
ADD CONSTRAINT saas_faq_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.saas_security_config
ADD CONSTRAINT saas_security_config_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.saas_twilio_config
ADD CONSTRAINT saas_twilio_config_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.services
ADD CONSTRAINT services_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.session_reminder_settings
ADD CONSTRAINT session_reminder_settings_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.submission_rate_limits
ADD CONSTRAINT submission_rate_limits_pkey PRIMARY KEY (ip_hash, endpoint);
ALTER TABLE ONLY public.subscription_events
ADD CONSTRAINT subscription_events_pkey PRIMARY KEY (id);
@@ -398,6 +500,18 @@ ALTER TABLE ONLY public.notification_templates
ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY public.whatsapp_credit_packages
ADD CONSTRAINT whatsapp_credit_packages_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.whatsapp_credits_balance
ADD CONSTRAINT whatsapp_credits_balance_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.addon_credits
ADD CONSTRAINT addon_credits_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id);
@@ -476,6 +590,12 @@ ALTER TABLE ONLY public.agendador_solicitacoes
ALTER TABLE ONLY public.agendador_solicitacoes
ADD CONSTRAINT agendador_sol_tenant_fk FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.audit_logs
ADD CONSTRAINT audit_logs_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.audit_logs
ADD CONSTRAINT audit_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.billing_contracts
ADD CONSTRAINT billing_contracts_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
@@ -500,6 +620,69 @@ ALTER TABLE ONLY public.commitment_time_logs
ALTER TABLE ONLY public.company_profiles
ADD CONSTRAINT company_profiles_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_email_types
ADD CONSTRAINT contact_email_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_emails
ADD CONSTRAINT contact_emails_contact_email_type_id_fkey FOREIGN KEY (contact_email_type_id) REFERENCES public.contact_email_types(id) ON DELETE RESTRICT;
ALTER TABLE ONLY public.contact_emails
ADD CONSTRAINT contact_emails_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_phones
ADD CONSTRAINT contact_phones_contact_type_id_fkey FOREIGN KEY (contact_type_id) REFERENCES public.contact_types(id) ON DELETE RESTRICT;
ALTER TABLE ONLY public.contact_phones
ADD CONSTRAINT contact_phones_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_optout_keywords
ADD CONSTRAINT conversation_optout_keywords_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_blocked_by_fkey FOREIGN KEY (blocked_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.conversation_tags(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_tagged_by_fkey FOREIGN KEY (tagged_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.determined_commitment_fields
ADD CONSTRAINT determined_commitment_fields_commitment_id_fkey FOREIGN KEY (commitment_id) REFERENCES public.determined_commitments(id) ON DELETE CASCADE;
@@ -509,6 +692,21 @@ ALTER TABLE ONLY public.determined_commitment_fields
ALTER TABLE ONLY public.determined_commitments
ADD CONSTRAINT determined_commitments_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_status_comparison_id_fkey FOREIGN KEY (comparison_id) REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_status_competitor_id_fkey FOREIGN KEY (competitor_id) REFERENCES public.dev_competitors(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_competitor_features
ADD CONSTRAINT dev_competitor_features_competitor_id_fkey FOREIGN KEY (competitor_id) REFERENCES public.dev_competitors(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_roadmap_items
ADD CONSTRAINT dev_roadmap_items_phase_id_fkey FOREIGN KEY (phase_id) REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_verificacoes_items
ADD CONSTRAINT dev_verificacoes_items_auditoria_item_id_fkey FOREIGN KEY (auditoria_item_id) REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.document_access_logs
ADD CONSTRAINT document_access_logs_documento_id_fkey FOREIGN KEY (documento_id) REFERENCES public.documents(id) ON DELETE CASCADE;
@@ -752,6 +950,18 @@ ALTER TABLE ONLY public.saas_faq_itens
ALTER TABLE ONLY public.services
ADD CONSTRAINT services_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_conversation_message_id_fkey FOREIGN KEY (conversation_message_id) REFERENCES public.conversation_messages(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_event_id_fkey FOREIGN KEY (event_id) REFERENCES public.agenda_eventos(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.session_reminder_settings
ADD CONSTRAINT session_reminder_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.subscription_intents_personal
ADD CONSTRAINT sint_personal_subscription_id_fkey FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id) ON DELETE SET NULL;
@@ -826,3 +1036,27 @@ ALTER TABLE ONLY public.twilio_subaccount_usage
ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_package_id_fkey FOREIGN KEY (package_id) REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credits_balance
ADD CONSTRAINT whatsapp_credits_balance_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_admin_id_fkey FOREIGN KEY (admin_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_conversation_message_id_fkey FOREIGN KEY (conversation_message_id) REFERENCES public.conversation_messages(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
+64 -2
View File
@@ -1,6 +1,6 @@
-- Triggers
-- Gerado automaticamente em 2026-04-17T12:23:05.238Z
-- Total: 80
-- Gerado automaticamente em 2026-04-21T23:16:34.965Z
-- Total: 111
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
@@ -40,6 +40,16 @@ 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_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_financial_records AFTER INSERT OR DELETE OR UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_patients AFTER INSERT OR DELETE OR UPDATE ON public.patients FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_tenant_members AFTER INSERT OR DELETE OR UPDATE ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
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_cancel_notifs_on_opt_out AFTER UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out();
@@ -48,10 +58,50 @@ CREATE TRIGGER trg_cancel_notifs_on_session_cancel AFTER UPDATE ON public.agenda
CREATE TRIGGER trg_company_profiles_updated_at BEFORE UPDATE ON public.company_profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_email_types_updated_at BEFORE UPDATE ON public.contact_email_types FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_emails_sync_legacy AFTER INSERT OR DELETE OR UPDATE ON public.contact_emails FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields();
CREATE TRIGGER trg_contact_emails_updated_at BEFORE UPDATE ON public.contact_emails FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_phones_sync_legacy AFTER INSERT OR DELETE OR UPDATE ON public.contact_phones FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields();
CREATE TRIGGER trg_contact_phones_updated_at BEFORE UPDATE ON public.contact_phones 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_autoreply_settings_updated_at BEFORE UPDATE ON public.conversation_autoreply_settings 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_optouts_updated_at BEFORE UPDATE ON public.conversation_optouts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_tags_updated_at BEFORE UPDATE ON public.conversation_tags FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_determined_commitment_fields_updated_at BEFORE UPDATE ON public.determined_commitment_fields FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_determined_commitments_updated_at BEFORE UPDATE ON public.determined_commitments FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_dev_auditoria_items_updated_at BEFORE UPDATE ON public.dev_auditoria_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_ccs_updated_at BEFORE UPDATE ON public.dev_comparison_competitor_status FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_comparison_matrix_updated_at BEFORE UPDATE ON public.dev_comparison_matrix FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_competitor_features_updated_at BEFORE UPDATE ON public.dev_competitor_features FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_competitors_updated_at BEFORE UPDATE ON public.dev_competitors FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_roadmap_items_updated_at BEFORE UPDATE ON public.dev_roadmap_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_roadmap_phases_updated_at BEFORE UPDATE ON public.dev_roadmap_phases FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_test_items_updated_at BEFORE UPDATE ON public.dev_test_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_verificacoes_updated_at BEFORE UPDATE ON public.dev_verificacoes_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_documents_timeline_insert AFTER INSERT ON public.documents FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert();
CREATE TRIGGER trg_documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
@@ -68,10 +118,14 @@ CREATE TRIGGER trg_email_templates_global_updated_at BEFORE UPDATE ON public.ema
CREATE TRIGGER trg_email_templates_tenant_updated_at BEFORE UPDATE ON public.email_templates_tenant FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_fanout_inbound_to_notifications AFTER INSERT ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
CREATE TRIGGER trg_financial_exceptions_updated_at BEFORE UPDATE ON public.financial_exceptions FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_financial_records_auto_overdue BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue();
CREATE TRIGGER trg_financial_records_inject_tenant BEFORE INSERT ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
CREATE TRIGGER trg_financial_records_updated_at BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_global_notices_updated_at BEFORE UPDATE ON public.global_notices FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
@@ -138,6 +192,8 @@ CREATE TRIGGER trg_psc_updated_at BEFORE UPDATE ON public.patient_support_contac
CREATE TRIGGER trg_services_updated_at BEFORE UPDATE ON public.services FOR EACH ROW EXECUTE FUNCTION public.set_services_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_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();
@@ -152,6 +208,12 @@ CREATE TRIGGER trg_therapist_payouts_updated_at BEFORE UPDATE ON public.therapis
CREATE TRIGGER trg_user_settings_updated_at BEFORE UPDATE ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_wa_credit_packages_updated_at BEFORE UPDATE ON public.whatsapp_credit_packages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_wa_credit_purchases_updated_at BEFORE UPDATE ON public.whatsapp_credit_purchases 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 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();
+472 -77
View File
@@ -1,7 +1,7 @@
-- RLS Policies
-- Gerado automaticamente em 2026-04-17T12:23:05.240Z
-- Enable RLS: 88 tabelas
-- Policies: 252
-- Gerado automaticamente em 2026-04-21T23:16:34.967Z
-- Enable RLS: 131 tabelas
-- Policies: 344
-- Enable RLS
ALTER TABLE public.addon_credits ENABLE ROW LEVEL SECURITY;
@@ -17,13 +17,36 @@ ALTER TABLE public.agenda_slots_bloqueados_semanais ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.agenda_slots_regras ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.agendador_configuracoes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.agendador_solicitacoes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.billing_contracts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.commitment_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.commitment_time_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.company_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_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_commitments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_auditoria_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_comparison_competitor_status ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_comparison_matrix ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_competitor_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_competitors ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_generation_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_roadmap_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_roadmap_phases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_user_credentials ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
@@ -43,6 +66,7 @@ ALTER TABLE public.global_notices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.insurance_plan_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.insurance_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.login_carousel_slides ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.module_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.modules ENABLE ROW LEVEL SECURITY;
@@ -60,6 +84,7 @@ ALTER TABLE public.patient_discounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_group_patient ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_intake_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_patient_tag ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_status_history ENABLE ROW LEVEL SECURITY;
@@ -69,9 +94,13 @@ ALTER TABLE public.patient_timeline ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.professional_pricing ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recurrence_exceptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recurrence_rule_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recurrence_rules ENABLE ROW LEVEL SECURITY;
@@ -80,11 +109,21 @@ ALTER TABLE public.saas_doc_votos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_faq ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_faq_itens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_legacy ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.support_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_modules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenants ENABLE ROW LEVEL SECURITY;
@@ -92,6 +131,10 @@ ALTER TABLE public.therapist_payout_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.therapist_payouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY;
-- Policies
CREATE POLICY addon_credits_admin_select ON public.addon_credits FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
@@ -115,8 +158,8 @@ CREATE POLICY addon_products_admin_all ON public.addon_products TO authenticated
CREATE POLICY addon_products_select_authenticated ON public.addon_products FOR SELECT TO authenticated USING (((deleted_at IS NULL) AND (is_active = true) AND (is_visible = true)));
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid()))));
FROM public.saas_admins sa
WHERE (sa.user_id = auth.uid()))));
CREATE POLICY addon_transactions_admin_select ON public.addon_transactions FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.saas_admins
@@ -180,7 +223,43 @@ CREATE POLICY agendador_sol_patient_read ON public.agendador_solicitacoes FOR SE
CREATE POLICY agendador_sol_public_insert ON public.agendador_solicitacoes FOR INSERT TO anon WITH CHECK (true);
CREATE POLICY "billing_contracts: owner full access" ON public.billing_contracts USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs FOR DELETE TO authenticated USING (false);
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs FOR INSERT TO authenticated WITH CHECK (false);
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs FOR UPDATE TO authenticated USING (false) WITH CHECK (false);
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_log: select" ON public.conversation_autoreply_log FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_log.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_settings: insert" ON public.conversation_autoreply_settings FOR INSERT TO authenticated WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_settings: select" ON public.conversation_autoreply_settings FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_settings: update" ON public.conversation_autoreply_settings FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "billing_contracts: select" ON public.billing_contracts FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "billing_contracts: update" ON public.billing_contracts FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY bloqueios_delete ON public.agenda_bloqueios FOR DELETE TO authenticated USING ((owner_id = auth.uid()));
@@ -198,11 +277,13 @@ CREATE POLICY clinic_admin_read_all_docs ON public.saas_docs FOR SELECT TO authe
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])))))));
CREATE POLICY "commitment_services: owner full access" ON public.commitment_services USING ((EXISTS ( SELECT 1
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services TO authenticated USING ((EXISTS ( SELECT 1
FROM public.services s
WHERE ((s.id = commitment_services.service_id) AND (s.owner_id = auth.uid()))))) WITH CHECK ((EXISTS ( SELECT 1
WHERE ((s.id = commitment_services.service_id) AND ((s.owner_id = auth.uid()) OR public.is_saas_admin() OR (s.tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))))))) WITH CHECK ((EXISTS ( SELECT 1
FROM public.services s
WHERE ((s.id = commitment_services.service_id) AND (s.owner_id = auth.uid())))));
WHERE ((s.id = commitment_services.service_id) AND ((s.owner_id = auth.uid()) OR public.is_saas_admin())))));
CREATE POLICY company_profiles_delete ON public.company_profiles FOR DELETE USING ((EXISTS ( SELECT 1
FROM public.tenant_members
@@ -222,13 +303,99 @@ CREATE POLICY company_profiles_update ON public.company_profiles FOR UPDATE USIN
FROM public.tenant_members
WHERE ((tenant_members.tenant_id = company_profiles.tenant_id) AND (tenant_members.user_id = auth.uid())))));
CREATE POLICY "contact_email_types: manage custom" ON public.contact_email_types TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_email_types.tenant_id) AND (tm.status = 'active'::text))))))) WITH CHECK (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_email_types.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "contact_email_types: select" ON public.contact_email_types FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_email_types.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "contact_emails: all tenant" ON public.contact_emails TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_emails.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_emails.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "contact_phones: all tenant" ON public.contact_phones TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_phones.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_phones.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "contact_types: manage custom" ON public.contact_types TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text))))))) WITH CHECK (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "contact_types: select" ON public.contact_types FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages FOR DELETE TO authenticated USING (false);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages FOR INSERT TO authenticated WITH CHECK (false);
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))) WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "conv_notes: insert tenant members" ON public.conversation_notes FOR INSERT TO authenticated WITH CHECK (((created_by = auth.uid()) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_notes.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_notes: select tenant members" ON public.conversation_notes FOR SELECT TO authenticated USING (((deleted_at IS NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_notes.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_notes: update creator or saas" ON public.conversation_notes FOR UPDATE TO authenticated USING (((deleted_at IS NULL) AND ((created_by = auth.uid()) OR public.is_saas_admin()))) WITH CHECK ((created_by = ( SELECT conversation_notes_1.created_by
FROM public.conversation_notes conversation_notes_1
WHERE (conversation_notes_1.id = conversation_notes_1.id))));
CREATE POLICY "conv_tags: delete custom" ON public.conversation_tags FOR DELETE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_tags: insert custom" ON public.conversation_tags FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NOT NULL) AND (is_system = false) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_tags: select" ON public.conversation_tags FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_tags: update custom" ON public.conversation_tags FOR UPDATE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text))))))) WITH CHECK ((is_system = false));
CREATE POLICY "conv_thread_tags: delete" ON public.conversation_thread_tags FOR DELETE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_thread_tags.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_thread_tags: insert" ON public.conversation_thread_tags FOR INSERT TO authenticated WITH CHECK (((tagged_by = auth.uid()) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_thread_tags.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_thread_tags: select" ON public.conversation_thread_tags FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_thread_tags.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY ctl_delete_for_active_member ON public.commitment_time_logs FOR DELETE TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = commitment_time_logs.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.tenant_id = commitment_time_logs.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY ctl_select_for_active_member ON public.commitment_time_logs FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
@@ -240,7 +407,9 @@ CREATE POLICY ctl_update_for_active_member ON public.commitment_time_logs FOR UP
FROM public.tenant_members tm
WHERE ((tm.tenant_id = commitment_time_logs.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs FOR INSERT WITH CHECK (true);
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "dal: tenant members can select" ON public.document_access_logs FOR SELECT USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
@@ -250,9 +419,9 @@ CREATE POLICY dc_delete_custom_for_active_member ON public.determined_commitment
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitments.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitments.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY dc_select_for_active_member ON public.determined_commitments FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
@@ -268,9 +437,9 @@ CREATE POLICY dcf_delete_for_active_member ON public.determined_commitment_field
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitment_fields.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitment_fields.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY dcf_select_for_active_member ON public.determined_commitment_fields FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
@@ -284,6 +453,16 @@ CREATE POLICY dcf_update_for_active_member ON public.determined_commitment_field
CREATE POLICY "delete own" ON public.agenda_bloqueios FOR DELETE USING ((owner_id = auth.uid()));
CREATE POLICY dev_auditoria_items_saas_admin_all ON public.dev_auditoria_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_comparison_competitor_status_saas_admin_all ON public.dev_comparison_competitor_status TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_comparison_matrix_saas_admin_all ON public.dev_comparison_matrix TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_competitor_features_saas_admin_all ON public.dev_competitor_features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_competitors_saas_admin_all ON public.dev_competitors TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_creds_select_saas_admin ON public.dev_user_credentials FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.profiles p
WHERE ((p.id = auth.uid()) AND (p.role = 'saas_admin'::text)))));
@@ -294,35 +473,67 @@ CREATE POLICY dev_creds_write_saas_admin ON public.dev_user_credentials TO authe
FROM public.profiles p
WHERE ((p.id = auth.uid()) AND (p.role = 'saas_admin'::text)))));
CREATE POLICY dev_generation_log_saas_admin_all ON public.dev_generation_log TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_roadmap_items_saas_admin_all ON public.dev_roadmap_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_roadmap_phases_saas_admin_all ON public.dev_roadmap_phases TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY "dg: generator full access" ON public.document_generated USING ((gerado_por = auth.uid())) WITH CHECK ((gerado_por = auth.uid()));
CREATE POLICY "dg: tenant members can select" ON public.document_generated FOR SELECT USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "documents: owner full access" ON public.documents USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "documents: delete" ON public.documents FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "ds: tenant members access" ON public.document_signatures USING ((tenant_id IN ( SELECT tm.tenant_id
CREATE POLICY "documents: insert" ON public.documents FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))) WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "documents: portal patient read" ON public.documents FOR SELECT TO authenticated USING (((compartilhado_portal = true) AND (patient_id IN ( SELECT p.id
FROM public.patients p
WHERE (p.user_id = auth.uid()))) AND ((expira_compartilhamento IS NULL) OR (expira_compartilhamento > now()))));
CREATE POLICY "documents: select" ON public.documents FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "dsl: creator full access" ON public.document_share_links USING ((criado_por = auth.uid())) WITH CHECK ((criado_por = auth.uid()));
CREATE POLICY "documents: update" ON public.documents FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "dsl: public read by token" ON public.document_share_links FOR SELECT USING (((ativo = true) AND (expira_em > now()) AND (usos < usos_max)));
CREATE POLICY "ds: delete" ON public.document_signatures FOR DELETE TO authenticated USING (((signatario_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY "ds: insert" ON public.document_signatures FOR INSERT TO authenticated WITH CHECK (((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))) AND ((signatario_id IS NULL) OR (signatario_id = auth.uid()))));
CREATE POLICY "ds: select" ON public.document_signatures FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "ds: update" ON public.document_signatures FOR UPDATE TO authenticated USING (((signatario_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((signatario_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "dsl: creator full access" ON public.document_share_links TO authenticated USING (((criado_por = auth.uid()) OR public.is_saas_admin())) WITH CHECK ((criado_por = auth.uid()));
CREATE POLICY "dt: global templates readable by all" ON public.document_templates FOR SELECT USING ((is_global = true));
CREATE POLICY "dt: owner can delete" ON public.document_templates FOR DELETE USING (((owner_id = auth.uid()) AND (is_global = false)));
CREATE POLICY "dt: owner can insert" ON public.document_templates FOR INSERT WITH CHECK (((owner_id = auth.uid()) AND (is_global = false)));
CREATE POLICY "dt: owner can insert" ON public.document_templates FOR INSERT TO authenticated WITH CHECK (((is_global = false) AND (owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "dt: owner can update" ON public.document_templates FOR UPDATE USING (((owner_id = auth.uid()) AND (is_global = false))) WITH CHECK (((owner_id = auth.uid()) AND (is_global = false)));
CREATE POLICY "dt: saas admin can delete global" ON public.document_templates FOR DELETE USING (((is_global = true) AND public.is_saas_admin()));
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates FOR INSERT WITH CHECK (((is_global = true) AND public.is_saas_admin()));
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates FOR INSERT TO authenticated WITH CHECK (((is_global = true) AND public.is_saas_admin()));
CREATE POLICY "dt: saas admin can update global" ON public.document_templates FOR UPDATE USING (((is_global = true) AND public.is_saas_admin())) WITH CHECK (((is_global = true) AND public.is_saas_admin()));
@@ -330,47 +541,57 @@ CREATE POLICY "dt: tenant members can select" ON public.document_templates FOR S
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY ent_inv_select_own ON public.entitlements_invalidation FOR SELECT USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY ent_inv_update_saas ON public.entitlements_invalidation FOR UPDATE USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY ent_inv_write_saas ON public.entitlements_invalidation FOR INSERT WITH CHECK (public.is_saas_admin());
CREATE POLICY faq_admin_write ON public.saas_faq TO authenticated USING ((EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['saas_admin'::text, 'tenant_admin'::text, 'clinic_admin'::text]))))));
CREATE POLICY faq_auth_read ON public.saas_faq FOR SELECT TO authenticated USING ((ativo = true));
CREATE POLICY faq_itens_admin_write ON public.saas_faq_itens TO authenticated USING ((EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['saas_admin'::text, 'tenant_admin'::text, 'clinic_admin'::text]))))));
CREATE POLICY faq_itens_auth_read ON public.saas_faq_itens FOR SELECT TO authenticated USING (((ativo = true) AND (EXISTS ( SELECT 1
FROM public.saas_docs d
WHERE ((d.id = saas_faq_itens.doc_id) AND (d.ativo = true))))));
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY faq_public_read ON public.saas_faq FOR SELECT USING (((publico = true) AND (ativo = true)));
CREATE POLICY faq_saas_admin_write ON public.saas_faq TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY features_read_authenticated ON public.features FOR SELECT TO authenticated USING (true);
CREATE POLICY features_write_saas_admin ON public.features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY feriados_delete ON public.feriados FOR DELETE USING ((owner_id = auth.uid()));
CREATE POLICY feriados_delete ON public.feriados FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR ((tenant_id IS NOT NULL) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))));
CREATE POLICY feriados_global_select ON public.feriados FOR SELECT USING ((tenant_id IS NULL));
CREATE POLICY feriados_insert ON public.feriados FOR INSERT WITH CHECK ((tenant_id IN ( SELECT tenant_members.tenant_id
FROM public.tenant_members
WHERE (tenant_members.user_id = auth.uid()))));
CREATE POLICY feriados_insert ON public.feriados FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NOT NULL) AND (owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY feriados_saas_delete ON public.feriados FOR DELETE USING ((EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid()))));
CREATE POLICY feriados_saas_insert ON public.feriados FOR INSERT WITH CHECK ((EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid()))));
CREATE POLICY feriados_saas_insert ON public.feriados FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NULL) AND (EXISTS ( SELECT 1
FROM public.saas_admins sa
WHERE (sa.user_id = auth.uid())))));
CREATE POLICY feriados_saas_select ON public.feriados FOR SELECT USING ((EXISTS ( SELECT 1
FROM public.saas_admins
@@ -406,15 +627,37 @@ CREATE POLICY global_notices_select ON public.global_notices FOR SELECT TO authe
CREATE POLICY "insert own" ON public.agenda_bloqueios FOR INSERT WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY insurance_plan_services_owner ON public.insurance_plan_services USING ((EXISTS ( SELECT 1
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services TO authenticated USING ((EXISTS ( SELECT 1
FROM public.insurance_plans ip
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND (ip.owner_id = auth.uid()))))) WITH CHECK ((EXISTS ( SELECT 1
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND ((ip.owner_id = auth.uid()) OR public.is_saas_admin() OR (ip.tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))))))) WITH CHECK ((EXISTS ( SELECT 1
FROM public.insurance_plans ip
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND (ip.owner_id = auth.uid())))));
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND ((ip.owner_id = auth.uid()) OR public.is_saas_admin())))));
CREATE POLICY "insurance_plans: owner full access" ON public.insurance_plans USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "medicos: owner full access" ON public.medicos USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "insurance_plans: select" ON public.insurance_plans FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "insurance_plans: update" ON public.insurance_plans FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "medicos: delete" ON public.medicos FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "medicos: insert" ON public.medicos FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "medicos: select" ON public.medicos FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "medicos: update" ON public.medicos FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY module_features_read_authenticated ON public.module_features FOR SELECT TO authenticated USING (true);
@@ -426,12 +669,32 @@ CREATE POLICY modules_write_saas_admin ON public.modules TO authenticated USING
CREATE POLICY notice_dismissals_own ON public.notice_dismissals TO authenticated USING ((user_id = auth.uid())) WITH CHECK ((user_id = auth.uid()));
CREATE POLICY notif_channels_delete ON public.notification_channels FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY notif_channels_insert ON public.notification_channels FOR INSERT TO authenticated WITH CHECK ((public.is_saas_admin() OR ((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
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
FROM public.tenant_members tm
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_tenant_member ON public.notification_logs FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY notif_prefs_owner ON public.notification_preferences USING (((owner_id = auth.uid()) AND (deleted_at IS NULL))) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY notif_queue_owner ON public.notification_queue FOR SELECT USING ((owner_id = auth.uid()));
CREATE POLICY notif_queue_tenant_member ON public.notification_queue FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY notif_schedules_owner ON public.notification_schedules USING (((owner_id = auth.uid()) AND (deleted_at IS NULL))) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY notif_templates_admin_all ON public.notification_templates TO authenticated USING ((EXISTS ( SELECT 1
@@ -444,7 +707,33 @@ CREATE POLICY notif_templates_read_global ON public.notification_templates FOR S
CREATE POLICY notif_templates_write_owner ON public.notification_templates TO authenticated USING (((owner_id = auth.uid()) OR public.is_tenant_member(tenant_id))) WITH CHECK (((owner_id = auth.uid()) OR public.is_tenant_member(tenant_id)));
CREATE POLICY notification_channels_owner ON public.notification_channels USING (((owner_id = auth.uid()) AND (deleted_at IS NULL))) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "optout_kw: delete custom" ON public.conversation_optout_keywords FOR DELETE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "optout_kw: insert custom" ON public.conversation_optout_keywords FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NOT NULL) AND (is_system = false) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "optout_kw: select" ON public.conversation_optout_keywords FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "optout_kw: update/delete custom" ON public.conversation_optout_keywords FOR UPDATE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "optouts: insert" ON public.conversation_optouts FOR INSERT TO authenticated WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optouts.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "optouts: select" ON public.conversation_optouts FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optouts.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "optouts: update" ON public.conversation_optouts FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optouts.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "owner only" ON public.notifications USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
@@ -478,6 +767,8 @@ CREATE POLICY patient_intake_requests_select ON public.patient_intake_requests F
CREATE POLICY patient_intake_requests_write ON public.patient_intake_requests USING ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit'::text))) WITH CHECK ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit'::text)));
CREATE POLICY patient_invite_attempts_owner_read ON public.patient_invite_attempts FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY patient_invites_owner_all ON public.patient_invites TO authenticated USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY patient_invites_select ON public.patient_invites FOR SELECT USING ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.view'::text)));
@@ -508,17 +799,37 @@ CREATE POLICY patients_update ON public.patients FOR UPDATE USING ((public.is_cl
CREATE POLICY "payment_settings: owner full access" ON public.payment_settings USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings FOR SELECT TO authenticated USING (((tenant_id IS NOT NULL) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY plan_features_read_authenticated ON public.plan_features FOR SELECT TO authenticated USING (true);
CREATE POLICY plan_features_write_saas_admin ON public.plan_features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plan_prices_read ON public.plan_prices FOR SELECT TO authenticated USING (true);
CREATE POLICY plan_prices_write ON public.plan_prices TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets FOR SELECT TO authenticated, anon USING (true);
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plan_public_read_anon ON public.plan_public FOR SELECT TO authenticated, anon USING (true);
CREATE POLICY plan_public_write ON public.plan_public TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plans_read_authenticated ON public.plans FOR SELECT TO authenticated USING (true);
CREATE POLICY plans_write_saas_admin ON public.plans TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY "professional_pricing: owner full access" ON public.professional_pricing USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY profiles_insert_own ON public.profiles FOR INSERT WITH CHECK ((id = auth.uid()));
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))));
CREATE POLICY profiles_insert_own ON public.profiles FOR INSERT TO authenticated WITH CHECK ((id = auth.uid()));
CREATE POLICY profiles_read_saas_admin ON public.profiles FOR SELECT USING (public.is_saas_admin());
@@ -526,6 +837,8 @@ CREATE POLICY profiles_select_own ON public.profiles FOR SELECT USING ((id = aut
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE USING ((id = auth.uid())) WITH CHECK ((id = auth.uid()));
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts FOR SELECT TO authenticated USING (public.is_saas_admin());
CREATE POLICY "psc: owner full access" ON public.patient_support_contacts USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY psh_insert ON public.patient_status_history FOR INSERT WITH CHECK ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit'::text)));
@@ -538,12 +851,6 @@ CREATE POLICY pt_select ON public.patient_timeline FOR SELECT USING ((public.is_
CREATE POLICY public_read ON public.login_carousel_slides FOR SELECT USING ((ativo = true));
CREATE POLICY "read features (auth)" ON public.features FOR SELECT TO authenticated USING (true);
CREATE POLICY "read plan_features (auth)" ON public.plan_features FOR SELECT TO authenticated USING (true);
CREATE POLICY "read plans (auth)" ON public.plans FOR SELECT TO authenticated USING (true);
CREATE POLICY recurrence_exceptions_tenant ON public.recurrence_exceptions TO authenticated USING ((tenant_id IN ( SELECT tenant_members.tenant_id
FROM public.tenant_members
WHERE (tenant_members.user_id = auth.uid())))) WITH CHECK ((tenant_id IN ( SELECT tenant_members.tenant_id
@@ -572,6 +879,16 @@ CREATE POLICY recurrence_rules_clinic_write ON public.recurrence_rules USING ((p
CREATE POLICY recurrence_rules_owner ON public.recurrence_rules TO authenticated USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "reminder_logs: tenant members select" ON public.session_reminder_logs FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = session_reminder_logs.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "reminder_settings: tenant members all" ON public.session_reminder_settings TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = session_reminder_settings.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = session_reminder_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "saas_admin can read subscription_intents" ON public.subscription_intents_legacy FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.saas_admins a
WHERE (a.user_id = auth.uid()))));
@@ -594,11 +911,29 @@ CREATE POLICY saas_admin_full_access ON public.saas_docs TO authenticated USING
CREATE POLICY saas_admins_select_self ON public.saas_admins FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY saas_security_config_read ON public.saas_security_config FOR SELECT TO authenticated USING (true);
CREATE POLICY saas_security_config_write ON public.saas_security_config FOR UPDATE TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config FOR SELECT TO authenticated USING (public.is_saas_admin());
CREATE POLICY "select own" ON public.agenda_bloqueios FOR SELECT USING ((owner_id = auth.uid()));
CREATE POLICY service_role_manage_usage ON public.twilio_subaccount_usage USING ((auth.role() = 'service_role'::text));
CREATE POLICY "services: owner full access" ON public.services USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "services: delete" ON public.services FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "services: insert" ON public.services FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "services: select" ON public.services FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
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 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());
@@ -606,9 +941,15 @@ CREATE POLICY subscription_events_write_saas ON public.subscription_events FOR I
CREATE POLICY subscription_intents_insert_own ON public.subscription_intents_legacy FOR INSERT TO authenticated WITH CHECK ((user_id = auth.uid()));
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal TO authenticated USING (((user_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((user_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY subscription_intents_select_own ON public.subscription_intents_legacy FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY "subscriptions read own" ON public.subscriptions FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text])))))));
CREATE POLICY "subscriptions: read if linked owner_users" ON public.subscriptions FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.owner_users ou
@@ -616,33 +957,53 @@ CREATE POLICY "subscriptions: read if linked owner_users" ON public.subscription
CREATE POLICY subscriptions_insert_own_personal ON public.subscriptions FOR INSERT TO authenticated WITH CHECK (((user_id = auth.uid()) AND (tenant_id IS NULL)));
CREATE POLICY subscriptions_no_direct_update ON public.subscriptions FOR UPDATE TO authenticated USING (false) WITH CHECK (false);
CREATE POLICY subscriptions_read_own ON public.subscriptions FOR SELECT TO authenticated USING (((user_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY subscriptions_select_for_tenant_members ON public.subscriptions FOR SELECT TO authenticated USING (((tenant_id IS NOT NULL) AND (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = subscriptions.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY subscriptions_select_own_personal ON public.subscriptions FOR SELECT TO authenticated USING (((user_id = auth.uid()) AND (tenant_id IS NULL)));
CREATE POLICY subscriptions_update_only_saas_admin ON public.subscriptions FOR UPDATE TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY support_sessions_saas_delete ON public.support_sessions FOR DELETE USING (((auth.uid() = admin_id) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text))))));
CREATE POLICY support_sessions_saas_insert ON public.support_sessions FOR INSERT WITH CHECK (((auth.uid() = admin_id) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text))))));
CREATE POLICY support_sessions_saas_insert ON public.support_sessions FOR INSERT TO authenticated WITH CHECK (((admin_id = auth.uid()) AND (EXISTS ( SELECT 1
FROM public.saas_admins sa
WHERE (sa.user_id = auth.uid())))));
CREATE POLICY support_sessions_saas_select ON public.support_sessions FOR SELECT USING (((auth.uid() = admin_id) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text))))));
CREATE POLICY "tenant manages own overrides" ON public.email_templates_tenant USING ((tenant_id = auth.uid())) WITH CHECK ((tenant_id = auth.uid()));
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "tenant owns email layout config" ON public.email_layout_config USING ((tenant_id = auth.uid())) WITH CHECK ((tenant_id = auth.uid()));
CREATE POLICY tenant_features_select ON public.tenant_features FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY tenant_invites_delete ON public.tenant_invites FOR DELETE TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_invites_insert ON public.tenant_invites FOR INSERT TO authenticated WITH CHECK (((invited_by = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_invites_select ON public.tenant_invites FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_invites_update ON public.tenant_invites FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_members_write_saas ON public.tenant_members TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
@@ -680,7 +1041,7 @@ CREATE POLICY tm_select_own_membership ON public.tenant_members FOR SELECT TO au
CREATE POLICY "update own" ON public.agenda_bloqueios FOR UPDATE USING ((owner_id = auth.uid()));
CREATE POLICY user_settings_insert_own ON public.user_settings FOR INSERT WITH CHECK ((user_id = auth.uid()));
CREATE POLICY user_settings_insert_own ON public.user_settings FOR INSERT TO authenticated WITH CHECK ((user_id = auth.uid()));
CREATE POLICY user_settings_select_own ON public.user_settings FOR SELECT USING ((user_id = auth.uid()));
@@ -692,6 +1053,28 @@ CREATE POLICY votos_select_own ON public.saas_doc_votos FOR SELECT TO authentica
CREATE POLICY votos_upsert_own ON public.saas_doc_votos TO authenticated USING ((user_id = auth.uid())) WITH CHECK ((user_id = auth.uid()));
CREATE POLICY "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_balance.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_balance.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_balance.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_transactions.tenant_id) AND (tm.status = 'active'::text))))));
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_purchases: select tenant" ON public.whatsapp_credit_purchases FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credit_purchases.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "Allow authenticated updates" ON storage.objects FOR UPDATE TO authenticated USING ((bucket_id = ANY (ARRAY['avatars'::text, 'logos'::text])));
CREATE POLICY "Allow authenticated uploads" ON storage.objects FOR INSERT TO authenticated WITH CHECK ((bucket_id = ANY (ARRAY['avatars'::text, 'logos'::text])));
@@ -730,25 +1113,31 @@ CREATE POLICY avatars_update_own ON storage.objects FOR UPDATE TO authenticated
CREATE POLICY avatars_update_own_folder ON storage.objects FOR UPDATE USING (((bucket_id = 'avatars'::text) AND (auth.role() = 'authenticated'::text) AND (name ~~ (('owners/'::text || auth.uid()) || '/%'::text)))) WITH CHECK (((bucket_id = 'avatars'::text) AND (auth.role() = 'authenticated'::text) AND (name ~~ (('owners/'::text || auth.uid()) || '/%'::text))));
CREATE POLICY "documents: authenticated delete" ON storage.objects FOR DELETE TO authenticated USING ((bucket_id = 'documents'::text));
CREATE POLICY "documents: tenant member delete" ON storage.objects FOR DELETE TO authenticated USING (((bucket_id = 'documents'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "documents: authenticated read" ON storage.objects FOR SELECT TO authenticated USING ((bucket_id = 'documents'::text));
CREATE POLICY "documents: tenant member read" ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'documents'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "documents: authenticated upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK ((bucket_id = 'documents'::text));
CREATE POLICY "documents: tenant member upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (((bucket_id = 'documents'::text) AND (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "generated-docs: authenticated delete" ON storage.objects FOR DELETE TO authenticated USING ((bucket_id = 'generated-docs'::text));
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects FOR DELETE TO authenticated USING (((bucket_id = 'generated-docs'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "generated-docs: authenticated read" ON storage.objects FOR SELECT TO authenticated USING ((bucket_id = 'generated-docs'::text));
CREATE POLICY "generated-docs: tenant member read" ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'generated-docs'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "generated-docs: authenticated upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK ((bucket_id = 'generated-docs'::text));
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (((bucket_id = 'generated-docs'::text) AND (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY intake_read_anon ON storage.objects FOR SELECT TO anon USING (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_read_public ON storage.objects FOR SELECT USING (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_upload_anon ON storage.objects FOR INSERT TO anon WITH CHECK (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_upload_public ON storage.objects FOR INSERT WITH CHECK (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_read_owner_only ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'avatars'::text) AND ((storage.foldername(name))[1] = 'intakes'::text)));
CREATE POLICY public_read ON storage.objects FOR SELECT USING ((bucket_id = 'saas-docs'::text));
@@ -759,3 +1148,9 @@ CREATE POLICY saas_admin_delete ON storage.objects FOR DELETE TO authenticated U
CREATE POLICY saas_admin_upload ON storage.objects FOR INSERT TO authenticated WITH CHECK (((bucket_id = 'saas-docs'::text) AND (EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid())))));
CREATE POLICY "whatsapp-media: delete saas admin" ON storage.objects FOR DELETE TO authenticated USING (((bucket_id = 'whatsapp-media'::text) AND public.is_saas_admin()));
CREATE POLICY "whatsapp-media: read tenant members" ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'whatsapp-media'::text) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND ((storage.foldername(objects.name))[1] = (tm.tenant_id)::text)))))));
+2 -1
View File
@@ -8,7 +8,8 @@
<meta name="description" content="Plataforma para gestão clínica e atendimento psicológico." />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="preconnect" href="https://fonts.cdnfonts.com" crossorigin />
<link href="https://fonts.cdnfonts.com/css/lato?display=swap" rel="stylesheet" />
<link rel="preload" as="style" href="https://fonts.cdnfonts.com/css/lato?display=swap" onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link href="https://fonts.cdnfonts.com/css/lato?display=swap" rel="stylesheet" /></noscript>
<link href="/src/main.js" as="script" />
<meta name="theme-color" content="#fff" />
</head>
+1461 -44
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@
"@supabase/supabase-js": "^2.95.3",
"chart.js": "3.3.2",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"html-to-pdfmake": "^2.5.33",
"html2canvas-pro": "^2.0.2",
"html2pdf.js": "^0.14.0",
+49
View File
@@ -293,3 +293,52 @@
.fc-timegrid-more-link {
box-shadow: 0 0 0 1px #000000;
}
/* Subheader padrão (cfg-subheader)
Header canônico usado nas páginas SaaS e de configurações.
Referência: SaasAddonsPage, SaasNotificationTemplatesPage. */
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
+7 -3
View File
@@ -86,6 +86,9 @@ const props = defineProps({
extraPayload: { type: Object, default: () => ({}) },
// Pré-preenchimento (usado ao converter número desconhecido em paciente, etc)
initialData: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true },
resetOnOpen: { type: Boolean, default: true }
});
@@ -120,9 +123,10 @@ watch(
);
function reset() {
form.nome_completo = '';
form.email_principal = '';
form.telefone = '';
const init = props.initialData || {};
form.nome_completo = init.nome_completo ?? '';
form.email_principal = init.email_principal ?? '';
form.telefone = init.telefone ?? '';
}
function close() {
@@ -20,7 +20,7 @@ import TabView from 'primevue/tabview';
import TabPanel from 'primevue/tabpanel';
import Dropdown from 'primevue/dropdown';
import InputNumber from 'primevue/inputnumber';
import InputSwitch from 'primevue/inputswitch';
import ToggleSwitch from 'primevue/toggleswitch';
import { useToast } from 'primevue/usetoast';
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService';
@@ -142,7 +142,7 @@ onMounted(load);
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 flex items-center gap-3">
<InputSwitch v-model="model[d.value].ativo" />
<ToggleSwitch v-model="model[d.value].ativo" />
<div>
<div class="text-900 font-medium">Ativo</div>
<div class="text-600 text-sm">Se desligado, o online não oferece horários nesse dia.</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI GlobalInboundNotifier
|--------------------------------------------------------------------------
| Componente global que escuta INSERTs de conversation_messages (direction
| inbound) e mostra card flutuante no canto inferior direito, além de tocar
| som opcional. Mesmo estando em outra tela, o usuário é avisado da nova
| mensagem e pode clicar "Abrir" pra ir direto à conversa.
|
| Monta uma vez em AppLayout.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { logEvent, logError } from '@/support/supportLogger';
const LOG_SRC = 'GlobalInboundNotifier';
const tenantStore = useTenantStore();
const drawerStore = useConversationDrawerStore();
const activeNotifs = ref([]); // array de popups ativos
let channel = null;
const SOUND_KEY = 'agenciapsi.inbound_sound_enabled';
const soundEnabled = ref(true);
function loadSoundPref() {
try {
soundEnabled.value = localStorage.getItem(SOUND_KEY) !== 'false';
} catch { soundEnabled.value = true; }
}
function toggleSound() {
soundEnabled.value = !soundEnabled.value;
try {
localStorage.setItem(SOUND_KEY, soundEnabled.value ? 'true' : 'false');
} catch {}
}
function playBeep() {
if (!soundEnabled.value) return;
try {
// WebAudio API beep sintético de 2 tons curtos
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const now = ctx.currentTime;
function tone(freq, start, dur, vol = 0.15) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0, now + start);
gain.gain.linearRampToValueAtTime(vol, now + start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, now + start + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(now + start);
osc.stop(now + start + dur);
}
tone(880, 0, 0.12);
tone(1320, 0.14, 0.12);
// Fecha o context após som
setTimeout(() => { try { ctx.close(); } catch {} }, 500);
} catch {
// se WebAudio falhar (sem interação prévia, etc), apenas ignora
}
}
function truncate(s, n = 80) {
if (!s) return '';
const str = String(s).replace(/\s+/g, ' ').trim();
return str.length > n ? str.slice(0, n - 1) + '…' : str;
}
async function showNotif(msg) {
logEvent(LOG_SRC, 'showNotif chamado', { id: msg.id, from: msg.from_number });
// Se o drawer dessa thread já está aberto, não notifica (o user já vê)
if (drawerStore.isOpen && drawerStore.messageBelongsToCurrentThread(msg)) {
logEvent(LOG_SRC, 'suprimido: drawer aberto na thread', { id: msg.id });
return;
}
// Se está na rota /conversas com aba visível, também não notifica (já tá no Kanban)
const onConversasRoute = typeof window !== 'undefined' && String(window.location.pathname).includes('/conversas');
if (onConversasRoute && document.visibilityState === 'visible') {
logEvent(LOG_SRC, 'suprimido: na rota /conversas visível', { id: msg.id });
return;
}
let name = msg.from_number || 'Desconhecido';
if (msg.patient_id) {
const { data } = await supabase.from('patients').select('nome_completo').eq('id', msg.patient_id).maybeSingle();
if (data?.nome_completo) name = data.nome_completo;
}
const notif = {
id: msg.id,
name,
body: msg.body || (msg.media_url ? '[mídia]' : ''),
patient_id: msg.patient_id,
from_number: msg.from_number,
channel: msg.channel
};
activeNotifs.value.push(notif);
logEvent(LOG_SRC, 'popup push', { total: activeNotifs.value.length });
playBeep();
setTimeout(() => dismiss(notif.id), 10000);
}
function dismiss(id) {
activeNotifs.value = activeNotifs.value.filter(n => n.id !== id);
}
async function openNotif(notif) {
dismiss(notif.id);
if (notif.patient_id) {
await drawerStore.openForPatient(notif.patient_id);
} else {
// thread anônima (número não vinculado a paciente)
await drawerStore.openForThread({
thread_key: `anon:${notif.from_number}`,
tenant_id: tenantStore.activeTenantId,
patient_id: null,
patient_name: null,
contact_number: notif.from_number,
channel: notif.channel,
message_count: 1,
unread_count: 1,
kanban_status: 'awaiting_us',
last_message_at: new Date().toISOString()
});
}
}
function channelIcon(ch) {
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
return map[ch] || 'pi-bell';
}
function subscribe() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
logEvent(LOG_SRC, 'subscribe skipped — sem tenant');
return;
}
if (channel) supabase.removeChannel(channel);
logEvent(LOG_SRC, 'subscribing', { tenantId });
channel = supabase
.channel(`global_inbound_${tenantId}_${Date.now()}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
const m = payload.new;
logEvent(LOG_SRC, 'INSERT recebido', {
id: m.id,
direction: m.direction,
from: m.from_number,
preview: m.body?.slice(0, 40)
});
if (m.direction !== 'inbound') {
logEvent(LOG_SRC, 'ignorando (não é inbound)', { direction: m.direction });
return;
}
showNotif(m);
}
)
.subscribe((status) => {
logEvent(LOG_SRC, 'channel status', { status });
});
}
function unsubscribe() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
onMounted(() => {
loadSoundPref();
subscribe();
});
onUnmounted(() => unsubscribe());
watch(() => tenantStore.activeTenantId, () => subscribe());
// expõe toggle pra quem quiser usar (ex: na aside do CRM)
defineExpose({ soundEnabled, toggleSound });
</script>
<template>
<div class="global-notif-container" aria-live="polite">
<TransitionGroup name="notif-slide" tag="div" class="flex flex-col gap-2">
<div
v-for="n in activeNotifs"
:key="n.id"
class="global-notif-card"
role="alert"
>
<div class="notif-icon" :class="n.channel === 'whatsapp' ? 'text-emerald-600 bg-emerald-500/10' : 'text-blue-600 bg-blue-500/10'">
<i :class="['pi', channelIcon(n.channel)]" />
</div>
<div class="notif-body">
<div class="notif-header">
<span class="notif-name">{{ n.name }}</span>
<span class="notif-channel">{{ n.channel === 'whatsapp' ? 'WhatsApp' : n.channel }}</span>
</div>
<div class="notif-preview">{{ truncate(n.body, 80) }}</div>
</div>
<div class="notif-actions">
<Button label="Abrir" size="small" severity="success" class="!text-xs" @click="openNotif(n)" />
<button class="notif-close" title="Dispensar" @click="dismiss(n.id)">
<i class="pi pi-times" />
</button>
</div>
</div>
</TransitionGroup>
<!-- Toggle de som (mini, aparece se tem notificação ou pode ficar sempre visível) -->
<button
v-if="activeNotifs.length > 0"
class="sound-toggle"
:title="soundEnabled ? 'Som ativado (clique pra desativar)' : 'Som desativado'"
@click="toggleSound"
>
<i :class="soundEnabled ? 'pi pi-volume-up' : 'pi pi-volume-off'" />
</button>
</div>
</template>
<style scoped>
.global-notif-container {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 1000;
pointer-events: none;
width: min(380px, calc(100vw - 2rem));
}
.global-notif-card {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e5e7eb);
border-radius: 10px;
padding: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15), 0 3px 10px rgba(0, 0, 0, 0.08);
}
.notif-icon {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
font-size: 1rem;
}
.notif-body {
flex: 1;
min-width: 0;
}
.notif-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.notif-name {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notif-channel {
font-size: 0.65rem;
padding: 0 0.35rem;
background: var(--surface-ground, #f3f4f6);
color: var(--text-color-secondary, #6b7280);
border-radius: 999px;
flex-shrink: 0;
}
.notif-preview {
font-size: 0.8rem;
color: var(--text-color-secondary, #6b7280);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.notif-actions {
display: flex;
flex-direction: column;
gap: 0.35rem;
align-items: center;
}
.notif-close {
width: 1.75rem;
height: 1.75rem;
border: none;
background: transparent;
color: var(--text-color-secondary, #6b7280);
border-radius: 50%;
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.75rem;
transition: background 0.15s;
}
.notif-close:hover {
background: var(--surface-ground, #f3f4f6);
}
.sound-toggle {
pointer-events: auto;
margin-top: 0.5rem;
align-self: flex-end;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid var(--surface-border, #e5e7eb);
background: var(--surface-card, #fff);
color: var(--text-color-secondary, #6b7280);
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.75rem;
}
.sound-toggle:hover {
background: var(--surface-ground, #f3f4f6);
color: var(--text-color, #111827);
}
/* Animação de entrada/saída */
.notif-slide-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.notif-slide-leave-active {
transition: all 0.2s ease-in;
}
.notif-slide-enter-from {
transform: translateX(400px);
opacity: 0;
}
.notif-slide-leave-to {
transform: translateX(400px);
opacity: 0;
}
</style>
@@ -15,15 +15,50 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useNotificationStore } from '@/stores/notificationStore';
import { useToast } from 'primevue/usetoast';
import {
useNotificationStore,
requestBrowserNotificationPermission,
setBrowserNotificationEnabled,
getBrowserNotificationEnabled
} from '@/stores/notificationStore';
import NotificationItem from './NotificationItem.vue';
const store = useNotificationStore();
const router = useRouter();
const toast = useToast();
const filter = ref('unread'); // 'unread' | 'all'
const browserNotifOn = ref(false);
onMounted(() => {
browserNotifOn.value = getBrowserNotificationEnabled();
});
async function toggleBrowserNotif() {
if (browserNotifOn.value) {
// desliga
setBrowserNotificationEnabled(false);
browserNotifOn.value = false;
toast.add({ severity: 'info', summary: 'Notificações do browser desligadas', life: 2500 });
return;
}
const granted = await requestBrowserNotificationPermission();
if (granted) {
setBrowserNotificationEnabled(true);
browserNotifOn.value = true;
toast.add({ severity: 'success', summary: 'Notificações do browser ativadas', life: 2500 });
} else {
toast.add({
severity: 'warn',
summary: 'Permissão negada',
detail: 'Habilite nas configurações do browser se mudar de ideia.',
life: 4000
});
}
}
const drawerOpen = computed({
get: () => store.drawerOpen,
@@ -57,6 +92,16 @@ function goToHistory() {
<div class="notification-drawer__header-content">
<span class="notification-drawer__title">Notificações</span>
<Badge v-if="store.unreadCount > 0" :value="store.unreadCount > 99 ? '99+' : store.unreadCount" severity="danger" />
<Button
:icon="browserNotifOn ? 'pi pi-bell' : 'pi pi-bell-slash'"
severity="secondary"
text
rounded
size="small"
class="ml-auto"
:title="browserNotifOn ? 'Desativar notificações do browser' : 'Ativar notificações do browser'"
@click="toggleBrowserNotif"
/>
</div>
</template>
@@ -20,6 +20,8 @@ import { useRouter } from 'vue-router';
import { formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { useNotificationStore } from '@/stores/notificationStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
item: { type: Object, required: true }
@@ -29,12 +31,15 @@ const emit = defineEmits(['read', 'archive']);
const router = useRouter();
const store = useNotificationStore();
const conversationDrawer = useConversationDrawerStore();
const tenantStore = useTenantStore();
const typeMap = {
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500' },
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500' },
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500' },
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' }
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500' },
inbound_message: { icon: 'pi-whatsapp', border: 'border-emerald-500' }
};
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' });
@@ -45,6 +50,31 @@ const timeAgo = computed(() => formatDistanceToNow(new Date(props.item.created_a
const initials = computed(() => props.item.payload?.avatar_initials || '?');
function handleRowClick() {
// Inbound message abre drawer global (evita 403 em rota admin/therapist)
if (props.item.type === 'inbound_message') {
const payload = props.item.payload || {};
if (payload.patient_id) {
conversationDrawer.openForPatient(payload.patient_id);
} else if (payload.from_number) {
conversationDrawer.openForThread({
thread_key: `anon:${payload.from_number}`,
tenant_id: tenantStore.activeTenantId,
patient_id: null,
patient_name: null,
contact_number: payload.from_number,
channel: payload.channel || 'whatsapp',
message_count: 1,
unread_count: 1,
kanban_status: 'awaiting_us',
last_message_at: new Date().toISOString()
});
}
store.drawerOpen = false;
emit('read', props.item.id);
return;
}
// Outros tipos: segue o deeplink normal
const deeplink = props.item.payload?.deeplink;
if (deeplink) {
router.push(deeplink);
+677
View File
@@ -0,0 +1,677 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI GlobalSearch (topbar)
|--------------------------------------------------------------------------
| Busca global com atalho Ctrl+K / +K. Consulta a RPC `search_global` e
| mostra resultados agrupados por entidade + ações rápidas client-side.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import InputText from 'primevue/inputtext';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { searchPages } from './pagesIndex';
const router = useRouter();
const tenantStore = useTenantStore();
//
// State
//
const rootEl = ref(null);
const inputEl = ref(null);
const query = ref('');
const results = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
const loading = ref(false);
const showPanel = ref(false);
const activeIndex = ref(-1);
//
// Ações estáticas (client-side, respondem na hora)
//
const STATIC_ACTIONS = [
{ id: 'act_new_patient', label: 'Novo paciente', icon: 'pi pi-user-plus', sublabel: 'Cadastrar paciente', to: '/therapist/patients/cadastro', keywords: ['novo','paciente','cadastrar','criar','add'] },
{ id: 'act_agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Ver calendário', to: '/therapist/agenda', keywords: ['agenda','calendario','sessoes','hoje'] },
{ id: 'act_patients', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Lista de pacientes', to: '/therapist/patients', keywords: ['pacientes','lista','todos'] },
{ id: 'act_financial', label: 'Financeiro', icon: 'pi pi-dollar', sublabel: 'Dashboard financeiro', to: '/therapist/financeiro', keywords: ['financeiro','cobrancas','pagamentos','dinheiro'] },
{ id: 'act_documents', label: 'Documentos', icon: 'pi pi-file', sublabel: 'Gestão de documentos', to: '/therapist/documents', keywords: ['documentos','arquivos','anexos'] },
{ id: 'act_services', label: 'Serviços', icon: 'pi pi-briefcase', sublabel: 'Precificação de serviços', to: '/configuracoes/precificacao', keywords: ['servicos','precificacao','precos','modalidade'] },
{ id: 'act_settings', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências', to: '/configuracoes', keywords: ['configuracoes','settings','preferencias','ajustes'] }
];
function normalize(s) {
return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
}
const filteredActions = computed(() => {
const q = normalize(query.value);
if (!q) return STATIC_ACTIONS.slice(0, 4); // quick defaults quando vazio
return STATIC_ACTIONS.filter((a) => {
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
return hay.includes(q);
}).slice(0, 4);
});
// Páginas do app (client-side, filtrado por papel ativo)
const filteredPages = computed(() => {
const q = query.value.trim();
const role = tenantStore?.activeRole || null;
if (!q) return []; // só mostra páginas quando o usuário busca algo
return searchPages(q, role, 5);
});
//
// Flat list pra navegação por teclado
//
const flatList = computed(() => {
const out = [];
filteredActions.value.forEach((a, i) => out.push({ group: 'actions', item: a, idx: i }));
results.value.patients.forEach((p, i) => out.push({ group: 'patients', item: p, idx: i }));
results.value.intakes.forEach((r, i) => out.push({ group: 'intakes', item: r, idx: i }));
results.value.appointments.forEach((a, i) => out.push({ group: 'appointments', item: a, idx: i }));
results.value.documents.forEach((d, i) => out.push({ group: 'documents', item: d, idx: i }));
results.value.services.forEach((s, i) => out.push({ group: 'services', item: s, idx: i }));
filteredPages.value.forEach((p, i) => out.push({ group: 'pages', item: p, idx: i }));
return out;
});
function findFlatIndex(group, idx) {
return flatList.value.findIndex((e) => e.group === group && e.idx === idx);
}
const hasAnyResult = computed(() =>
filteredActions.value.length
|| results.value.patients.length
|| results.value.intakes.length
|| results.value.appointments.length
|| results.value.documents.length
|| results.value.services.length
|| filteredPages.value.length
);
//
// Fetch (debounced, com controle de ordem)
//
let debounceT = null;
let searchSeq = 0;
function resetResults() {
results.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
}
watch(query, (v) => {
if (debounceT) clearTimeout(debounceT);
const q = String(v || '').trim();
if (q.length < 2) {
resetResults();
activeIndex.value = flatList.value.length ? 0 : -1;
loading.value = false;
return;
}
loading.value = true;
const mySeq = ++searchSeq;
debounceT = setTimeout(async () => {
try {
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
if (mySeq !== searchSeq) return; // resposta antiga, descarta
if (error) {
// eslint-disable-next-line no-console
console.error('[search_global] erro:', error);
resetResults();
} else {
results.value = {
patients: Array.isArray(data?.patients) ? data.patients : [],
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
documents: Array.isArray(data?.documents) ? data.documents : [],
services: Array.isArray(data?.services) ? data.services : [],
intakes: Array.isArray(data?.intakes) ? data.intakes : []
};
}
} catch (e) {
if (mySeq !== searchSeq) return;
// eslint-disable-next-line no-console
console.error('[search_global] exceção:', e);
resetResults();
} finally {
if (mySeq === searchSeq) loading.value = false;
}
activeIndex.value = flatList.value.length ? 0 : -1;
}, 200);
});
//
// Teclado
//
function focusInput() {
nextTick(() => {
const inst = inputEl.value;
const el = inst?.$el?.tagName === 'INPUT' ? inst.$el : inst?.$el?.querySelector?.('input');
el?.focus?.();
try { el?.select?.(); } catch { /* ignore */ }
});
}
function onGlobalKeydown(e) {
const isK = e.key?.toLowerCase() === 'k';
const cmd = e.ctrlKey || e.metaKey;
if (cmd && isK) {
e.preventDefault();
e.stopPropagation();
showPanel.value = true;
focusInput();
return;
}
if (e.key === 'Escape' && showPanel.value) {
showPanel.value = false;
}
}
function onInputKeydown(e) {
const n = flatList.value.length;
if (e.key === 'ArrowDown') {
if (!n) return;
e.preventDefault();
activeIndex.value = (activeIndex.value + 1 + n) % n;
} else if (e.key === 'ArrowUp') {
if (!n) return;
e.preventDefault();
activeIndex.value = (activeIndex.value - 1 + n) % n;
} else if (e.key === 'Enter') {
const entry = flatList.value[activeIndex.value];
if (entry) {
e.preventDefault();
goTo(entry);
}
} else if (e.key === 'Escape') {
showPanel.value = false;
const inst = inputEl.value;
const el = inst?.$el?.tagName === 'INPUT' ? inst.$el : inst?.$el?.querySelector?.('input');
el?.blur?.();
}
}
async function goTo(entry) {
const target = entry?.item?.to || entry?.item?.deeplink || entry?.item?.path;
if (!target) return;
showPanel.value = false;
query.value = '';
resetResults();
activeIndex.value = -1;
await router.push(target);
}
function onFocus() {
showPanel.value = true;
if (flatList.value.length && activeIndex.value < 0) activeIndex.value = 0;
}
function onDocMouseDown(e) {
if (!showPanel.value) return;
const el = rootEl.value;
if (!el) return;
if (!el.contains(e.target)) showPanel.value = false;
}
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true);
document.addEventListener('mousedown', onDocMouseDown);
});
onBeforeUnmount(() => {
if (debounceT) clearTimeout(debounceT);
window.removeEventListener('keydown', onGlobalKeydown, true);
document.removeEventListener('mousedown', onDocMouseDown);
});
//
// UI helpers
//
const kbdIsMac = typeof navigator !== 'undefined' && /Mac|iP(ad|od|hone)/i.test(navigator.platform || '');
const kbdModifier = kbdIsMac ? '⌘' : 'Ctrl';
</script>
<template>
<div ref="rootEl" class="gs-root">
<div class="gs-field">
<IconField class="gs-field__wrap">
<InputIcon class="pi pi-search gs-field__icon" />
<InputText
ref="inputEl"
v-model="query"
type="search"
autocomplete="off"
spellcheck="false"
placeholder="Buscar pacientes, agenda, documentos…"
class="gs-field__input"
@focus="onFocus"
@keydown="onInputKeydown"
/>
</IconField>
<span class="gs-field__kbd" aria-hidden="true">
<kbd>{{ kbdModifier }}</kbd>
<kbd>K</kbd>
</span>
</div>
<div v-if="showPanel" class="gs-panel" role="listbox">
<div v-if="loading" class="gs-panel__state">
<i class="pi pi-spin pi-spinner" /> buscando
</div>
<div v-else-if="query.trim().length >= 2 && !hasAnyResult" class="gs-panel__state">
Nada encontrado pra <b>{{ query }}</b>.
</div>
<template v-else>
<!-- Ações -->
<div v-if="filteredActions.length" class="gs-group">
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
<button
v-for="(a, i) in filteredActions"
:key="a.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('actions', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('actions', i)"
@click="goTo({ group: 'actions', item: a, idx: i })"
>
<span class="gs-item__icon"><i :class="a.icon" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ a.label }}</span>
<span class="gs-item__sub">{{ a.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Pacientes -->
<div v-if="results.patients.length" class="gs-group">
<div class="gs-group__title">Pacientes</div>
<button
v-for="(p, i) in results.patients"
:key="p.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('patients', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('patients', i)"
@click="goTo({ group: 'patients', item: p, idx: i })"
>
<span class="gs-item__avatar">
<img v-if="p.avatar_url" :src="p.avatar_url" :alt="p.label" />
<i v-else class="pi pi-user" />
</span>
<span class="gs-item__main">
<span class="gs-item__label">{{ p.label }}</span>
<span class="gs-item__sub">{{ p.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Cadastros pendentes (intakes) -->
<div v-if="results.intakes.length" class="gs-group">
<div class="gs-group__title">Cadastros pendentes</div>
<button
v-for="(r, i) in results.intakes"
:key="r.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('intakes', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('intakes', i)"
@click="goTo({ group: 'intakes', item: r, idx: i })"
>
<span class="gs-item__icon gs-item__icon--amber"><i class="pi pi-inbox" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ r.label }}</span>
<span class="gs-item__sub">{{ r.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Agendamentos -->
<div v-if="results.appointments.length" class="gs-group">
<div class="gs-group__title">Agendamentos</div>
<button
v-for="(a, i) in results.appointments"
:key="a.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('appointments', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('appointments', i)"
@click="goTo({ group: 'appointments', item: a, idx: i })"
>
<span class="gs-item__icon gs-item__icon--blue"><i class="pi pi-calendar" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ a.label }}</span>
<span class="gs-item__sub">{{ a.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Documentos -->
<div v-if="results.documents.length" class="gs-group">
<div class="gs-group__title">Documentos</div>
<button
v-for="(d, i) in results.documents"
:key="d.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('documents', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('documents', i)"
@click="goTo({ group: 'documents', item: d, idx: i })"
>
<span class="gs-item__icon gs-item__icon--purple"><i class="pi pi-file" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ d.label }}</span>
<span class="gs-item__sub">{{ d.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Serviços -->
<div v-if="results.services.length" class="gs-group">
<div class="gs-group__title">Serviços</div>
<button
v-for="(s, i) in results.services"
:key="s.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('services', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('services', i)"
@click="goTo({ group: 'services', item: s, idx: i })"
>
<span class="gs-item__icon gs-item__icon--orange"><i class="pi pi-briefcase" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ s.label }}</span>
<span class="gs-item__sub">{{ s.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Páginas do app -->
<div v-if="filteredPages.length" class="gs-group">
<div class="gs-group__title">Páginas</div>
<button
v-for="(p, i) in filteredPages"
:key="p.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('pages', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('pages', i)"
@click="goTo({ group: 'pages', item: p, idx: i })"
>
<span class="gs-item__icon gs-item__icon--slate"><i :class="p.icon" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ p.label }}</span>
<span class="gs-item__sub">{{ p.sublabel }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Hint -->
<div v-if="!query.trim()" class="gs-panel__hint">
<i class="pi pi-info-circle" />
Digite ao menos 2 caracteres para buscar em pacientes, agenda, documentos e serviços.
</div>
</template>
</div>
</div>
</template>
<style scoped>
.gs-root {
position: relative;
width: 100%;
max-width: 420px;
min-width: 220px;
flex: 1 1 320px;
}
.gs-field { position: relative; display: flex; align-items: center; }
.gs-field__wrap { flex: 1; min-width: 0; }
.gs-field__icon { font-size: 0.82rem; opacity: 0.6; }
.gs-field__wrap :deep(.p-inputtext) {
width: 100%;
padding: 8px 56px 8px 34px;
font-size: 0.82rem;
height: 36px;
border-radius: 10px;
background: var(--p-content-background, var(--surface-card));
border: 1px solid var(--p-content-border-color, var(--surface-border));
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.gs-field__wrap :deep(.p-inputtext:hover) {
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--surface-border));
}
.gs-field__wrap :deep(.p-inputtext:focus) {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent);
outline: none;
}
.gs-field__kbd {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
gap: 3px;
pointer-events: none;
}
.gs-field__kbd kbd {
font-family: inherit;
font-size: 0.6rem;
font-weight: 600;
padding: 2px 5px;
border-radius: 4px;
background: color-mix(in srgb, var(--text-color) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--text-color) 10%, transparent);
color: var(--text-color-secondary);
line-height: 1;
min-width: 16px;
text-align: center;
}
.gs-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 3100;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 12px;
box-shadow: 0 16px 40px -14px rgba(0, 0, 0, 0.3);
max-height: 70vh;
overflow-y: auto;
padding: 6px;
min-width: 360px;
}
.gs-panel__state {
padding: 18px;
text-align: center;
font-size: 0.82rem;
color: var(--text-color-secondary);
}
.gs-panel__hint {
padding: 10px 12px;
font-size: 0.72rem;
color: var(--text-color-secondary);
display: flex;
align-items: center;
gap: 7px;
opacity: 0.85;
}
.gs-group + .gs-group {
margin-top: 4px;
border-top: 1px solid var(--surface-border);
padding-top: 4px;
}
.gs-group__title {
padding: 8px 10px 4px;
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--text-color-secondary);
opacity: 0.7;
}
.gs-item {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
text-align: left;
font-family: inherit;
color: var(--text-color);
}
.gs-item.is-active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border-color: color-mix(in srgb, var(--primary-color) 24%, transparent);
}
.gs-item__icon,
.gs-item__avatar {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 8px;
flex-shrink: 0;
font-size: 0.82rem;
}
.gs-item__icon {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
.gs-item__icon--blue {
background: color-mix(in srgb, #60a5fa 14%, transparent);
color: #60a5fa;
}
.gs-item__icon--purple {
background: color-mix(in srgb, #c084fc 14%, transparent);
color: #c084fc;
}
.gs-item__icon--orange {
background: color-mix(in srgb, #fb923c 14%, transparent);
color: #fb923c;
}
.gs-item__icon--amber {
background: color-mix(in srgb, #f59e0b 14%, transparent);
color: #f59e0b;
}
.gs-item__icon--slate {
background: color-mix(in srgb, var(--text-color) 8%, transparent);
color: var(--text-color-secondary);
}
.gs-item__avatar {
border-radius: 50%;
overflow: hidden;
background: color-mix(in srgb, var(--text-color) 6%, transparent);
color: var(--text-color-secondary);
}
.gs-item__avatar img { width: 100%; height: 100%; object-fit: cover; }
.gs-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.gs-item__label {
font-size: 0.82rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gs-item__sub {
font-size: 0.7rem;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gs-item__go {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0;
transition: opacity 0.12s;
}
.gs-item.is-active .gs-item__go { opacity: 1; color: var(--primary-color); }
/* Responsive */
@media (max-width: 820px) { .gs-root { max-width: 260px; flex-basis: 220px; } }
/* Compact: ≤ 640px → só ícone + kbd visíveis (input textual colapsado) */
@media (max-width: 640px) {
.gs-root {
flex: 0 0 auto;
width: auto;
min-width: 0;
max-width: none;
}
.gs-field__wrap :deep(.p-inputtext) {
width: 92px;
padding: 8px 42px 8px 30px;
color: transparent;
caret-color: transparent;
cursor: pointer;
}
.gs-field__wrap :deep(.p-inputtext::placeholder) {
color: transparent;
}
/* Mantém kbd visível em compact (dica visual do atalho) */
.gs-field__kbd {
display: inline-flex;
right: 6px;
pointer-events: none;
}
.gs-field__kbd kbd {
font-size: 0.55rem;
padding: 1px 4px;
}
/* Focado: expande como overlay sobre o topbar */
.gs-root:focus-within {
position: absolute;
top: 50%;
left: 0.5rem;
right: 0.5rem;
transform: translateY(-50%);
z-index: 110;
}
.gs-root:focus-within .gs-field__wrap :deep(.p-inputtext) {
width: 100%;
padding: 8px 56px 8px 34px;
color: inherit;
caret-color: auto;
cursor: text;
background: var(--p-content-background, var(--surface-card));
}
.gs-root:focus-within .gs-field__wrap :deep(.p-inputtext::placeholder) {
color: var(--text-color-secondary);
opacity: 0.6;
}
}
</style>
+115
View File
@@ -0,0 +1,115 @@
/*
|--------------------------------------------------------------------------
| Agência PSI pagesIndex.js
|--------------------------------------------------------------------------
| Índice curado das páginas navegáveis do app. Consumido pelo GlobalSearch
| pra dar "ir pra tal página" como resultado da busca.
|
| Regras:
| `roles` vazio ou ausente todos logados veem.
| `roles` com valores apenas esses papéis veem.
| Roles reconhecidos: 'therapist', 'clinic_admin', 'tenant_admin',
| 'supervisor', 'saas_admin', 'portal_user'.
|
| Não duplique aqui o que está nas STATIC_ACTIONS do GlobalSearch
| (Agenda, Pacientes, Financeiro, Documentos, Configurações, etc.).
| O foco aqui são *sub-páginas* e telas menos frequentes, que o usuário
| raramente lembra onde ficam.
|--------------------------------------------------------------------------
*/
// Helpers opcionais pra keywords mais ricas
const kw = (...arr) => arr.flat().filter(Boolean);
export const PAGES = [
// ═══════════════════ THERAPIST (/therapist/*) ═══════════════════
{ id: 'p_t_dashboard', label: 'Dashboard', icon: 'pi pi-home', sublabel: 'Visão geral', path: '/therapist', roles: ['therapist'], keywords: kw('home','inicio','painel','dashboard') },
{ id: 'p_t_conversas', label: 'Conversas', icon: 'pi pi-comments', sublabel: 'Mensagens com pacientes', path: '/therapist/conversas', roles: ['therapist'], keywords: kw('conversas','chat','mensagens','whatsapp') },
{ id: 'p_t_agenda_rec', label: 'Recorrências da agenda', icon: 'pi pi-refresh', sublabel: 'Sessões recorrentes', path: '/therapist/agenda/recorrencias', roles: ['therapist'], keywords: kw('recorrencia','repeticao','semanal','sessao fixa') },
{ id: 'p_t_agenda_comp', label: 'Compromissos determinados', icon: 'pi pi-bookmark', sublabel: 'Sessões fixas', path: '/therapist/agenda/compromissos', roles: ['therapist'], keywords: kw('compromissos','determinado','fixo') },
{ id: 'p_t_plano', label: 'Meu plano', icon: 'pi pi-crown', sublabel: 'Assinatura e billing', path: '/therapist/meu-plano', roles: ['therapist'], keywords: kw('plano','assinatura','pagamento','mensalidade','billing') },
{ id: 'p_t_upgrade', label: 'Upgrade de plano', icon: 'pi pi-arrow-up', sublabel: 'Mudar de plano', path: '/therapist/upgrade', roles: ['therapist'], keywords: kw('upgrade','trocar','mudar plano') },
{ id: 'p_t_grupos', label: 'Grupos de pacientes', icon: 'pi pi-users', sublabel: 'Organizar pacientes em grupos', path: '/therapist/patients/grupos', roles: ['therapist'], keywords: kw('grupos','segmentacao','categorias') },
{ id: 'p_t_tags', label: 'Tags de pacientes', icon: 'pi pi-tags', sublabel: 'Etiquetas', path: '/therapist/patients/tags', roles: ['therapist'], keywords: kw('tags','etiquetas','marcadores','labels') },
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
{ id: 'p_t_relatorios', label: 'Relatórios', icon: 'pi pi-chart-bar', sublabel: 'Métricas e análises', path: '/therapist/relatorios', roles: ['therapist'], keywords: kw('relatorios','metricas','dashboards','analytics','indicadores') },
{ id: 'p_t_security', label: 'Segurança da conta', icon: 'pi pi-shield', sublabel: 'Senha, 2FA, sessões', path: '/therapist/settings/security', roles: ['therapist'], keywords: kw('seguranca','senha','2fa','autenticacao','mfa') },
{ id: 'p_t_notificacoes', label: 'Notificações', icon: 'pi pi-bell', sublabel: 'Histórico completo', path: '/therapist/notificacoes', roles: ['therapist'], keywords: kw('notificacoes','historico','alertas','avisos','inbox') },
// ═══════════════════ CLINIC ADMIN (/admin/*) ═══════════════════
{ id: 'p_c_dashboard', label: 'Dashboard (clínica)', icon: 'pi pi-home', sublabel: 'Visão geral da clínica', path: '/admin', roles: ['clinic_admin','tenant_admin'], keywords: kw('dashboard','home','clinica') },
{ id: 'p_c_features', label: 'Features da clínica', icon: 'pi pi-th-large', sublabel: 'Recursos habilitados', path: '/admin/clinic/features', roles: ['clinic_admin','tenant_admin'], keywords: kw('features','recursos','modulos','addons') },
{ id: 'p_c_professionals', label: 'Profissionais', icon: 'pi pi-user', sublabel: 'Terapeutas da clínica', path: '/admin/clinic/professionals', roles: ['clinic_admin','tenant_admin'], keywords: kw('profissionais','terapeutas','equipe','time') },
{ id: 'p_c_agenda_clinica', label: 'Agenda da clínica', icon: 'pi pi-calendar', sublabel: 'Agenda consolidada', path: '/admin/agenda/clinica', roles: ['clinic_admin','tenant_admin'], keywords: kw('agenda','clinica','global','consolidada') },
{ id: 'p_c_plano', label: 'Plano da clínica', icon: 'pi pi-crown', sublabel: 'Assinatura', path: '/admin/meu-plano', roles: ['clinic_admin','tenant_admin'], keywords: kw('plano','assinatura','billing','clinica') },
// ═══════════════════ CONFIGURAÇÕES (/configuracoes/*) ═══════════════════
{ id: 'p_cfg_agenda', label: 'Config: Agenda', icon: 'pi pi-calendar', sublabel: 'Horário de atendimento, durações', path: '/configuracoes/agenda', keywords: kw('config','agenda','horario','duracao','expediente') },
{ id: 'p_cfg_bloqueios', label: 'Config: Bloqueios', icon: 'pi pi-ban', sublabel: 'Feriados e indisponibilidades', path: '/configuracoes/bloqueios', keywords: kw('bloqueios','feriados','ferias','indisponivel','folga') },
{ id: 'p_cfg_agendador', label: 'Config: Agendador público', icon: 'pi pi-globe', sublabel: 'Configurações da página pública', path: '/configuracoes/agendador', keywords: kw('agendador','publico','online','booking','configurar') },
{ id: 'p_cfg_pagamento', label: 'Config: Formas de pagamento', icon: 'pi pi-wallet', sublabel: 'Métodos aceitos', path: '/configuracoes/pagamento', keywords: kw('pagamento','metodos','pix','cartao','boleto','dinheiro') },
{ id: 'p_cfg_descontos', label: 'Config: Descontos', icon: 'pi pi-percentage', sublabel: 'Regras de desconto', path: '/configuracoes/descontos', keywords: kw('descontos','promocao','reducao','abatimento') },
{ id: 'p_cfg_excecoes', label: 'Config: Exceções financeiras', icon: 'pi pi-exclamation-triangle', sublabel: 'Casos especiais', path: '/configuracoes/excecoes-financeiras', keywords: kw('excecoes','financeiras','casos especiais','ajustes','manuais') },
{ id: 'p_cfg_convenios', label: 'Config: Convênios', icon: 'pi pi-id-card', sublabel: 'Planos de saúde', path: '/configuracoes/convenios', keywords: kw('convenios','planos','saude','insurance','seguro') },
{ id: 'p_cfg_email', label: 'Config: Templates de e-mail', icon: 'pi pi-envelope', sublabel: 'Mensagens automáticas', path: '/configuracoes/email-templates', keywords: kw('email','templates','modelos','notificacoes','mensagens') },
{ id: 'p_cfg_empresa', label: 'Config: Empresa', icon: 'pi pi-building', sublabel: 'Dados da sua empresa', path: '/configuracoes/empresa', keywords: kw('empresa','dados','cnpj','razao social','fantasia','endereco') },
{ id: 'p_cfg_conv_tags', label: 'Config: Tags de Conversa', icon: 'pi pi-tag', sublabel: 'Etiquetas pro CRM de conversas', path: '/configuracoes/conversas-tags', keywords: kw('tags','etiquetas','conversas','crm','whatsapp','kanban') },
{ id: 'p_cfg_conv_autoreply', label: 'Config: Auto-reply WhatsApp', icon: 'pi pi-reply', sublabel: 'Resposta fora do horário', path: '/configuracoes/conversas-autoreply', keywords: kw('auto-reply','autoresposta','fora do horario','whatsapp','automatico','bot') },
{ id: 'p_cfg_conv_optouts', label: 'Config: Opt-outs (LGPD)', icon: 'pi pi-ban', sublabel: 'Números bloqueados de envios auto', path: '/configuracoes/conversas-optouts', keywords: kw('opt-out','optout','lgpd','parar','bloqueio','cancelar','compliance','anpd') },
{ id: 'p_cfg_lembretes', label: 'Config: Lembretes de Sessão', icon: 'pi pi-bell', sublabel: 'WhatsApp 24h e 2h antes', path: '/configuracoes/lembretes-sessao', keywords: kw('lembretes','sessao','whatsapp','automatico','reduzir faltas','avisar') },
{ id: 'p_cfg_creditos_wa', label: 'Config: Créditos WhatsApp', icon: 'pi pi-credit-card', sublabel: 'Saldo, pacotes, extrato', path: '/configuracoes/creditos-whatsapp', keywords: kw('creditos','whatsapp','saldo','pacote','pagamento','pix','comprar','asaas') },
{ id: 'p_cfg_canais', label: 'Config: Canais de notificação', icon: 'pi pi-bell', sublabel: 'WhatsApp, SMS, e-mail', path: '/configuracoes/canais', keywords: kw('canais','notificacoes','lembretes','whatsapp','sms','email') },
{ id: 'p_cfg_whatsapp', label: 'Config: WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Canal WhatsApp', path: '/configuracoes/whatsapp', keywords: kw('whatsapp','wa','mensageria') },
{ id: 'p_cfg_whatsapp_ofc', label: 'Config: WhatsApp Oficial AgenciaPSI', icon: 'pi pi-verified', sublabel: 'Número oficial gerenciado', path: '/configuracoes/whatsapp-oficial', keywords: kw('whatsapp','oficial','agenciapsi','business','api','meta','creditos') },
{ id: 'p_cfg_whatsapp_pes', label: 'Config: WhatsApp Pessoal', icon: 'pi pi-mobile', sublabel: 'Conectar via QR code', path: '/configuracoes/whatsapp-pessoal', keywords: kw('whatsapp','pessoal','qr','gratis','evolution','baileys') },
{ id: 'p_cfg_sms', label: 'Config: SMS', icon: 'pi pi-mobile', sublabel: 'Canal SMS', path: '/configuracoes/sms', keywords: kw('sms','torpedo','texto') },
{ id: 'p_cfg_sms_canal', label: 'Config: Canal SMS', icon: 'pi pi-mobile', sublabel: 'Provedor e número', path: '/configuracoes/sms-canal', keywords: kw('sms','canal','provedor') },
{ id: 'p_cfg_extras', label: 'Config: Recursos extras', icon: 'pi pi-plus-circle', sublabel: 'Addons e créditos', path: '/configuracoes/recursos-extras', keywords: kw('recursos','extras','addons','creditos','saldo') },
{ id: 'p_cfg_auditoria', label: 'Config: Auditoria', icon: 'pi pi-history', sublabel: 'Log de ações', path: '/configuracoes/auditoria', keywords: kw('auditoria','log','historico','acoes') },
// ═══════════════════ CONTA (/account/*) ═══════════════════
{ id: 'p_acc_profile', label: 'Meu perfil', icon: 'pi pi-user', sublabel: 'Dados pessoais', path: '/account/profile', keywords: kw('perfil','profile','meus dados','nome','foto','bio') },
{ id: 'p_acc_negocio', label: 'Meu negócio', icon: 'pi pi-briefcase', sublabel: 'Dados profissionais', path: '/account/negocio', keywords: kw('negocio','profissional','crp','atuacao','especialidade') },
{ id: 'p_acc_security', label: 'Segurança (conta)', icon: 'pi pi-shield', sublabel: 'Senha e autenticação', path: '/account/security', keywords: kw('seguranca','senha','password','2fa') }
];
/**
* Filtra páginas pelo papel ativo do usuário.
* @param {string|null} role - 'therapist'|'clinic_admin'|'tenant_admin'|'supervisor'|'saas_admin'|'portal_user'
* @returns {Array}
*/
export function filterPagesForRole(role) {
if (!role) return PAGES.filter((p) => !p.roles || p.roles.length === 0);
return PAGES.filter((p) => !p.roles || p.roles.length === 0 || p.roles.includes(role));
}
/**
* Normaliza string pra busca fuzzy simples (remove acento, minúsculo, trim).
*/
function _norm(s) {
return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
}
/**
* Busca fuzzy em páginas filtradas pelo papel.
* @param {string} q
* @param {string|null} role
* @param {number} limit
*/
export function searchPages(q, role, limit = 6) {
const query = _norm(q);
const pool = filterPagesForRole(role);
if (!query) return pool.slice(0, limit); // top N quando vazio
return pool
.filter((p) => {
const hay = _norm(p.label + ' ' + p.sublabel + ' ' + (p.keywords || []).join(' '));
return hay.includes(query);
})
.slice(0, limit);
}
+248
View File
@@ -0,0 +1,248 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI Editor de emails polimórfico
|--------------------------------------------------------------------------
| Uso:
| <ContactEmailsEditor entity-type="patient" :entity-id="patient.id" />
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, watch, reactive } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useContactEmails } from '@/composables/useContactEmails';
const props = defineProps({
entityType: { type: String, required: true },
entityId: { type: String, required: false, default: null },
readonly: { type: Boolean, default: false },
confirmGroup: { type: String, default: '' }
});
const emit = defineEmits(['change']);
const toast = useToast();
const confirm = useConfirm();
const api = useContactEmails();
const showAddForm = ref(false);
const newForm = reactive({ contact_email_type_id: null, email: '', notes: '' });
function openAddForm() {
const defaultType = api.typeBySlug('principal') || api.types.value[0];
newForm.contact_email_type_id = defaultType?.id || null;
newForm.email = '';
newForm.notes = '';
showAddForm.value = true;
}
function cancelAddForm() {
showAddForm.value = false;
}
async function submitAddForm() {
if (!newForm.contact_email_type_id || !newForm.email.trim()) return;
const res = await api.addEmail(props.entityType, props.entityId, {
contact_email_type_id: newForm.contact_email_type_id,
email: newForm.email,
is_primary: api.emails.value.length === 0,
notes: newForm.notes || null
});
if (res.ok) {
showAddForm.value = false;
toast.add({ severity: 'success', summary: 'Email adicionado', life: 1800 });
emit('change', api.emails.value);
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
const editingId = ref(null);
const editForm = reactive({ contact_email_type_id: null, email: '', notes: '' });
function startEdit(email) {
editingId.value = email.id;
editForm.contact_email_type_id = email.contact_email_type_id;
editForm.email = email.email;
editForm.notes = email.notes || '';
}
function cancelEdit() {
editingId.value = null;
}
async function saveEdit(email) {
const res = await api.updateEmail(props.entityType, props.entityId, email.id, {
contact_email_type_id: editForm.contact_email_type_id,
email: editForm.email,
notes: editForm.notes.trim() || null
});
if (res.ok) {
editingId.value = null;
toast.add({ severity: 'success', summary: 'Email atualizado', life: 1800 });
emit('change', api.emails.value);
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
async function setPrimary(email) {
if (email.is_primary) return;
const res = await api.setPrimary(props.entityType, props.entityId, email.id);
if (res.ok) {
toast.add({ severity: 'success', summary: 'Email principal atualizado', life: 1800 });
emit('change', api.emails.value);
}
}
function confirmRemove(email) {
const typeName = api.typeById(email.contact_email_type_id)?.name || 'email';
confirm.require({
group: props.confirmGroup || undefined,
message: `Remover este ${typeName}${email.is_primary ? ' (principal)' : ''}?`,
header: 'Remover email',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
const res = await api.removeEmail(props.entityType, props.entityId, email.id);
if (res.ok) {
toast.add({ severity: 'success', summary: 'Removido', life: 1800 });
emit('change', api.emails.value);
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
});
}
onMounted(async () => {
await api.loadTypes();
if (props.entityId) await api.loadEmails(props.entityType, props.entityId);
});
watch(() => props.entityId, async (v) => {
if (v) await api.loadEmails(props.entityType, v);
else api.emails.value = [];
});
</script>
<template>
<div class="flex flex-col gap-2">
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
<span>
Marque um email como <strong>principal</strong> ele é usado pra
<strong>envio de faturas, templates e notificações por email</strong>.
</span>
</div>
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!api.emails.value.length && !showAddForm" class="text-xs text-center py-4 italic text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-md">
Nenhum email cadastrado.
</div>
<div v-else class="flex flex-col gap-1.5">
<div
v-for="email in api.emails.value"
:key="email.id"
class="flex items-center gap-2 p-2 rounded-md border border-[var(--surface-border)]"
:class="email.is_primary ? 'bg-[var(--primary-color)]/5 border-[var(--primary-color)]/30' : 'bg-[var(--surface-card)]'"
>
<template v-if="editingId !== email.id">
<div class="w-8 h-8 rounded grid place-items-center shrink-0 bg-[var(--surface-ground)]">
<i :class="api.typeById(email.contact_email_type_id)?.icon || 'pi pi-envelope'" class="text-sm text-[var(--text-color-secondary)]" />
</div>
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">{{ api.typeById(email.contact_email_type_id)?.name || 'Email' }}</span>
<span v-if="email.is_primary" class="inline-flex items-center px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-[var(--primary-color)] text-white">Principal</span>
</div>
<a :href="`mailto:${email.email}`" class="text-sm font-mono truncate text-[var(--primary-color)] hover:underline" @click.stop>{{ email.email }}</a>
<span v-if="email.notes" class="text-[0.7rem] italic text-[var(--text-color-secondary)]">{{ email.notes }}</span>
</div>
<div v-if="!readonly" class="flex items-center gap-0.5 shrink-0">
<Button v-if="!email.is_primary" icon="pi pi-star" text size="small" class="h-7 w-7" v-tooltip.top="'Marcar como principal'" :disabled="api.saving.value" @click="setPrimary(email)" />
<Button icon="pi pi-pencil" text size="small" class="h-7 w-7" v-tooltip.top="'Editar'" :disabled="api.saving.value" @click="startEdit(email)" />
<Button icon="pi pi-trash" text severity="danger" size="small" class="h-7 w-7" v-tooltip.top="'Remover'" :disabled="api.saving.value" @click="confirmRemove(email)" />
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1.5 flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<Select
v-model="editForm.contact_email_type_id"
:options="api.types.value"
optionLabel="name"
optionValue="id"
class="text-xs"
style="width: 140px"
appendTo="body"
>
<template #option="slotProps">
<div class="flex items-center gap-1.5">
<i :class="slotProps.option.icon || 'pi pi-envelope'" class="text-xs" />
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
<InputText v-model="editForm.email" type="email" class="flex-1 text-sm font-mono" style="min-width: 200px" />
</div>
<InputText v-model="editForm.notes" placeholder="Observação (opcional)" class="w-full text-xs" :maxlength="200" />
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button icon="pi pi-check" severity="primary" size="small" class="h-7 w-7" v-tooltip.top="'Salvar'" :loading="api.saving.value" @click="saveEdit(email)" />
<Button icon="pi pi-times" severity="secondary" text size="small" class="h-7 w-7" v-tooltip.top="'Cancelar'" :disabled="api.saving.value" @click="cancelEdit" />
</div>
</template>
</div>
</div>
<div v-if="showAddForm" class="flex flex-col gap-2 p-2 rounded-md border border-dashed border-[var(--primary-color)]/50 bg-[var(--primary-color)]/5">
<div class="flex items-center gap-1.5 flex-wrap">
<Select
v-model="newForm.contact_email_type_id"
:options="api.types.value"
optionLabel="name"
optionValue="id"
class="text-xs"
style="width: 140px"
appendTo="body"
>
<template #option="slotProps">
<div class="flex items-center gap-1.5">
<i :class="slotProps.option.icon || 'pi pi-envelope'" class="text-xs" />
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
<InputText v-model="newForm.email" type="email" placeholder="nome@exemplo.com" class="flex-1 text-sm font-mono" style="min-width: 200px" autofocus />
</div>
<InputText v-model="newForm.notes" placeholder="Observação (opcional)" class="w-full text-xs" :maxlength="200" />
<div class="flex items-center gap-1 justify-end">
<Button label="Cancelar" severity="secondary" text size="small" :disabled="api.saving.value" @click="cancelAddForm" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="api.saving.value" :disabled="!newForm.contact_email_type_id || !newForm.email.trim()" @click="submitAddForm" />
</div>
</div>
<Button
v-if="!readonly && !showAddForm"
label="Adicionar email"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="self-start rounded-full"
:disabled="!props.entityId"
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar emails' : null"
@click="openAddForm"
/>
</div>
</template>
+342
View File
@@ -0,0 +1,342 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI Editor de telefones polimórfico
|--------------------------------------------------------------------------
| Uso:
| <ContactPhonesEditor entity-type="patient" :entity-id="patient.id" />
|
| Auto-salva mudanças no banco (contact_phones) via useContactPhones.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch, reactive } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useContactPhones } from '@/composables/useContactPhones';
const props = defineProps({
entityType: { type: String, required: true }, // 'patient' | 'medico'
entityId: { type: String, required: false, default: null }, // null = modo pendente (antes de criar entidade)
readonly: { type: Boolean, default: false },
confirmGroup: { type: String, default: '' } // grupo do ConfirmDialog (pra isolar de outros na página)
});
const emit = defineEmits(['change']);
const toast = useToast();
const confirm = useConfirm();
const api = useContactPhones();
// Mascaras
const MASK_MOBILE = '(99) 99999-9999';
const MASK_FIXED = '(99) 9999-9999';
function maskForType(typeId) {
const t = api.typeById(typeId);
return t?.is_mobile ? MASK_MOBILE : MASK_FIXED;
}
// Formulário de nova linha
const showAddForm = ref(false);
const newForm = reactive({ contact_type_id: null, number: '', notes: '' });
function openAddForm() {
// Default: primeiro tipo system (celular)
const defaultType = api.typeBySlug('celular') || api.types.value[0];
newForm.contact_type_id = defaultType?.id || null;
newForm.number = '';
newForm.notes = '';
showAddForm.value = true;
}
function cancelAddForm() {
showAddForm.value = false;
}
async function submitAddForm() {
if (!newForm.contact_type_id || !newForm.number.trim()) return;
const res = await api.addPhone(props.entityType, props.entityId, {
contact_type_id: newForm.contact_type_id,
number: newForm.number,
is_primary: api.phones.value.length === 0, // primeiro vira primary
notes: newForm.notes || null
});
if (res.ok) {
showAddForm.value = false;
toast.add({ severity: 'success', summary: 'Telefone adicionado', life: 1800 });
emit('change', api.phones.value);
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
// Edição inline
const editingId = ref(null);
const editForm = reactive({ contact_type_id: null, number: '', notes: '' });
function startEdit(phone) {
editingId.value = phone.id;
editForm.contact_type_id = phone.contact_type_id;
editForm.number = phone.number;
editForm.notes = phone.notes || '';
}
function cancelEdit() {
editingId.value = null;
}
async function saveEdit(phone) {
const res = await api.updatePhone(props.entityType, props.entityId, phone.id, {
contact_type_id: editForm.contact_type_id,
number: editForm.number,
notes: editForm.notes.trim() || null
});
if (res.ok) {
editingId.value = null;
toast.add({ severity: 'success', summary: 'Telefone atualizado', life: 1800 });
emit('change', api.phones.value);
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
// Principal
async function setPrimary(phone) {
if (phone.is_primary) return;
const res = await api.setPrimary(props.entityType, props.entityId, phone.id);
if (res.ok) {
toast.add({ severity: 'success', summary: 'Telefone principal atualizado', life: 1800 });
emit('change', api.phones.value);
}
}
// Remover
function confirmRemove(phone) {
const typeName = api.typeById(phone.contact_type_id)?.name || 'telefone';
confirm.require({
group: props.confirmGroup || undefined,
message: `Remover este ${typeName}${phone.is_primary ? ' (principal)' : ''}?`,
header: 'Remover telefone',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
const res = await api.removePhone(props.entityType, props.entityId, phone.id);
if (res.ok) {
toast.add({ severity: 'success', summary: 'Removido', life: 1800 });
emit('change', api.phones.value);
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
}
}
});
}
// Helpers
function formatDisplay(number) {
const s = String(number || '').replace(/\D/g, '');
if (s.length === 11) return `(${s.slice(0, 2)}) ${s.slice(2, 7)}-${s.slice(7)}`;
if (s.length === 10) return `(${s.slice(0, 2)}) ${s.slice(2, 6)}-${s.slice(6)}`;
if (s.length === 13 && s.startsWith('55')) return `+55 (${s.slice(2, 4)}) ${s.slice(4, 9)}-${s.slice(9)}`;
if (s.length === 12 && s.startsWith('55')) return `+55 (${s.slice(2, 4)}) ${s.slice(4, 8)}-${s.slice(8)}`;
return number;
}
// Lifecycle
onMounted(async () => {
await api.loadTypes();
if (props.entityId) await api.loadPhones(props.entityType, props.entityId);
});
watch(() => props.entityId, async (v) => {
if (v) await api.loadPhones(props.entityType, v);
else api.phones.value = [];
});
</script>
<template>
<div class="flex flex-col gap-2">
<!-- Aviso sobre telefone principal -->
<div class="text-[0.7rem] text-[var(--text-color-secondary)] flex items-start gap-1.5 px-1">
<i class="pi pi-info-circle text-sky-500 mt-0.5 shrink-0" />
<span>
Marque um telefone como <strong>principal</strong> ele é usado pra
<strong>cobranças, lembretes automáticos e contato padrão</strong>.
Número vindo do CRM WhatsApp recebe a etiqueta <strong>"vinculado"</strong>.
</span>
</div>
<!-- Lista de telefones -->
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-1" /> Carregando
</div>
<div v-else-if="!api.phones.value.length && !showAddForm" class="text-xs text-center py-4 italic text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-md">
Nenhum telefone cadastrado.
</div>
<div v-else class="flex flex-col gap-1.5">
<div
v-for="phone in api.phones.value"
:key="phone.id"
class="flex items-center gap-2 p-2 rounded-md border border-[var(--surface-border)]"
:class="phone.is_primary ? 'bg-[var(--primary-color)]/5 border-[var(--primary-color)]/30' : 'bg-[var(--surface-card)]'"
>
<!-- Modo leitura -->
<template v-if="editingId !== phone.id">
<!-- Ícone do tipo -->
<div class="w-8 h-8 rounded grid place-items-center shrink-0 bg-[var(--surface-ground)]">
<i :class="api.typeById(phone.contact_type_id)?.icon || 'pi pi-phone'" class="text-sm text-[var(--text-color-secondary)]" />
</div>
<!-- Tipo + número + badges -->
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">{{ api.typeById(phone.contact_type_id)?.name || 'Telefone' }}</span>
<span v-if="phone.is_primary" class="inline-flex items-center px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-[var(--primary-color)] text-white">Principal</span>
<span v-if="phone.whatsapp_linked_at" class="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-emerald-500/15 text-emerald-600">
<i class="pi pi-link text-[0.55rem]" /> Vinculado
</span>
</div>
<span class="text-sm font-mono font-semibold">{{ formatDisplay(phone.number) }}</span>
<span v-if="phone.notes" class="text-[0.7rem] italic text-[var(--text-color-secondary)]">{{ phone.notes }}</span>
</div>
<!-- Ações -->
<div v-if="!readonly" class="flex items-center gap-0.5 shrink-0">
<Button
v-if="!phone.is_primary"
icon="pi pi-star"
text
size="small"
class="h-7 w-7"
v-tooltip.top="'Marcar como principal'"
:disabled="api.saving.value"
@click="setPrimary(phone)"
/>
<Button
icon="pi pi-pencil"
text
size="small"
class="h-7 w-7"
v-tooltip.top="'Editar'"
:disabled="api.saving.value"
@click="startEdit(phone)"
/>
<Button
icon="pi pi-trash"
text
severity="danger"
size="small"
class="h-7 w-7"
v-tooltip.top="'Remover'"
:disabled="api.saving.value"
@click="confirmRemove(phone)"
/>
</div>
</template>
<!-- Modo edição -->
<template v-else>
<div class="flex flex-col gap-1.5 flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<Select
v-model="editForm.contact_type_id"
:options="api.types.value"
optionLabel="name"
optionValue="id"
class="text-xs"
style="width: 140px"
appendTo="body"
>
<template #option="slotProps">
<div class="flex items-center gap-1.5">
<i :class="slotProps.option.icon || 'pi pi-phone'" class="text-xs" />
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
<InputMask
v-model="editForm.number"
:mask="maskForType(editForm.contact_type_id)"
class="flex-1 text-sm font-mono"
style="min-width: 140px"
/>
</div>
<InputText
v-model="editForm.notes"
placeholder="Observação (opcional)"
class="w-full text-xs"
:maxlength="200"
/>
</div>
<div class="flex items-center gap-0.5 shrink-0">
<Button icon="pi pi-check" severity="primary" size="small" class="h-7 w-7" v-tooltip.top="'Salvar'" :loading="api.saving.value" @click="saveEdit(phone)" />
<Button icon="pi pi-times" severity="secondary" text size="small" class="h-7 w-7" v-tooltip.top="'Cancelar'" :disabled="api.saving.value" @click="cancelEdit" />
</div>
</template>
</div>
</div>
<!-- Formulário de nova linha -->
<div v-if="showAddForm" class="flex flex-col gap-2 p-2 rounded-md border border-dashed border-[var(--primary-color)]/50 bg-[var(--primary-color)]/5">
<div class="flex items-center gap-1.5 flex-wrap">
<Select
v-model="newForm.contact_type_id"
:options="api.types.value"
optionLabel="name"
optionValue="id"
class="text-xs"
style="width: 140px"
appendTo="body"
>
<template #option="slotProps">
<div class="flex items-center gap-1.5">
<i :class="slotProps.option.icon || 'pi pi-phone'" class="text-xs" />
<span>{{ slotProps.option.name }}</span>
</div>
</template>
</Select>
<InputMask
v-model="newForm.number"
:mask="maskForType(newForm.contact_type_id)"
class="flex-1 text-sm font-mono"
style="min-width: 140px"
autofocus
/>
</div>
<InputText
v-model="newForm.notes"
placeholder="Observação (opcional)"
class="w-full text-xs"
:maxlength="200"
/>
<div class="flex items-center gap-1 justify-end">
<Button label="Cancelar" severity="secondary" text size="small" :disabled="api.saving.value" @click="cancelAddForm" />
<Button
label="Adicionar"
icon="pi pi-check"
size="small"
:loading="api.saving.value"
:disabled="!newForm.contact_type_id || !newForm.number.trim()"
@click="submitAddForm"
/>
</div>
</div>
<!-- Botão + -->
<Button
v-if="!readonly && !showAddForm"
label="Adicionar telefone"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="self-start rounded-full"
:disabled="!props.entityId"
v-tooltip.right="!props.entityId ? 'Salve o cadastro primeiro pra adicionar telefones' : null"
@click="openAddForm"
/>
</div>
</template>
@@ -116,6 +116,32 @@ async function onCreated(data) {
@click="pageRef?.fillRandomPatient?.()"
/>
<!-- Conversar no WhatsApp ( em edição) -->
<Button
v-if="patientId"
icon="pi pi-whatsapp"
severity="success"
outlined
size="small"
class="rounded-full"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
title="Conversar no WhatsApp"
@click="pageRef?.goToConversation?.(); isOpen = false;"
/>
<!-- Exportar LGPD ( em edição) -->
<Button
v-if="patientId"
icon="pi pi-shield"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
title="Exportar dados do paciente (LGPD)"
@click="pageRef?.openLgpdDialog?.()"
/>
<!-- Excluir ( em edição) -->
<Button
v-if="patientId"
+209
View File
@@ -0,0 +1,209 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAddonExtrato.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers de período ─────────────────────────────────────────────────────
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function endOfDay(d) {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function resolveDateRange(preset, customRange) {
const now = new Date();
if (preset === 'thisMonth') {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'lastMonth') {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 0);
return { from: startOfDay(start), to: endOfDay(end) };
}
if (preset === 'last90') {
const start = new Date(now);
start.setDate(start.getDate() - 90);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'custom' && Array.isArray(customRange) && customRange[0] && customRange[1]) {
return { from: startOfDay(customRange[0]), to: endOfDay(customRange[1]) };
}
// fallback: thisMonth
const start = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: startOfDay(start), to: endOfDay(now) };
}
// ─── sanitização de busca ───────────────────────────────────────────────────
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
const trimmed = raw.trim();
if (!trimmed) return '';
// limite defensivo para não enviar termos absurdos pro client-side filter
return trimmed.slice(0, 120).toLowerCase();
}
// ─── composable ─────────────────────────────────────────────────────────────
export function useAddonExtrato() {
const tenantStore = useTenantStore();
const transactions = ref([]);
const balances = ref({}); // { sms: {balance, total_purchased, total_consumed}, ... }
const loading = ref(false);
const error = ref(null);
const filters = ref({
periodPreset: 'thisMonth',
customRange: null, // [Date, Date]
addonTypes: [], // [] = todos
movementTypes: [], // [] = todos
search: ''
});
const dateRange = computed(() => resolveDateRange(filters.value.periodPreset, filters.value.customRange));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
transactions.value = [];
error.value = new Error('Tenant ativo inválido.');
return;
}
loading.value = true;
error.value = null;
try {
const { from, to } = dateRange.value;
let query = supabase
.from('addon_transactions')
.select('id, created_at, addon_type, type, amount, balance_before, balance_after, description, payment_method, payment_reference, price_cents, currency, queue_id')
.eq('tenant_id', tenantId)
.gte('created_at', from.toISOString())
.lte('created_at', to.toISOString())
.order('created_at', { ascending: false })
.limit(5000);
if (filters.value.addonTypes.length > 0) {
query = query.in('addon_type', filters.value.addonTypes);
}
if (filters.value.movementTypes.length > 0) {
query = query.in('type', filters.value.movementTypes);
}
const { data, error: qErr } = await query;
if (qErr) throw qErr;
transactions.value = data ?? [];
} catch (err) {
error.value = err;
transactions.value = [];
} finally {
loading.value = false;
}
}
async function loadBalances() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
balances.value = {};
return;
}
const { data } = await supabase
.from('addon_credits')
.select('addon_type, balance, total_purchased, total_consumed, low_balance_threshold, expires_at')
.eq('tenant_id', tenantId)
.eq('is_active', true);
const map = {};
for (const c of data ?? []) map[c.addon_type] = c;
balances.value = map;
}
// filtro client-side de busca textual (sobre server-side result)
const rows = computed(() => {
const q = sanitizeSearch(filters.value.search);
if (!q) return transactions.value;
return transactions.value.filter((r) => {
const ref = (r.payment_reference || '').toLowerCase();
const desc = (r.description || '').toLowerCase();
const method = (r.payment_method || '').toLowerCase();
return ref.includes(q) || desc.includes(q) || method.includes(q);
});
});
const summary = computed(() => {
let purchasedCredits = 0;
let purchasedCents = 0;
let consumedCredits = 0;
let adjustedCredits = 0;
let refundedCredits = 0;
for (const r of rows.value) {
const amt = Number(r.amount) || 0;
switch (r.type) {
case 'purchase':
purchasedCredits += Math.abs(amt);
purchasedCents += Number(r.price_cents) || 0;
break;
case 'consumption':
consumedCredits += Math.abs(amt);
break;
case 'adjustment':
adjustedCredits += amt; // mantém sinal
break;
case 'refund':
refundedCredits += Math.abs(amt);
break;
default:
break;
}
}
return {
purchasedCredits,
purchasedCents,
consumedCredits,
adjustedCredits,
refundedCredits,
totalRows: rows.value.length
};
});
return {
transactions,
rows,
balances,
filters,
dateRange,
loading,
error,
summary,
load,
loadBalances
};
}
+206
View File
@@ -0,0 +1,206 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAuditoria.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── helpers ────────────────────────────────────────────────────────────────
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function endOfDay(d) {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function resolveDateRange(preset, customRange) {
const now = new Date();
if (preset === 'today') {
return { from: startOfDay(now), to: endOfDay(now) };
}
if (preset === 'last7') {
const start = new Date(now);
start.setDate(start.getDate() - 6);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'last30') {
const start = new Date(now);
start.setDate(start.getDate() - 29);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'last90') {
const start = new Date(now);
start.setDate(start.getDate() - 89);
return { from: startOfDay(start), to: endOfDay(now) };
}
if (preset === 'custom' && Array.isArray(customRange) && customRange[0] && customRange[1]) {
return { from: startOfDay(customRange[0]), to: endOfDay(customRange[1]) };
}
const start = new Date(now);
start.setDate(start.getDate() - 6);
return { from: startOfDay(start), to: endOfDay(now) };
}
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 120).toLowerCase();
}
// ─── composable ─────────────────────────────────────────────────────────────
export function useAuditoria() {
const tenantStore = useTenantStore();
const events = ref([]);
const usersMap = ref({}); // { uid: { email, display_name } }
const loading = ref(false);
const error = ref(null);
const filters = ref({
periodPreset: 'last7',
customRange: null,
sources: [], // [] = todas
entityTypes: [], // [] = todos
actions: [], // [] = todas
userId: null,
search: ''
});
const dateRange = computed(() => resolveDateRange(filters.value.periodPreset, filters.value.customRange));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
events.value = [];
error.value = new Error('Tenant ativo inválido.');
return;
}
loading.value = true;
error.value = null;
try {
const { from, to } = dateRange.value;
let query = supabase
.from('audit_log_unified')
.select('uid, tenant_id, user_id, entity_type, entity_id, action, description, occurred_at, source, details')
.eq('tenant_id', tenantId)
.gte('occurred_at', from.toISOString())
.lte('occurred_at', to.toISOString())
.order('occurred_at', { ascending: false })
.limit(5000);
if (filters.value.sources.length > 0) {
query = query.in('source', filters.value.sources);
}
if (filters.value.entityTypes.length > 0) {
query = query.in('entity_type', filters.value.entityTypes);
}
if (filters.value.actions.length > 0) {
query = query.in('action', filters.value.actions);
}
if (filters.value.userId) {
query = query.eq('user_id', filters.value.userId);
}
const { data, error: qErr } = await query;
if (qErr) throw qErr;
events.value = data ?? [];
await resolveUserNames();
} catch (err) {
error.value = err;
events.value = [];
} finally {
loading.value = false;
}
}
async function resolveUserNames() {
const uids = [...new Set(events.value.map((e) => e.user_id).filter(Boolean))];
const unknown = uids.filter((u) => !usersMap.value[u]);
if (!unknown.length) return;
const { data } = await supabase.from('profiles').select('id, full_name, nickname').in('id', unknown);
const next = { ...usersMap.value };
for (const u of unknown) next[u] = { email: '', display_name: '' };
for (const p of data ?? []) {
next[p.id] = { email: '', display_name: p.full_name || p.nickname || '' };
}
usersMap.value = next;
}
function userDisplay(userId) {
if (!userId) return 'Sistema';
const u = usersMap.value[userId];
if (!u) return 'Usuário desconhecido';
return u.display_name || u.email || userId.slice(0, 8);
}
// filtro client-side: busca textual
const rows = computed(() => {
const q = sanitizeSearch(filters.value.search);
if (!q) return events.value;
return events.value.filter((e) => {
const desc = (e.description || '').toLowerCase();
const entity = (e.entity_type || '').toLowerCase();
const action = (e.action || '').toLowerCase();
const src = (e.source || '').toLowerCase();
const user = userDisplay(e.user_id).toLowerCase();
return desc.includes(q) || entity.includes(q) || action.includes(q) || src.includes(q) || user.includes(q);
});
});
const summary = computed(() => {
const bySource = {};
const byAction = {};
const byUser = new Set();
for (const e of rows.value) {
bySource[e.source] = (bySource[e.source] || 0) + 1;
byAction[e.action] = (byAction[e.action] || 0) + 1;
if (e.user_id) byUser.add(e.user_id);
}
return {
totalRows: rows.value.length,
bySource,
byAction,
distinctUsers: byUser.size
};
});
return {
events,
rows,
filters,
dateRange,
loading,
error,
summary,
userDisplay,
load
};
}
+127
View File
@@ -0,0 +1,127 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useAutoReplySettings.js
| Data: 2026-04-21
|
| Settings de auto-reply fora do horário (CRM Grupo 2.3).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const DEFAULT_SETTINGS = {
enabled: false,
message: 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!',
cooldown_minutes: 180,
schedule_mode: 'agenda',
business_hours: [],
custom_window: []
};
export function useAutoReplySettings() {
const tenantStore = useTenantStore();
const settings = ref({ ...DEFAULT_SETTINGS });
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
const lastLoadedAt = ref(null);
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_autoreply_settings')
.select('enabled, message, cooldown_minutes, schedule_mode, business_hours, custom_window')
.eq('tenant_id', tenantId)
.maybeSingle();
if (err) throw err;
if (data) {
settings.value = {
enabled: !!data.enabled,
message: data.message ?? DEFAULT_SETTINGS.message,
cooldown_minutes: Number(data.cooldown_minutes ?? DEFAULT_SETTINGS.cooldown_minutes),
schedule_mode: data.schedule_mode ?? 'agenda',
business_hours: Array.isArray(data.business_hours) ? data.business_hours : [],
custom_window: Array.isArray(data.custom_window) ? data.custom_window : []
};
} else {
settings.value = { ...DEFAULT_SETTINGS, business_hours: [], custom_window: [] };
}
lastLoadedAt.value = new Date().toISOString();
} catch (e) {
error.value = e?.message || 'Falha ao carregar settings';
} finally {
loading.value = false;
}
}
async function save(partial = null) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'no_tenant' };
const payload = partial ? { ...settings.value, ...partial } : { ...settings.value };
// Sanitização básica
payload.message = String(payload.message || '').trim().slice(0, 2000);
if (!payload.message) return { ok: false, error: 'mensagem vazia' };
payload.cooldown_minutes = Math.max(0, Math.min(43200, Number(payload.cooldown_minutes) || 0));
if (!['agenda', 'business_hours', 'custom'].includes(payload.schedule_mode)) {
payload.schedule_mode = 'agenda';
}
payload.business_hours = Array.isArray(payload.business_hours) ? payload.business_hours : [];
payload.custom_window = Array.isArray(payload.custom_window) ? payload.custom_window : [];
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_autoreply_settings')
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
if (err) throw err;
settings.value = payload;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Falha ao salvar' };
} finally {
saving.value = false;
}
}
// Busca a agenda_regras_semanais do tenant — pra mostrar preview "seguindo agenda"
async function loadAgendaWindows() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return [];
try {
const { data } = await supabase
.from('agenda_regras_semanais')
.select('dia_semana, hora_inicio, hora_fim, ativo')
.eq('tenant_id', tenantId)
.eq('ativo', true)
.order('dia_semana');
return (data || []).map((r) => ({
dow: r.dia_semana,
start: String(r.hora_inicio).slice(0, 5),
end: String(r.hora_fim).slice(0, 5)
}));
} catch {
return [];
}
}
return {
settings,
loading,
saving,
error,
lastLoadedAt,
load,
save,
loadAgendaWindows
};
}
+239
View File
@@ -0,0 +1,239 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useClinicKPIs.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function startOfMonth(d = new Date()) {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
function endOfMonth(d = new Date()) {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
}
function addMonths(d, n) {
return new Date(d.getFullYear(), d.getMonth() + n, 1);
}
function monthLabel(d) {
return d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' });
}
export function useClinicKPIs() {
const tenantStore = useTenantStore();
const loading = ref(false);
const error = ref(null);
// totais do mês corrente
const mrrCurrentCents = ref(0); // receita recebida no mês
const overdueCents = ref(0);
const overdueCount = ref(0);
const pendingCents = ref(0);
// pacientes
const activePatients = ref(0);
const inactivePatients = ref(0);
const totalPatients = ref(0);
// sessões
const sessionsDone = ref(0);
const sessionsCancelled = ref(0);
const sessionsNoShow = ref(0);
const sessionsScheduled = ref(0);
// receita últimos 6 meses
const revenueSeries = ref([]); // [{ label, received, due }]
// top 5 pacientes (por valor recebido últimos 6 meses)
const topPatients = ref([]); // [{ patient_id, nome_completo, total }]
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
error.value = new Error('Tenant ativo inválido');
return;
}
loading.value = true;
error.value = null;
const now = new Date();
const monthStart = startOfMonth(now).toISOString();
const monthEnd = endOfMonth(now).toISOString();
const sixMonthsAgo = startOfMonth(addMonths(now, -5)).toISOString();
try {
const [finRes, pendRes, patRes, eventRes, finSeriesRes] = await Promise.all([
// 1) financial_records PAGO no mês (para MRR)
supabase
.from('financial_records')
.select('final_amount, patient_id')
.eq('tenant_id', tenantId)
.eq('status', 'paid')
.gte('paid_at', monthStart)
.lte('paid_at', monthEnd),
// 2) financial_records pending/overdue (qualquer data)
supabase
.from('financial_records')
.select('status, final_amount')
.eq('tenant_id', tenantId)
.in('status', ['pending', 'overdue']),
// 3) patients por status
supabase
.from('patients')
.select('status')
.eq('tenant_id', tenantId),
// 4) eventos de agenda no mês (para realizado/cancelado/faltou)
supabase
.from('agenda_eventos')
.select('status, tipo')
.eq('tenant_id', tenantId)
.gte('inicio_em', monthStart)
.lte('inicio_em', monthEnd)
.neq('tipo', 'bloqueio'),
// 5) financial_records pagos últimos 6 meses (série + top pacientes)
supabase
.from('financial_records')
.select('final_amount, paid_at, patient_id, patients!patient_id(nome_completo)')
.eq('tenant_id', tenantId)
.eq('status', 'paid')
.gte('paid_at', sixMonthsAgo)
.lte('paid_at', monthEnd)
]);
if (finRes.error) throw finRes.error;
if (pendRes.error) throw pendRes.error;
if (patRes.error) throw patRes.error;
if (eventRes.error) throw eventRes.error;
if (finSeriesRes.error) throw finSeriesRes.error;
// MRR do mês (em centavos ou reais? seguindo financial_records.amount está em number/int; tratar como BRL)
mrrCurrentCents.value = 0;
for (const r of finRes.data ?? []) {
mrrCurrentCents.value += Number(r.final_amount) || 0;
}
// pending / overdue
overdueCents.value = 0;
overdueCount.value = 0;
pendingCents.value = 0;
for (const r of pendRes.data ?? []) {
const v = Number(r.final_amount) || 0;
if (r.status === 'overdue') {
overdueCents.value += v;
overdueCount.value += 1;
} else if (r.status === 'pending') {
pendingCents.value += v;
}
}
// pacientes
totalPatients.value = (patRes.data ?? []).length;
activePatients.value = 0;
inactivePatients.value = 0;
for (const p of patRes.data ?? []) {
if (p.status === 'Ativo') activePatients.value += 1;
else if (p.status === 'Inativo' || p.status === 'Arquivado') inactivePatients.value += 1;
}
// sessões
sessionsDone.value = 0;
sessionsCancelled.value = 0;
sessionsNoShow.value = 0;
sessionsScheduled.value = 0;
for (const ev of eventRes.data ?? []) {
sessionsScheduled.value += 1;
if (ev.status === 'realizado') sessionsDone.value += 1;
else if (ev.status === 'cancelado') sessionsCancelled.value += 1;
else if (ev.status === 'faltou') sessionsNoShow.value += 1;
}
// série 6 meses + top 5 pacientes
const monthBuckets = {};
for (let i = 5; i >= 0; i--) {
const d = startOfMonth(addMonths(now, -i));
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
monthBuckets[key] = { label: monthLabel(d), received: 0 };
}
const patientTotals = new Map();
for (const r of finSeriesRes.data ?? []) {
if (!r.paid_at) continue;
const d = new Date(r.paid_at);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const v = Number(r.final_amount) || 0;
if (monthBuckets[key]) {
monthBuckets[key].received += v;
}
if (r.patient_id) {
const prev = patientTotals.get(r.patient_id) || { nome: r.patients?.nome_completo || '—', total: 0 };
patientTotals.set(r.patient_id, { nome: prev.nome, total: prev.total + v });
}
}
revenueSeries.value = Object.values(monthBuckets);
topPatients.value = [...patientTotals.entries()]
.map(([patient_id, v]) => ({ patient_id, nome_completo: v.nome, total: v.total }))
.sort((a, b) => b.total - a.total)
.slice(0, 5);
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
const avgTicket = computed(() => {
if (sessionsDone.value === 0) return 0;
return mrrCurrentCents.value / sessionsDone.value;
});
const noShowRate = computed(() => {
const closed = sessionsDone.value + sessionsCancelled.value + sessionsNoShow.value;
if (closed === 0) return null;
return Math.round((sessionsNoShow.value / closed) * 100);
});
return {
loading,
error,
mrrCurrentCents,
overdueCents,
overdueCount,
pendingCents,
activePatients,
inactivePatients,
totalPatients,
sessionsDone,
sessionsCancelled,
sessionsNoShow,
sessionsScheduled,
avgTicket,
noShowRate,
revenueSeries,
topPatients,
load
};
}
+185
View File
@@ -0,0 +1,185 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useContactEmails.js
| Data: 2026-04-21
|
| Gerenciamento polimórfico de emails (patients, medicos, etc).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i;
function normalizeEmail(raw) {
return String(raw || '').trim().toLowerCase();
}
export function useContactEmails() {
const tenantStore = useTenantStore();
const types = ref([]);
const emails = ref([]);
const loading = ref(false);
const saving = ref(false);
async function loadTypes() {
try {
const { data } = await supabase
.from('contact_email_types')
.select('id, tenant_id, name, slug, icon, is_system, position')
.order('position', { ascending: true })
.order('name', { ascending: true });
types.value = data || [];
} catch (e) {
console.error('[useContactEmails] loadTypes:', e?.message);
types.value = [];
}
}
async function loadEmails(entityType, entityId) {
if (!entityType || !entityId) {
emails.value = [];
return;
}
loading.value = true;
try {
const { data, error } = await supabase
.from('contact_emails')
.select('id, contact_email_type_id, email, is_primary, notes, position, created_at')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('is_primary', { ascending: false })
.order('position', { ascending: true });
if (error) throw error;
emails.value = data || [];
} catch (e) {
console.error('[useContactEmails] loadEmails:', e?.message);
emails.value = [];
} finally {
loading.value = false;
}
}
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase
.from('contact_emails')
.update({ is_primary: false })
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.eq('is_primary', true);
if (exceptId) q.neq('id', exceptId);
await q;
}
async function addEmail(entityType, entityId, { contact_email_type_id, email, is_primary = false, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const clean = normalizeEmail(email);
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
if (!contact_email_type_id) return { ok: false, error: 'Tipo obrigatório' };
if (!clean || !EMAIL_RE.test(clean)) return { ok: false, error: 'Email inválido' };
saving.value = true;
try {
if (is_primary) {
await unsetOtherPrimaries(entityType, entityId);
} else if (emails.value.length === 0) {
is_primary = true;
}
const maxPos = emails.value.reduce((m, e) => Math.max(m, e.position || 0), 0);
const { data, error } = await supabase
.from('contact_emails')
.insert({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_email_type_id,
email: clean,
is_primary,
notes,
position: maxPos + 10
})
.select()
.single();
if (error) throw error;
await loadEmails(entityType, entityId);
return { ok: true, email: data };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function updateEmail(entityType, entityId, id, patch) {
saving.value = true;
try {
const sanitized = { ...patch };
if (sanitized.email !== undefined) {
sanitized.email = normalizeEmail(sanitized.email);
if (!sanitized.email || !EMAIL_RE.test(sanitized.email)) {
return { ok: false, error: 'Email inválido' };
}
}
if (sanitized.is_primary === true) {
await unsetOtherPrimaries(entityType, entityId, id);
}
const { error } = await supabase.from('contact_emails').update(sanitized).eq('id', id);
if (error) throw error;
await loadEmails(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
async function removeEmail(entityType, entityId, id) {
saving.value = true;
try {
const wasPrimary = emails.value.find((e) => e.id === id)?.is_primary;
const { error } = await supabase.from('contact_emails').delete().eq('id', id);
if (error) throw error;
if (wasPrimary) {
const remaining = emails.value.filter((e) => e.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) {
await supabase.from('contact_emails').update({ is_primary: true }).eq('id', remaining[0].id);
}
}
await loadEmails(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'remove_failed' };
} finally {
saving.value = false;
}
}
async function setPrimary(entityType, entityId, id) {
return updateEmail(entityType, entityId, id, { is_primary: true });
}
function typeBySlug(slug) { return types.value.find((t) => t.slug === slug); }
function typeById(id) { return types.value.find((t) => t.id === id); }
return {
types,
emails,
loading,
saving,
loadTypes,
loadEmails,
addEmail,
updateEmail,
removeEmail,
setPrimary,
typeBySlug,
typeById
};
}
+199
View File
@@ -0,0 +1,199 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useContactPhones.js
| Data: 2026-04-21
|
| Gerenciamento polimórfico de telefones (patients, medicos, etc).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function normalizeDigits(raw) {
return String(raw || '').replace(/\D/g, '');
}
export function useContactPhones() {
const tenantStore = useTenantStore();
const types = ref([]); // contact_types (system + custom do tenant)
const phones = ref([]); // contact_phones da entidade atual
const loading = ref(false);
const saving = ref(false);
async function loadTypes() {
try {
const { data } = await supabase
.from('contact_types')
.select('id, tenant_id, name, slug, icon, is_mobile, is_system, position')
.order('position', { ascending: true })
.order('name', { ascending: true });
types.value = data || [];
} catch (e) {
console.error('[useContactPhones] loadTypes:', e?.message);
types.value = [];
}
}
async function loadPhones(entityType, entityId) {
if (!entityType || !entityId) {
phones.value = [];
return;
}
loading.value = true;
try {
const { data, error } = await supabase
.from('contact_phones')
.select('id, contact_type_id, number, is_primary, whatsapp_linked_at, notes, position, created_at')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('is_primary', { ascending: false })
.order('position', { ascending: true });
if (error) throw error;
phones.value = data || [];
} catch (e) {
console.error('[useContactPhones] loadPhones:', e?.message);
phones.value = [];
} finally {
loading.value = false;
}
}
// Ensure só 1 primary por entidade — seta outros pra false antes de inserir/atualizar
async function unsetOtherPrimaries(entityType, entityId, exceptId = null) {
const q = supabase
.from('contact_phones')
.update({ is_primary: false })
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.eq('is_primary', true);
if (exceptId) q.neq('id', exceptId);
await q;
}
async function addPhone(entityType, entityId, { contact_type_id, number, is_primary = false, whatsapp_linked_at = null, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const digits = normalizeDigits(number);
if (!tenantId || !entityType || !entityId) return { ok: false, error: 'invalid_context' };
if (!contact_type_id) return { ok: false, error: 'Tipo de contato obrigatório' };
if (!digits || digits.length < 8 || digits.length > 15) return { ok: false, error: 'Telefone inválido' };
saving.value = true;
try {
// Se marcou como primary, desmarca outros
if (is_primary) {
await unsetOtherPrimaries(entityType, entityId);
} else if (phones.value.length === 0) {
// Primeiro telefone → vira primary automaticamente
is_primary = true;
}
const maxPos = phones.value.reduce((m, p) => Math.max(m, p.position || 0), 0);
const { data, error } = await supabase
.from('contact_phones')
.insert({
tenant_id: tenantId,
entity_type: entityType,
entity_id: entityId,
contact_type_id,
number: digits,
is_primary,
whatsapp_linked_at,
notes,
position: maxPos + 10
})
.select()
.single();
if (error) throw error;
await loadPhones(entityType, entityId);
return { ok: true, phone: data };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function updatePhone(entityType, entityId, id, patch) {
saving.value = true;
try {
const sanitized = { ...patch };
if (sanitized.number !== undefined) sanitized.number = normalizeDigits(sanitized.number);
if (sanitized.number && (sanitized.number.length < 8 || sanitized.number.length > 15)) {
return { ok: false, error: 'Telefone inválido' };
}
if (sanitized.is_primary === true) {
await unsetOtherPrimaries(entityType, entityId, id);
}
const { error } = await supabase
.from('contact_phones')
.update(sanitized)
.eq('id', id);
if (error) throw error;
await loadPhones(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
async function removePhone(entityType, entityId, id) {
saving.value = true;
try {
const wasPrimary = phones.value.find((p) => p.id === id)?.is_primary;
const { error } = await supabase.from('contact_phones').delete().eq('id', id);
if (error) throw error;
// Se removeu o primary, promove o próximo pra primary
if (wasPrimary) {
const remaining = phones.value.filter((p) => p.id !== id).sort((a, b) => (a.position || 0) - (b.position || 0));
if (remaining.length > 0) {
await supabase
.from('contact_phones')
.update({ is_primary: true })
.eq('id', remaining[0].id);
}
}
await loadPhones(entityType, entityId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'remove_failed' };
} finally {
saving.value = false;
}
}
async function setPrimary(entityType, entityId, id) {
return updatePhone(entityType, entityId, id, { is_primary: true });
}
function typeBySlug(slug) {
return types.value.find((t) => t.slug === slug);
}
function typeById(id) {
return types.value.find((t) => t.id === id);
}
return {
types,
phones,
loading,
saving,
loadTypes,
loadPhones,
addPhone,
updatePhone,
removePhone,
setPrimary,
typeBySlug,
typeById
};
}
@@ -0,0 +1,154 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationAssignment.js
| Data: 2026-04-21
|
| Atribuicao de thread de conversa a um terapeuta/membro do tenant.
| Uma linha por (tenant_id, thread_key). Reatribuir = UPSERT.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
export function useConversationAssignment() {
const tenantStore = useTenantStore();
const assignment = ref(null); // { assigned_to, assigned_by, assigned_at, _assignee_name }
const members = ref([]); // lista de membros do tenant (pra Select)
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
async function loadMembers() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) { members.value = []; return; }
try {
const { data, error: err } = await supabase
.from('v_tenant_members_with_profiles')
.select('user_id, full_name, email, role')
.eq('tenant_id', tenantId)
.in('role', ['tenant_admin', 'therapist', 'secretary'])
.eq('status', 'active')
.order('full_name', { ascending: true });
if (err) throw err;
members.value = (data || []).map((m) => ({
user_id: m.user_id,
label: m.full_name || m.email || m.user_id,
email: m.email,
role: m.role
}));
} catch (e) {
error.value = e?.message || 'Falha ao carregar membros';
members.value = [];
}
}
async function load(threadKey) {
if (!threadKey) { assignment.value = null; return; }
const tenantId = tenantStore.activeTenantId;
if (!tenantId) { assignment.value = null; return; }
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_assignments')
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.maybeSingle();
if (err) throw err;
if (!data || !data.assigned_to) {
assignment.value = data || null;
return;
}
// Resolve nome do assignee via membros (se carregado) ou profiles
let assigneeName = null;
const hit = members.value.find((m) => m.user_id === data.assigned_to);
if (hit) assigneeName = hit.label;
if (!assigneeName) {
const { data: u } = await supabase.from('profiles').select('full_name').eq('id', data.assigned_to).maybeSingle();
assigneeName = u?.full_name || null;
}
assignment.value = { ...data, _assignee_name: assigneeName };
} catch (e) {
error.value = e?.message || 'Falha ao carregar atribuicao';
assignment.value = null;
} finally {
loading.value = false;
}
}
async function assign({ threadKey, patientId = null, contactNumber = null, assignedTo }) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !threadKey) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const payload = {
tenant_id: tenantId,
thread_key: threadKey,
patient_id: patientId || null,
contact_number: contactNumber || null,
assigned_to: assignedTo || null,
assigned_by: userId,
assigned_at: new Date().toISOString()
};
const { data, error: err } = await supabase
.from('conversation_assignments')
.upsert(payload, { onConflict: 'tenant_id,thread_key' })
.select('tenant_id, thread_key, patient_id, contact_number, assigned_to, assigned_by, assigned_at')
.single();
if (err) throw err;
let assigneeName = null;
if (data.assigned_to) {
const hit = members.value.find((m) => m.user_id === data.assigned_to);
assigneeName = hit?.label || null;
if (!assigneeName) {
const { data: u } = await supabase.from('profiles').select('full_name').eq('id', data.assigned_to).maybeSingle();
assigneeName = u?.full_name || null;
}
}
assignment.value = { ...data, _assignee_name: assigneeName };
return { ok: true, assignment: assignment.value };
} catch (e) {
return { ok: false, error: e?.message || 'assign_failed' };
} finally {
saving.value = false;
}
}
async function unassign({ threadKey, patientId = null, contactNumber = null }) {
return assign({ threadKey, patientId, contactNumber, assignedTo: null });
}
function clear() {
assignment.value = null;
error.value = null;
}
return {
assignment,
members,
loading,
saving,
error,
loadMembers,
load,
assign,
unassign,
clear
};
}
+178
View File
@@ -0,0 +1,178 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationNotes.js
| Data: 2026-04-21
|
| Notas internas por thread de conversa. Carregadas sob demanda quando o
| drawer da conversa abre.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function sanitizeBody(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 4000);
}
export function useConversationNotes() {
const tenantStore = useTenantStore();
const notes = ref([]);
const loading = ref(false);
const saving = ref(false);
const error = ref(null);
const count = computed(() => notes.value.length);
async function load(threadKey) {
if (!threadKey) {
notes.value = [];
return;
}
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
notes.value = [];
return;
}
loading.value = true;
error.value = null;
try {
const { data, error: err } = await supabase
.from('conversation_notes')
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (err) throw err;
// Busca nomes dos criadores (1 query só)
const rows = data || [];
const userIds = [...new Set(rows.map((r) => r.created_by).filter(Boolean))];
let nameMap = {};
if (userIds.length) {
const { data: users } = await supabase
.from('profiles')
.select('id, full_name')
.in('id', userIds);
nameMap = Object.fromEntries((users || []).map((u) => [u.id, u.full_name || '']));
}
notes.value = rows.map((r) => ({ ...r, _author_name: nameMap[r.created_by] || null }));
} catch (e) {
error.value = e?.message || 'Falha ao carregar notas';
notes.value = [];
} finally {
loading.value = false;
}
}
async function create({ threadKey, patientId = null, contactNumber = null, body }) {
const tenantId = tenantStore.activeTenantId;
const clean = sanitizeBody(body);
if (!tenantId || !threadKey || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const { data, error: err } = await supabase
.from('conversation_notes')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
patient_id: patientId,
contact_number: contactNumber,
body: clean,
created_by: userId
})
.select('id, thread_key, patient_id, contact_number, body, created_by, created_at, updated_at')
.single();
if (err) throw err;
// Prepend (mais recente primeiro)
notes.value = [{ ...data, _author_name: null }, ...notes.value];
// Recarrega o display_name do autor novo
const { data: u } = await supabase
.from('profiles')
.select('full_name')
.eq('id', data.created_by)
.maybeSingle();
if (u?.full_name) {
const item = notes.value.find((n) => n.id === data.id);
if (item) item._author_name = u.full_name;
}
return { ok: true, note: data };
} catch (e) {
return { ok: false, error: e?.message || 'insert_failed' };
} finally {
saving.value = false;
}
}
async function update(id, body) {
const clean = sanitizeBody(body);
if (!id || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_notes')
.update({ body: clean })
.eq('id', id);
if (err) throw err;
const item = notes.value.find((n) => n.id === id);
if (item) {
item.body = clean;
item.updated_at = new Date().toISOString();
}
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
async function remove(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const { error: err } = await supabase
.from('conversation_notes')
.update({ deleted_at: new Date().toISOString() })
.eq('id', id);
if (err) throw err;
notes.value = notes.value.filter((n) => n.id !== id);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
function clear() {
notes.value = [];
error.value = null;
}
return {
notes,
count,
loading,
saving,
error,
load,
create,
update,
remove,
clear
};
}
+203
View File
@@ -0,0 +1,203 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationOptouts.js
| Data: 2026-04-21
|
| Gerencia opt-outs do CRM WhatsApp (LGPD Art. 18 Sec.2).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function normalizePhoneBR(raw) {
if (!raw) return '';
const digits = String(raw).replace(/\D/g, '');
// Sem DDI 55 → agrega
if (digits.length === 10 || digits.length === 11) return '55' + digits;
return digits;
}
export function useConversationOptouts() {
const tenantStore = useTenantStore();
const optouts = ref([]);
const keywords = ref([]);
const loading = ref(false);
const saving = ref(false);
const activeOptouts = computed(() => optouts.value.filter((o) => !o.opted_back_in_at));
const historyOptouts = computed(() => optouts.value.filter((o) => o.opted_back_in_at));
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [optsRes, kwsRes] = await Promise.all([
supabase
.from('conversation_optouts')
.select('id, phone, patient_id, source, keyword_matched, original_message, notes, opted_out_at, opted_back_in_at, blocked_by')
.eq('tenant_id', tenantId)
.order('opted_out_at', { ascending: false }),
supabase
.from('conversation_optout_keywords')
.select('id, tenant_id, keyword, enabled, is_system')
.or(`tenant_id.is.null,tenant_id.eq.${tenantId}`)
.order('is_system', { ascending: false })
.order('keyword', { ascending: true })
]);
optouts.value = optsRes.data || [];
keywords.value = kwsRes.data || [];
// Enriquece com nome do paciente
const patIds = [...new Set(optouts.value.map((o) => o.patient_id).filter(Boolean))];
if (patIds.length) {
const { data: pats } = await supabase.from('patients').select('id, nome_completo').in('id', patIds);
const patMap = Object.fromEntries((pats || []).map((p) => [p.id, p.nome_completo]));
optouts.value = optouts.value.map((o) => ({ ...o, _patient_name: patMap[o.patient_id] || null }));
}
} catch (e) {
console.error('[useConversationOptouts] load:', e?.message);
} finally {
loading.value = false;
}
}
async function addManual({ phone, patientId = null, notes = null }) {
const tenantId = tenantStore.activeTenantId;
const cleanPhone = normalizePhoneBR(phone);
if (!tenantId || !cleanPhone) return { ok: false, error: 'invalid_params' };
if (!/^\d{6,15}$/.test(cleanPhone)) return { ok: false, error: 'invalid_phone_format' };
saving.value = true;
try {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
// Verifica se já existe ativo
const { data: existing } = await supabase
.from('conversation_optouts')
.select('id')
.eq('tenant_id', tenantId)
.eq('phone', cleanPhone)
.is('opted_back_in_at', null)
.maybeSingle();
if (existing) return { ok: false, error: 'already_opted_out' };
const { data, error } = await supabase
.from('conversation_optouts')
.insert({
tenant_id: tenantId,
phone: cleanPhone,
patient_id: patientId,
source: 'manual',
notes,
blocked_by: userId
})
.select('id, phone, patient_id, source, notes, opted_out_at, blocked_by')
.single();
if (error) throw error;
optouts.value = [{ ...data, _patient_name: null }, ...optouts.value];
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'add_failed' };
} finally {
saving.value = false;
}
}
async function restore(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const now = new Date().toISOString();
const { error } = await supabase
.from('conversation_optouts')
.update({ opted_back_in_at: now })
.eq('id', id);
if (error) throw error;
const item = optouts.value.find((o) => o.id === id);
if (item) item.opted_back_in_at = now;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'restore_failed' };
} finally {
saving.value = false;
}
}
async function addKeyword(keyword) {
const tenantId = tenantStore.activeTenantId;
const clean = String(keyword || '').trim().slice(0, 100);
if (!tenantId || !clean) return { ok: false, error: 'invalid_params' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_optout_keywords')
.insert({ tenant_id: tenantId, keyword: clean, is_system: false, enabled: true })
.select('id, tenant_id, keyword, enabled, is_system')
.single();
if (error) throw error;
keywords.value = [...keywords.value, data];
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'add_keyword_failed' };
} finally {
saving.value = false;
}
}
async function toggleKeyword(id, enabled) {
saving.value = true;
try {
const { error } = await supabase
.from('conversation_optout_keywords')
.update({ enabled })
.eq('id', id);
if (error) throw error;
const item = keywords.value.find((k) => k.id === id);
if (item) item.enabled = enabled;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'toggle_failed' };
} finally {
saving.value = false;
}
}
async function deleteKeyword(id) {
saving.value = true;
try {
const { error } = await supabase
.from('conversation_optout_keywords')
.delete()
.eq('id', id);
if (error) throw error;
keywords.value = keywords.value.filter((k) => k.id !== id);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
return {
optouts,
keywords,
activeOptouts,
historyOptouts,
loading,
saving,
load,
addManual,
restore,
addKeyword,
toggleKeyword,
deleteKeyword
};
}
+260
View File
@@ -0,0 +1,260 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useConversationTags.js
| Data: 2026-04-21
|
| Tags aplicáveis a threads de conversa (urgente, primeira consulta, etc).
| Combina tags do sistema (tenant_id NULL) com custom do tenant.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
function sanitizeName(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 40);
}
function toSlug(name) {
return sanitizeName(name)
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
export function useConversationTags() {
const tenantStore = useTenantStore();
const allTags = ref([]); // todas as tags disponíveis (system + custom)
const threadTagIds = ref(new Set()); // tag_ids aplicados na thread atual
const loading = ref(false);
const saving = ref(false);
const threadTags = computed(() =>
allTags.value.filter((t) => threadTagIds.value.has(t.id))
);
// ── Carrega todas as tags visíveis (system + custom do tenant) ────
async function loadAllTags() {
const tenantId = tenantStore.activeTenantId;
loading.value = true;
try {
// RLS filtra automaticamente: system (tenant_id IS NULL) + custom do tenant ativo
const { data, error } = await supabase
.from('conversation_tags')
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.order('position', { ascending: true })
.order('name', { ascending: true });
if (error) throw error;
allTags.value = data || [];
} catch (e) {
console.error('[useConversationTags] loadAllTags:', e?.message);
allTags.value = [];
} finally {
loading.value = false;
}
}
// ── Carrega tags aplicadas em MÚLTIPLAS threads (batch) ────
// Retorna Map<thread_key, tag_id[]> pra renderização em Kanban
async function loadForThreads(threadKeys) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !Array.isArray(threadKeys) || !threadKeys.length) return new Map();
try {
const { data, error } = await supabase
.from('conversation_thread_tags')
.select('thread_key, tag_id')
.eq('tenant_id', tenantId)
.in('thread_key', threadKeys);
if (error) throw error;
const map = new Map();
for (const row of data || []) {
if (!map.has(row.thread_key)) map.set(row.thread_key, []);
map.get(row.thread_key).push(row.tag_id);
}
return map;
} catch (e) {
console.error('[useConversationTags] loadForThreads:', e?.message);
return new Map();
}
}
// ── Carrega tags aplicadas em uma thread ────
async function loadForThread(threadKey) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !threadKey) {
threadTagIds.value = new Set();
return;
}
try {
const { data, error } = await supabase
.from('conversation_thread_tags')
.select('tag_id')
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey);
if (error) throw error;
threadTagIds.value = new Set((data || []).map((r) => r.tag_id));
} catch (e) {
console.error('[useConversationTags] loadForThread:', e?.message);
threadTagIds.value = new Set();
}
}
// ── Toggle: adiciona ou remove tag da thread ────
async function toggleOnThread(threadKey, tagId) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId || !threadKey || !tagId) return { ok: false, error: 'invalid_params' };
saving.value = true;
const hasTag = threadTagIds.value.has(tagId);
try {
if (hasTag) {
const { error } = await supabase
.from('conversation_thread_tags')
.delete()
.eq('tenant_id', tenantId)
.eq('thread_key', threadKey)
.eq('tag_id', tagId);
if (error) throw error;
const next = new Set(threadTagIds.value);
next.delete(tagId);
threadTagIds.value = next;
} else {
const { data: authData } = await supabase.auth.getUser();
const userId = authData?.user?.id;
if (!userId) return { ok: false, error: 'not_authenticated' };
const { error } = await supabase
.from('conversation_thread_tags')
.insert({
tenant_id: tenantId,
thread_key: threadKey,
tag_id: tagId,
tagged_by: userId
});
if (error) throw error;
const next = new Set(threadTagIds.value);
next.add(tagId);
threadTagIds.value = next;
}
return { ok: true, added: !hasTag };
} catch (e) {
return { ok: false, error: e?.message || 'toggle_failed' };
} finally {
saving.value = false;
}
}
// ── Cria tag custom ────
async function createCustomTag({ name, color = '#6366f1', icon = null }) {
const tenantId = tenantStore.activeTenantId;
const cleanName = sanitizeName(name);
if (!tenantId || !cleanName) return { ok: false, error: 'invalid_params' };
const slug = toSlug(cleanName);
if (!slug) return { ok: false, error: 'invalid_slug' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_tags')
.insert({
tenant_id: tenantId,
name: cleanName,
slug,
color,
icon,
is_system: false
})
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.single();
if (error) throw error;
allTags.value = [...allTags.value, data].sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
return { ok: true, tag: data };
} catch (e) {
return { ok: false, error: e?.message || 'create_failed' };
} finally {
saving.value = false;
}
}
// ── Atualiza tag custom ────
async function updateCustomTag(id, { name, color, icon }) {
if (!id) return { ok: false, error: 'invalid_id' };
const patch = {};
if (name !== undefined) {
const clean = sanitizeName(name);
if (!clean) return { ok: false, error: 'invalid_name' };
patch.name = clean;
patch.slug = toSlug(clean);
if (!patch.slug) return { ok: false, error: 'invalid_slug' };
}
if (color !== undefined) patch.color = color;
if (icon !== undefined) patch.icon = icon;
if (!Object.keys(patch).length) return { ok: false, error: 'nothing_to_update' };
saving.value = true;
try {
const { data, error } = await supabase
.from('conversation_tags')
.update(patch)
.eq('id', id)
.select('id, tenant_id, name, slug, color, icon, position, is_system')
.single();
if (error) throw error;
allTags.value = allTags.value
.map((t) => (t.id === id ? data : t))
.sort((a, b) => (a.position - b.position) || a.name.localeCompare(b.name));
return { ok: true, tag: data };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
} finally {
saving.value = false;
}
}
// ── Remove tag custom (system bloqueada por RLS) ────
async function deleteCustomTag(id) {
if (!id) return { ok: false, error: 'invalid_id' };
saving.value = true;
try {
const { error } = await supabase.from('conversation_tags').delete().eq('id', id);
if (error) throw error;
allTags.value = allTags.value.filter((t) => t.id !== id);
const next = new Set(threadTagIds.value);
next.delete(id);
threadTagIds.value = next;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'delete_failed' };
} finally {
saving.value = false;
}
}
function clear() {
threadTagIds.value = new Set();
}
return {
allTags,
threadTags,
threadTagIds,
loading,
saving,
loadAllTags,
loadForThread,
loadForThreads,
toggleOnThread,
createCustomTag,
updateCustomTag,
deleteCustomTag,
clear
};
}
+268
View File
@@ -0,0 +1,268 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useConversations.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const KANBAN_ORDER = ['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'];
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 120).toLowerCase();
}
export function useConversations() {
const tenantStore = useTenantStore();
const threads = ref([]);
const loading = ref(false);
const error = ref(null);
const filters = ref({
search: '',
channel: null, // null = todos
unreadOnly: false,
assigned: null // null = todas | 'me' | 'unassigned' | <uuid>
});
const currentUserId = ref(null);
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
let realtimeChannel = null;
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) {
threads.value = [];
error.value = new Error('Tenant ativo inválido');
return;
}
loading.value = true;
error.value = null;
try {
const { data, error: qErr } = await supabase
.from('conversation_threads')
.select('*')
.eq('tenant_id', tenantId)
.order('last_message_at', { ascending: false })
.limit(500);
if (qErr) throw qErr;
threads.value = data ?? [];
} catch (err) {
error.value = err;
threads.value = [];
} finally {
loading.value = false;
}
}
function subscribeRealtime() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
realtimeChannel = supabase
.channel(`conv_msg_tenant_${tenantId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
// refetch da lista (view agrega tudo)
load();
// se o drawer esta aberto numa thread desta msg, appenda
const newMsg = payload.new;
if (currentThread.value && messageBelongsToThread(newMsg, currentThread.value)) {
const alreadyThere = threadMessages.value.some((m) => m.id === newMsg.id);
if (!alreadyThere) threadMessages.value.push(newMsg);
}
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
load();
const updated = payload.new;
if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) {
const idx = threadMessages.value.findIndex((m) => m.id === updated.id);
if (idx >= 0) threadMessages.value[idx] = updated;
}
}
)
.subscribe();
}
function unsubscribeRealtime() {
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
realtimeChannel = null;
}
}
onUnmounted(() => unsubscribeRealtime());
const filteredThreads = computed(() => {
const q = sanitizeSearch(filters.value.search);
const assignFilter = filters.value.assigned;
const uid = currentUserId.value;
return threads.value.filter((t) => {
if (filters.value.channel && t.channel !== filters.value.channel) return false;
if (filters.value.unreadOnly && (t.unread_count || 0) === 0) return false;
if (assignFilter === 'me') {
if (!uid || t.assigned_to !== uid) return false;
} else if (assignFilter === 'unassigned') {
if (t.assigned_to) return false;
} else if (assignFilter && typeof assignFilter === 'string') {
if (t.assigned_to !== assignFilter) return false;
}
if (q) {
const name = (t.patient_name || '').toLowerCase();
const num = (t.contact_number || '').toLowerCase();
const body = (t.last_message_body || '').toLowerCase();
if (!name.includes(q) && !num.includes(q) && !body.includes(q)) return false;
}
return true;
});
});
const byKanban = computed(() => {
const map = { urgent: [], awaiting_us: [], awaiting_patient: [], resolved: [] };
for (const t of filteredThreads.value) {
const k = KANBAN_ORDER.includes(t.kanban_status) ? t.kanban_status : 'awaiting_us';
map[k].push(t);
}
return map;
});
const summary = computed(() => ({
total: threads.value.length,
urgent: byKanban.value.urgent.length,
awaiting_us: byKanban.value.awaiting_us.length,
awaiting_patient: byKanban.value.awaiting_patient.length,
resolved: byKanban.value.resolved.length,
unreadTotal: threads.value.reduce((s, t) => s + (t.unread_count || 0), 0)
}));
// Mensagens de uma thread especifica (drawer)
const threadMessages = ref([]);
const threadLoading = ref(false);
const currentThread = ref(null);
function messageBelongsToThread(msg, thread) {
if (!thread || !msg) return false;
if (thread.patient_id) return msg.patient_id === thread.patient_id;
// thread anônima
if (msg.patient_id) return false;
return (
msg.from_number === thread.contact_number ||
msg.to_number === thread.contact_number
);
}
async function loadThreadMessages(thread) {
currentThread.value = thread;
if (!thread) {
threadMessages.value = [];
return;
}
threadLoading.value = true;
try {
let q = supabase
.from('conversation_messages')
.select('*')
.eq('tenant_id', tenantStore.activeTenantId)
.order('created_at', { ascending: true })
.limit(500);
if (thread.patient_id) {
q = q.eq('patient_id', thread.patient_id);
} else {
// anônimo — filtra por from_number ou to_number
q = q.or(`from_number.eq.${thread.contact_number},to_number.eq.${thread.contact_number}`).is('patient_id', null);
}
const { data, error: qErr } = await q;
if (qErr) throw qErr;
threadMessages.value = data ?? [];
} finally {
threadLoading.value = false;
}
}
async function markThreadRead(thread) {
if (!thread) return;
// Marca unread do inbound como lido
const nowIso = new Date().toISOString();
const tenantId = tenantStore.activeTenantId;
let q = supabase
.from('conversation_messages')
.update({ read_at: nowIso })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
await q;
load();
}
async function setKanbanStatus(thread, newStatus) {
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(newStatus)) return;
const tenantId = tenantStore.activeTenantId;
const patch = { kanban_status: newStatus };
if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString();
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
await q;
load();
}
return {
threads,
filteredThreads,
byKanban,
summary,
filters,
loading,
error,
load,
subscribeRealtime,
unsubscribeRealtime,
threadMessages,
threadLoading,
loadThreadMessages,
markThreadRead,
setKanbanStatus
};
}
+108
View File
@@ -0,0 +1,108 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useLgpdExport.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { downloadLgpdPDF } from '@/utils/lgpdExportFormats';
function slugify(s) {
if (!s) return 'paciente';
return (
String(s)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
.slice(0, 40) || 'paciente'
);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function useLgpdExport() {
const loading = ref(false);
const error = ref(null);
const lastPayload = ref(null);
async function fetchExport(patientId) {
if (!patientId) {
throw new Error('patientId obrigatório');
}
const { data, error: rpcErr } = await supabase.rpc('export_patient_data', { p_patient_id: patientId });
if (rpcErr) throw rpcErr;
return data;
}
async function exportJSON(patientId, patientName) {
loading.value = true;
error.value = null;
try {
const payload = await fetchExport(patientId);
lastPayload.value = payload;
const ts = new Date().toISOString().slice(0, 10);
const filename = `lgpd-export-${slugify(patientName)}-${ts}.json`;
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' });
downloadBlob(blob, filename);
return payload;
} catch (err) {
error.value = err;
throw err;
} finally {
loading.value = false;
}
}
async function exportPDF(patientId, patientName, tenantName) {
loading.value = true;
error.value = null;
try {
const payload = await fetchExport(patientId);
lastPayload.value = payload;
const ts = new Date().toISOString().slice(0, 10);
const filename = `lgpd-export-${slugify(patientName)}-${ts}.pdf`;
await downloadLgpdPDF(payload, tenantName, filename);
return payload;
} catch (err) {
error.value = err;
throw err;
} finally {
loading.value = false;
}
}
return {
loading,
error,
lastPayload,
fetchExport,
exportJSON,
exportPDF
};
}
+43 -1
View File
@@ -23,9 +23,11 @@ import { useTenantStore } from '@/stores/tenantStore';
const agendaHoje = ref(0);
const cadastrosRecebidos = ref(0);
const agendamentosRecebidos = ref(0);
const conversasUnread = ref(0);
let _timer = null;
let _started = false;
let _realtimeChannel = null;
async function _refresh() {
try {
@@ -69,23 +71,63 @@ async function _refresh() {
const { count } = await q;
agendamentosRecebidos.value = count || 0;
}
// 4. Conversas não lidas (mensagens inbound sem read_at)
if (tenantId) {
const { count } = await supabase
.from('conversation_messages')
.select('id', { count: 'exact', head: true })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
conversasUnread.value = count || 0;
}
} catch {
// badge falhar não deve quebrar a navegação
}
}
// Subscribe Realtime pra atualizar badge ao vivo
function _subscribeRealtime() {
try {
const tenantStore = useTenantStore();
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tenantId) return;
if (_realtimeChannel) {
supabase.removeChannel(_realtimeChannel);
}
_realtimeChannel = supabase
.channel(`menu_badges_conv_${tenantId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
() => _refresh()
)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'conversation_messages', filter: `tenant_id=eq.${tenantId}` },
() => _refresh()
)
.subscribe();
} catch {
// Realtime falhar não deve quebrar
}
}
// ─── API pública ───────────────────────────────────────────
export function useMenuBadges() {
if (!_started) {
_started = true;
_refresh();
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min
_subscribeRealtime();
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min (fallback)
}
return {
agendaHoje,
cadastrosRecebidos,
agendamentosRecebidos,
conversasUnread,
refresh: _refresh
};
}
+123
View File
@@ -0,0 +1,123 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useSessionReminders.js
| Data: 2026-04-21
|
| Lembretes automáticos de sessão (CRM Grupo 2.4).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const DEFAULTS = {
enabled: false,
send_24h: true,
send_2h: true,
template_24h: 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!',
template_2h: 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊',
quiet_hours_enabled: true,
quiet_hours_start: '22:00',
quiet_hours_end: '08:00',
respect_opt_out: true
};
export function useSessionReminders() {
const tenantStore = useTenantStore();
const settings = ref({ ...DEFAULTS });
const recentLogs = ref([]);
const loading = ref(false);
const saving = ref(false);
async function load() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [settingsRes, logsRes] = await Promise.all([
supabase
.from('session_reminder_settings')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle(),
supabase
.from('session_reminder_logs')
.select('id, event_id, reminder_type, sent_at, provider, skip_reason, to_phone')
.eq('tenant_id', tenantId)
.order('sent_at', { ascending: false })
.limit(30)
]);
if (settingsRes.data) {
settings.value = {
enabled: !!settingsRes.data.enabled,
send_24h: !!settingsRes.data.send_24h,
send_2h: !!settingsRes.data.send_2h,
template_24h: settingsRes.data.template_24h || DEFAULTS.template_24h,
template_2h: settingsRes.data.template_2h || DEFAULTS.template_2h,
quiet_hours_enabled: !!settingsRes.data.quiet_hours_enabled,
quiet_hours_start: String(settingsRes.data.quiet_hours_start || DEFAULTS.quiet_hours_start).slice(0, 5),
quiet_hours_end: String(settingsRes.data.quiet_hours_end || DEFAULTS.quiet_hours_end).slice(0, 5),
respect_opt_out: !!settingsRes.data.respect_opt_out
};
} else {
settings.value = { ...DEFAULTS };
}
recentLogs.value = logsRes.data || [];
} catch (e) {
console.error('[useSessionReminders] load:', e?.message);
} finally {
loading.value = false;
}
}
async function save() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'no_tenant' };
const payload = { ...settings.value };
payload.template_24h = String(payload.template_24h || '').trim().slice(0, 2000);
payload.template_2h = String(payload.template_2h || '').trim().slice(0, 2000);
if (!payload.template_24h || !payload.template_2h) {
return { ok: false, error: 'Templates não podem ficar vazios' };
}
saving.value = true;
try {
const { error } = await supabase
.from('session_reminder_settings')
.upsert({ tenant_id: tenantId, ...payload }, { onConflict: 'tenant_id' });
if (error) throw error;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Falha ao salvar' };
} finally {
saving.value = false;
}
}
// Dispara manualmente (pra teste ou pra catch-up de eventos perdidos)
async function runNow() {
try {
const { data, error } = await supabase.functions.invoke('send-session-reminders', { body: {} });
if (error) throw error;
return { ok: true, stats: data?.stats || null };
} catch (e) {
return { ok: false, error: e?.message || 'Falha ao executar' };
}
}
return {
settings,
recentLogs,
loading,
saving,
load,
save,
runNow
};
}
+144
View File
@@ -0,0 +1,144 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useWhatsappCredits.js
| Data: 2026-04-21
|
| Sistema de créditos WhatsApp (Marco B).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
export function useWhatsappCredits() {
const tenantStore = useTenantStore();
const balance = ref(null); // { balance, lifetime_purchased, lifetime_used, low_balance_threshold }
const transactions = ref([]); // últimos extratos
const packages = ref([]); // pacotes ativos (loja)
const purchases = ref([]); // minhas ordens de compra
const tenantCpfCnpj = ref(''); // CPF/CNPJ armazenado no tenant (pra prefill)
const loading = ref(false);
const creating = ref(false);
const currentBalance = computed(() => balance.value?.balance ?? 0);
const isLow = computed(() => {
if (!balance.value) return false;
return balance.value.balance <= balance.value.low_balance_threshold;
});
async function loadAll() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
loading.value = true;
try {
const [balRes, txRes, pkgRes, purRes, tenRes] = await Promise.all([
supabase
.from('whatsapp_credits_balance')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle(),
supabase
.from('whatsapp_credits_transactions')
.select('id, kind, amount, balance_after, note, created_at, purchase_id, admin_id')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
.limit(50),
supabase
.from('whatsapp_credit_packages')
.select('*')
.eq('is_active', true)
.order('position', { ascending: true })
.order('price_brl', { ascending: true }),
supabase
.from('whatsapp_credit_purchases')
.select('id, package_name, credits, amount_brl, status, paid_at, expires_at, created_at, asaas_pix_qrcode, asaas_pix_copy_paste, asaas_payment_link')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
.limit(20),
supabase
.from('tenants')
.select('cpf_cnpj')
.eq('id', tenantId)
.maybeSingle()
]);
balance.value = balRes.data || { balance: 0, lifetime_purchased: 0, lifetime_used: 0, low_balance_threshold: 20 };
transactions.value = txRes.data || [];
packages.value = pkgRes.data || [];
purchases.value = purRes.data || [];
tenantCpfCnpj.value = tenRes.data?.cpf_cnpj || '';
} catch (e) {
console.error('[useWhatsappCredits] loadAll:', e?.message);
} finally {
loading.value = false;
}
}
async function createPurchase(packageId, cpfCnpj = null) {
if (!packageId) return { ok: false, error: 'package_missing' };
creating.value = true;
try {
const cleanDoc = (cpfCnpj || '').replace(/\D/g, '') || null;
const body = cleanDoc ? { package_id: packageId, cpf_cnpj: cleanDoc } : { package_id: packageId };
const { data, error } = await supabase.functions.invoke('create-whatsapp-credit-charge', { body });
if (error) {
// Edge function errors (non-2xx) vêm em error.context.json() no SDK novo
let parsed = null;
try {
parsed = typeof error.context?.json === 'function'
? await error.context.json()
: null;
} catch (_) { /* swallow */ }
return {
ok: false,
error: parsed?.error || error.message || 'invoke_failed',
message: parsed?.message || null
};
}
if (!data?.ok) return { ok: false, error: data?.error || 'unknown', message: data?.message || null };
// Atualiza CPF armazenado se a compra foi com um novo
if (cleanDoc) tenantCpfCnpj.value = cleanDoc;
await loadAll();
return { ok: true, purchase: data.purchase };
} catch (e) {
return { ok: false, error: e?.message || 'create_failed' };
} finally {
creating.value = false;
}
}
async function updateLowBalanceThreshold(newThreshold) {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return { ok: false, error: 'no_tenant' };
const v = Math.max(0, Math.min(10000, Number(newThreshold) || 0));
try {
const { error } = await supabase
.from('whatsapp_credits_balance')
.upsert({ tenant_id: tenantId, low_balance_threshold: v }, { onConflict: 'tenant_id' });
if (error) throw error;
if (balance.value) balance.value.low_balance_threshold = v;
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'update_failed' };
}
}
return {
balance,
transactions,
packages,
purchases,
tenantCpfCnpj,
currentBalance,
isLow,
loading,
creating,
loadAll,
createPurchase,
updateLowBalanceThreshold
};
}
@@ -95,8 +95,9 @@ const loadingBloqueios = ref(false);
async function loadBloqueiosMes() {
if (!_ownerId.value) return;
const ano = new Date().getFullYear();
const lastDay = new Date(ano, mesAtual, 0).getDate();
const start = `${ano}-${String(mesAtual).padStart(2, '0')}-01`;
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-31`;
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
loadingBloqueios.value = true;
try {
const { data } = await supabase.from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
@@ -2075,7 +2075,7 @@ function goRecorrencias() {
<div ref="headerSentinelRef" class="ag-sentinel" />
<!-- Topbar compacta sticky -->
<div ref="headerEl" class="ag-topbar mx-3 md:mx-4 mb-3" :class="{ 'ag-topbar--stuck': headerStuck }">
<div ref="headerEl" class="ag-topbar my-3 md:mx-4" :class="{ 'ag-topbar--stuck': headerStuck }">
<div class="ag-topbar__blobs" aria-hidden="true">
<div class="ag-topbar__blob ag-topbar__blob--1" />
<div class="ag-topbar__blob ag-topbar__blob--2" />
@@ -2141,9 +2141,9 @@ function goRecorrencias() {
<div class="hidden xl:flex items-center gap-1">
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recarregar'" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recorrências'" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
</div>
</div>
</div>
@@ -2152,7 +2152,7 @@ function goRecorrencias() {
<!-- Aviso: fora da jornada -->
<div
v-if="hasEventsOutsideWorkHours"
class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3"
class="my-3 md:mx-4 rounded-[6px] p-3"
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent)"
>
<div class="flex items-center gap-3">
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -31,6 +31,15 @@ const mode = computed(() => route.meta?.mode || 'therapist');
const isClinic = computed(() => mode.value === 'clinic');
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId);
// Hero sticky
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
// Mobile
const filtersDlgOpen = ref(false);
// state
const loading = ref(false);
const userId = ref(null);
@@ -306,25 +315,59 @@ function goBack() {
else router.push({ name: 'therapist-agenda' });
}
onMounted(init);
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
_observer = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0, rootMargin }
);
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
await init();
});
onBeforeUnmount(() => {
_observer?.disconnect();
});
</script>
<template>
<!-- Header -->
<div class="rr-page mx-3 md:mx-5">
<div class="rr-header">
<div class="flex items-center gap-3">
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
Hero sticky
-->
<div
ref="headerEl"
class="sticky my-3 mx-3 md:mx-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/9" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Voltar + Brand -->
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
<div>
<div class="text-xl font-bold leading-tight">Recorrências</div>
<div class="text-sm opacity-55">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-refresh text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recorrências</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">
{{ isClinic ? 'Todas as séries da clínica' : 'Suas séries de sessões recorrentes' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<!-- Status filter -->
<!-- Filtros desktop -->
<div class="hidden xl:flex items-center gap-2 flex-1 min-w-0 mx-2">
<SelectButton
v-model="filterStatus"
:options="[
@@ -335,10 +378,9 @@ onMounted(init);
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
@change="load"
/>
<!-- Therapist filter (clinic only) -->
<Select
v-if="isClinic && staffOptions.length"
v-model="filterOwner"
@@ -349,28 +391,75 @@ onMounted(init);
class="w-[220px]"
@change="load"
/>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-filter" severity="secondary" outlined class="xl:hidden h-9 w-9 rounded-full" v-tooltip.bottom="'Filtros'" @click="filtersDlgOpen = true" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
</div>
</div>
</div>
<!-- Dialog filtros mobile -->
<Dialog v-model:visible="filtersDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Filtros" class="w-[94vw] max-w-sm">
<div class="flex flex-col gap-3 pt-1">
<div>
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5">Status</label>
<SelectButton
v-model="filterStatus"
:options="[
{ label: 'Ativas', value: 'ativo' },
{ label: 'Encerradas', value: 'cancelado' },
{ label: 'Todas', value: 'all' }
]"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
class="w-full"
@change="load"
/>
</div>
<div v-if="isClinic && staffOptions.length">
<label class="block text-xs font-semibold text-[var(--text-color-secondary)] mb-1.5">Terapeuta</label>
<Select v-model="filterOwner" :options="[{ label: 'Todos os terapeutas', value: null }, ...staffOptions]" optionLabel="label" optionValue="value" placeholder="Todos os terapeutas" class="w-full" @change="load" />
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="filtersDlgOpen = false" />
</template>
</Dialog>
<!--
Conteúdo principal
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading -->
<div v-if="loading" class="flex flex-col gap-3 mt-4">
<div v-if="loading" class="flex flex-col gap-3">
<Skeleton v-for="i in 4" :key="i" height="130px" class="rounded-2xl" />
</div>
<!-- Empty -->
<div v-else-if="!rules.length" class="rr-empty">
<i class="pi pi-calendar-times text-5xl opacity-25" />
<div class="text-lg font-semibold opacity-50">Nenhuma série encontrada</div>
<div class="text-sm opacity-35">
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
<div v-else-if="!rules.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-list text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Séries cadastradas</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--surface-200,#e5e7eb)] text-[var(--text-color-secondary)] text-[0.72rem] font-bold">0</span>
</div>
<div class="py-10 px-6 text-center">
<i class="pi pi-calendar-times text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhuma série encontrada</div>
<div class="text-xs opacity-60 mt-1">
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
</div>
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" size="small" class="rounded-full mt-3" @click="goBack" />
</div>
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" class="rounded-full mt-2" @click="goBack" />
</div>
<!-- Rule cards -->
<div v-else class="flex flex-col gap-4 mt-4 pb-8">
<div v-else class="flex flex-col gap-4">
<div v-for="rule in rules" :key="rule.id" class="rr-card">
<!-- Card head: patient info + status badge -->
<div class="rr-card__head">
@@ -434,33 +523,6 @@ onMounted(init);
</template>
<style scoped>
/* ── Page ─────────────────────────────────────────────────────────── */
.rr-page {
padding-bottom: 2rem;
}
/* ── Header ───────────────────────────────────────────────────────── */
.rr-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 20px 0 16px;
border-bottom: 1px solid var(--surface-border);
margin-bottom: 4px;
}
/* ── Empty ────────────────────────────────────────────────────────── */
.rr-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 64px 24px;
text-align: center;
}
/* ── Card ─────────────────────────────────────────────────────────── */
.rr-card {
border-radius: 1.25rem;
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useTenantStore } from '@/stores/tenantStore';
import { supabase } from '@/lib/supabase/client';
@@ -223,6 +223,12 @@ const headerMenuRef = ref(null);
const agPanelOpen = ref(false);
const blockMenuRef = ref(null);
// Fecha o drawer mobile ao cruzar para desktop ( xl / 1280px)
const mqDesktop = typeof window !== 'undefined' ? window.matchMedia('(min-width: 1280px)') : null;
const onMqDesktopChange = (e) => {
if (e.matches) agPanelOpen.value = false;
};
// Prontuário
const prontuarioOpen = ref(false);
const selectedPatient = ref(null);
@@ -2305,6 +2311,12 @@ onMounted(async () => {
);
io.observe(headerSentinelRef.value);
}
mqDesktop?.addEventListener('change', onMqDesktopChange);
});
onBeforeUnmount(() => {
mqDesktop?.removeEventListener('change', onMqDesktopChange);
});
</script>
@@ -2319,7 +2331,7 @@ onMounted(async () => {
<!-- Hero compacto padrão Compromissos -->
<div
ref="headerEl"
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3 sticky top-[var(--layout-sticky-top,56px)] z-20"
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 my-3 mx-3 md:mx-4 sticky top-[var(--layout-sticky-top,56px)] z-20"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
@@ -2337,7 +2349,7 @@ onMounted(async () => {
</div>
<!-- Nav + filtros (desktop) -->
<div class="hidden xl:flex items-center gap-2 flex-1 min-w-0 mx-2">
<div class="hidden min-[1500px]:flex items-center gap-2 flex-1 min-w-0 mx-2">
<!-- Navegação
<div class="flex items-center gap-1 shrink-0">
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
@@ -2357,7 +2369,7 @@ onMounted(async () => {
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 shrink-0">
<div class="hidden min-[1500px]:flex items-center gap-1 shrink-0">
<div class="w-44">
<FloatLabel variant="on">
<IconField>
@@ -2376,23 +2388,13 @@ onMounted(async () => {
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="onCreateFromButton" />
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recarregar'" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Recorrências'" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<!-- Nav mobile -->
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer whitespace-nowrap transition-colors duration-150 hover:border-[var(--p-primary-400)]"
@click="toggleMonthPicker"
>
<i class="pi pi-calendar text-xs opacity-60" />
{{ subtitleText }}
</span>
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
<div class="flex min-[1500px]:hidden items-center gap-1 shrink-0 ml-auto">
<div v-if="feriadosTodosProximos.length" class="relative">
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
<span v-if="feriadosSemBloqueio.length" class="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[0.65rem] font-bold flex items-center justify-center pointer-events-none">{{
@@ -2411,7 +2413,7 @@ onMounted(async () => {
<div
ref="foraJornadaBannerRef"
v-if="hasEventsOutsideWorkHours"
class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3"
class="my-3 mx-3 md:mx-4 rounded-[6px] p-3"
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent)"
>
<div class="flex items-center gap-3">
@@ -2423,11 +2425,15 @@ onMounted(async () => {
<!-- GRID 3 COLUNAS -->
<!-- Overlay mobile -->
<div v-if="agPanelOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="agPanelOpen = false" />
<div
v-if="agPanelOpen"
class="fixed left-0 right-0 bottom-0 top-[calc(var(--notice-banner-height,0px)+56px)] bg-black/40 backdrop-blur-sm z-[39] xl:hidden"
@click="agPanelOpen = false"
/>
<!-- Drawer mobile: col esquerda + col direita empilhadas -->
<aside
class="panel-drawer fixed top-0 left-0 h-[100dvh] w-[min(340px,88vw)] z-40 bg-[var(--surface-card)] border-r border-[var(--surface-border)] shadow-[4px_0_24px_rgba(0,0,0,0.12)] transition-[transform,visibility] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
class="panel-drawer fixed top-[calc(var(--notice-banner-height,0px)+56px)] left-0 h-[calc(100dvh-var(--notice-banner-height,0px)-56px)] w-[min(340px,88vw)] z-40 bg-[var(--surface-card)] border-r border-[var(--surface-border)] shadow-[4px_0_24px_rgba(0,0,0,0.12)] transition-[transform,visibility] duration-[250ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
:class="agPanelOpen ? 'translate-x-0 visible' : '-translate-x-full invisible'"
>
<div class="flex flex-col gap-3 p-4 h-full overflow-y-auto">
@@ -2668,22 +2674,23 @@ onMounted(async () => {
<!-- COL 1: Mini calendário + Jornada + Feriados -->
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[25%] shrink-0">
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between min-w-0">
<!--<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-calendar" />{{ visibleTitle }}</span>-->
<div class="flex items-center gap-2 mb-2">
<div class="flex items-center gap-2 mb-2 min-w-0 flex-1">
<!--<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />-->
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full shrink-0" @click="goToday" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full shrink-0" @click="goPrev" />
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer whitespace-nowrap transition-colors duration-150 hover:border-[var(--p-primary-400)]"
v-tooltip.top="subtitleText"
class="inline-flex flex-1 min-w-0 items-center gap-1.5 px-3 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-sm font-semibold text-[var(--text-color)] cursor-pointer transition-colors duration-150 hover:border-[var(--p-primary-400)]"
@click="toggleMonthPicker"
>
<i class="pi pi-calendar text-xs opacity-60" />
{{ subtitleText }}
<i class="pi pi-calendar text-xs opacity-60 shrink-0" />
<span class="truncate">{{ subtitleText }}</span>
</span>
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full shrink-0" @click="goNext" />
</div>
</div>
<DatePicker v-model="miniDate" inline class="w-full" @update:modelValue="onMiniPick" :pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }">
@@ -350,7 +350,7 @@ const emptySub = computed(() => {
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
class="sticky my-3 md:mx-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
@@ -421,7 +421,7 @@ const emptySub = computed(() => {
<Transition name="ar-banner">
<div
v-if="totalAutorizados > 0 && filtroStatus !== 'autorizado' && !loading"
class="mx-3 md:mx-4 mb-3 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50 cursor-pointer hover:bg-amber-100/70 transition-colors duration-150"
class="my-3 md:mx-4 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50 cursor-pointer hover:bg-amber-100/70 transition-colors duration-150"
@click="filtroStatus = 'autorizado'"
>
<!-- Ícone pulsante -->
@@ -16,10 +16,11 @@
-->
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import InputSwitch from 'primevue/inputswitch';
import ToggleSwitch from 'primevue/toggleswitch';
import Menu from 'primevue/menu';
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue';
@@ -29,6 +30,14 @@ import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const tenantStore = useTenantStore();
const route = useRoute();
const router = useRouter();
function goBack() {
const isClinic = (route.name || '').toString().startsWith('admin-');
if (isClinic) router.push({ name: 'admin-agenda-clinica' });
else router.push({ name: 'therapist-agenda' });
}
// Hero sticky
const headerEl = ref(null);
@@ -382,7 +391,7 @@ function isRecent(row) {
-->
<div
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-(--surface-border,#e2e8f0) bg-(--surface-card,#fff) px-3 py-2.5 transition-[border-radius] duration-200"
class="sticky my-3 mx-3 md:mx-4 z-20 overflow-hidden rounded-md border border-(--surface-border,#e2e8f0) bg-(--surface-card,#fff) px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
@@ -393,8 +402,9 @@ function isRecent(row) {
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<!-- Voltar + Brand -->
<div class="flex items-center gap-2 shrink-0">
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-list text-base" />
</div>
@@ -453,7 +463,7 @@ function isRecent(row) {
-->
<div class="px-3 md:px-4 pb-5 flex flex-col xl:flex-row gap-3 xl:gap-4 items-start">
<!-- Coluna principal -->
<div class="w-full xl:flex-1 xl:min-w-0">
<div class="w-full xl:flex-1 xl:min-w-0 flex flex-col gap-3 xl:gap-4">
<!-- Stats row -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
<template v-if="loading">
@@ -471,7 +481,7 @@ function isRecent(row) {
>
{{ s.value }}
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
</div>
</template>
</div>
@@ -533,7 +543,7 @@ function isRecent(row) {
<Column field="active" header="Ativo" style="width: 7rem">
<template #body="{ data }">
<InputSwitch v-model="data.active" :disabled="isActiveLocked(data) || saving" @change="onToggleActive(data)" />
<ToggleSwitch v-model="data.active" :disabled="isActiveLocked(data) || saving" @change="onToggleActive(data)" />
</template>
</Column>
@@ -0,0 +1,531 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/conversations/CRMConversasPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useConversations } from '@/composables/useConversations';
import { useConversationTags } from '@/composables/useConversationTags';
import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
const route = useRoute();
const router = useRouter();
const tenantStore = useTenantStore();
const drawerStore = useConversationDrawerStore();
function goSettings() {
router.push('/configuracoes/whatsapp');
}
const { threads, filteredThreads, byKanban, summary, filters, loading, load, subscribeRealtime, unsubscribeRealtime } = useConversations();
// Tags pra renderizar pills nos cards
const tagsApi = useConversationTags();
const threadTagsMap = ref(new Map()); // Map<thread_key, tag_id[]>
const tagById = computed(() => {
const m = {};
for (const t of tagsApi.allTags.value) m[t.id] = t;
return m;
});
function tagsForThread(threadKey) {
const ids = threadTagsMap.value.get(threadKey) || [];
return ids.map((id) => tagById.value[id]).filter(Boolean);
}
async function reloadThreadTags() {
const keys = filteredThreads.value.map((t) => t.thread_key);
threadTagsMap.value = await tagsApi.loadForThreads(keys);
}
// Layout
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
const isMobileLayout = computed(() => isMobile.value);
const asideLeft = computed(() => {
if (isMobileLayout.value) return undefined;
if (effectiveVariant.value !== 'rail') {
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
return isStaticActive ? '20rem' : '0';
}
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
});
const asideOpen = ref(false);
const KANBAN_COLUMNS = [
{ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' },
{ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' },
{ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' },
{ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' }
];
const CHANNEL_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'WhatsApp', value: 'whatsapp' },
{ label: 'SMS', value: 'sms' },
{ label: 'E-mail', value: 'email' }
];
function fmtRelative(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'agora';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
return d.toLocaleDateString('pt-BR');
}
function channelIcon(ch) {
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
return map[ch] || 'pi-comment';
}
function truncate(s, n = 80) {
if (!s) return '';
const str = String(s).replace(/\s+/g, ' ').trim();
return str.length > n ? str.slice(0, n - 1) + '…' : str;
}
function contactLabel(thread) {
return thread.patient_name || thread.contact_number || 'Desconhecido';
}
function onCardClick(thread) {
drawerStore.openForThread(thread);
if (isMobileLayout.value) asideOpen.value = false;
}
// Top 10 pacientes com atividade recente aside
const recentPatients = computed(() => {
return filteredThreads.value
.filter((t) => t.patient_id)
.slice(0, 10);
});
const unlinkedCount = computed(() => filteredThreads.value.filter((t) => !t.patient_id).length);
// Atribuição contadores sobre threads (antes do filtro de atribuição)
const currentUserId = ref(null);
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
const mineCount = computed(() => {
const uid = currentUserId.value;
if (!uid) return 0;
return threads.value.filter((t) => t.assigned_to === uid).length;
});
const unassignedCount = computed(() => threads.value.filter((t) => !t.assigned_to).length);
// Map user_id nome curto pra chip no card
const memberNameMap = ref({});
async function loadMemberNames() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
const { data } = await supabase
.from('v_tenant_members_with_profiles')
.select('user_id, full_name, email')
.eq('tenant_id', tenantId)
.eq('status', 'active');
const map = {};
for (const m of (data || [])) {
const full = m.full_name || m.email || '';
map[m.user_id] = full;
}
memberNameMap.value = map;
}
function assigneeLabel(userId) {
if (!userId) return '';
const full = memberNameMap.value[userId];
if (!full) return 'Atribuída';
const parts = full.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 14);
return `${parts[0]} ${parts[parts.length - 1][0]}.`;
}
// Abre drawer automaticamente se query ?patient=<uuid>
async function openThreadByPatientId(patientId) {
if (!patientId) return;
await drawerStore.openForPatient(patientId);
}
onMounted(async () => {
await load();
subscribeRealtime();
// Carrega definições de tags e tags por thread (em paralelo) + nomes de membros
await Promise.all([tagsApi.loadAllTags(), reloadThreadTags(), loadMemberNames()]);
// Se vier com query ?patient=<uuid>, abre o drawer direto
const patientFromQuery = route.query?.patient;
if (patientFromQuery) {
await openThreadByPatientId(String(patientFromQuery));
}
});
// Recarrega tags + threads quando o drawer fecha (tags/atribuição podem ter mudado)
watch(() => drawerStore.isOpen, (isOpen) => {
if (!isOpen) {
reloadThreadTags();
load();
}
});
// Recarrega tags quando a lista de threads muda (nova mensagem cria nova thread)
watch(() => filteredThreads.value.length, () => { reloadThreadTags(); });
// Reage a mudanças de rota (ex: clicou outro paciente)
watch(
() => route.query?.patient,
async (pid) => { if (pid) await openThreadByPatientId(String(pid)); }
);
watch(() => tenantStore.activeTenantId, async () => {
unsubscribeRealtime();
await load();
subscribeRealtime();
});
</script>
<template>
<div class="flex min-h-[calc(100vh-4.5rem)] bg-[var(--surface-ground,#f5f7fa)]">
<!-- Overlay mobile -->
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
<!--
ASIDE filtros rápidos + pacientes ativos
-->
<aside
class="aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]"
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
:style="{ left: asideLeft }"
>
<!-- Cabeçalho -->
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
<i class="pi pi-filter" /><span>Filtros rápidos</span>
</div>
<div class="flex flex-col gap-1.5">
<button
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
:class="{ 'ring-2 ring-blue-500/40': !filters.unreadOnly && !filters.channel }"
@click="filters.unreadOnly = false; filters.channel = null; filters.search = ''"
>
<span class="flex items-center gap-2"><i class="pi pi-list text-xs" /> Todas</span>
<Badge :value="summary.total" severity="secondary" />
</button>
<button
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-red-500/5"
:class="{ 'ring-2 ring-red-500/40 bg-red-500/5': filters.unreadOnly }"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<span class="flex items-center gap-2"><i class="pi pi-bell text-xs text-red-500" /> Não lidas</span>
<Badge :value="summary.unreadTotal" :severity="summary.unreadTotal > 0 ? 'danger' : 'secondary'" />
</button>
</div>
</div>
<!-- Atribuição -->
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
<i class="pi pi-user" /><span>Atribuição</span>
</div>
<div class="flex flex-col gap-1.5">
<button
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
:class="{ 'ring-2 ring-blue-500/40 bg-blue-500/5': !filters.assigned }"
@click="filters.assigned = null"
>
<span class="flex items-center gap-2 text-xs"><i class="pi pi-list text-xs" /> Todas</span>
</button>
<button
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
:class="{ 'ring-2 ring-blue-500/40 bg-blue-500/5': filters.assigned === 'me' }"
@click="filters.assigned = 'me'"
>
<span class="flex items-center gap-2 text-xs"><i class="pi pi-user text-xs text-blue-500" /> Minhas</span>
<Badge :value="mineCount" severity="secondary" />
</button>
<button
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
:class="{ 'ring-2 ring-amber-500/40 bg-amber-500/5': filters.assigned === 'unassigned' }"
@click="filters.assigned = 'unassigned'"
>
<span class="flex items-center gap-2 text-xs"><i class="pi pi-user-minus text-xs text-amber-500" /> Não atribuídas</span>
<Badge :value="unassignedCount" :severity="unassignedCount > 0 ? 'warn' : 'secondary'" />
</button>
</div>
</div>
<!-- Status Kanban (resumo) -->
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
<i class="pi pi-chart-bar" /><span>Por status</span>
</div>
<div class="flex flex-col gap-1.5">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md text-sm border border-[var(--surface-border)]"
:class="{
'border-red-500/30 bg-red-500/5': col.color === 'red' && summary[col.key] > 0,
'border-amber-500/30 bg-amber-500/5': col.color === 'amber' && summary[col.key] > 0,
'border-blue-500/30 bg-blue-500/5': col.color === 'blue' && summary[col.key] > 0,
'border-emerald-500/30 bg-emerald-500/5': col.color === 'emerald' && summary[col.key] > 0
}"
>
<span class="flex items-center gap-2 text-xs">
<i :class="col.icon" />
{{ col.label }}
</span>
<Badge :value="summary[col.key] || 0" severity="secondary" />
</div>
</div>
</div>
<!-- Canais -->
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
<i class="pi pi-send" /><span>Canais</span>
</div>
<div class="flex flex-col gap-1.5">
<button
v-for="opt in CHANNEL_OPTIONS"
:key="String(opt.value)"
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-colors border border-[var(--surface-border)] bg-transparent cursor-pointer hover:bg-[var(--surface-hover)]"
:class="{ 'ring-2 ring-blue-500/40 bg-blue-500/5': filters.channel === opt.value }"
@click="filters.channel = opt.value"
>
<i v-if="opt.value" :class="['pi text-xs', channelIcon(opt.value)]" />
<i v-else class="pi pi-list text-xs" />
{{ opt.label }}
</button>
</div>
</div>
<!-- Pacientes com atividade recente -->
<div v-if="recentPatients.length" class="p-3.5 pb-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
<i class="pi pi-users" /><span>Recentes</span>
</div>
<div class="flex flex-col gap-1">
<button
v-for="t in recentPatients"
:key="t.thread_key"
class="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md text-xs border-none bg-transparent cursor-pointer text-left hover:bg-[var(--surface-hover)] transition-colors"
@click="onCardClick(t)"
>
<span class="flex items-center gap-2 min-w-0 flex-1">
<i :class="['pi', channelIcon(t.channel), 'text-[0.65rem] opacity-60']" />
<span class="truncate text-[var(--text-color)]">{{ contactLabel(t) }}</span>
</span>
<span class="flex items-center gap-1 shrink-0">
<Badge v-if="t.unread_count > 0" :value="t.unread_count" severity="danger" />
<span class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-75">{{ fmtRelative(t.last_message_at) }}</span>
</span>
</button>
</div>
</div>
<!-- Alerta: não vinculados -->
<div v-if="unlinkedCount > 0" class="p-3.5">
<div class="flex items-start gap-2 p-2.5 rounded-md border border-amber-500/30 bg-amber-500/5 text-xs">
<i class="pi pi-exclamation-circle text-amber-600 mt-0.5" />
<div>
<div class="font-semibold text-amber-700">{{ unlinkedCount }} conversa(s) sem paciente vinculado</div>
<div class="opacity-75">Números de telefone que não batem com pacientes cadastrados.</div>
</div>
</div>
</div>
</aside>
<!--
ÁREA PRINCIPAL
-->
<main class="flex-1 min-w-0 p-4 xl:p-[1.125rem_1.375rem] flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
<!-- Header -->
<section class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-blue-500/10" />
<div class="absolute w-80 h-80 top-2 -left-20 rounded-full blur-[70px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-1 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-blue-500/10 text-blue-600">
<i class="pi pi-comments text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">Conversas</div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">
CRM de WhatsApp · {{ summary.total }} conversa(s)
<span v-if="summary.unreadTotal > 0" class="ml-2 text-red-500 font-semibold">· {{ summary.unreadTotal }} não lida(s)</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters.search" placeholder="Buscar paciente, número ou mensagem" class="w-64" maxlength="120" />
</IconField>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Configurações'" @click="goSettings" />
</div>
</div>
</section>
<!-- Toggle aside mobile -->
<button
class="xl:hidden flex w-full items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] rounded-md text-sm font-semibold text-[var(--text-color)] cursor-pointer"
@click="asideOpen = !asideOpen"
>
<div class="flex items-center gap-2">
<i class="pi pi-filter text-[var(--primary-color,#6366f1)]" />
<span>Filtros & recentes</span>
</div>
<i class="pi transition-transform duration-200" :class="asideOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<!-- Kanban -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="flex flex-col rounded-lg border bg-[var(--surface-card)] min-h-[400px]"
:class="{
'border-red-500/40': col.color === 'red',
'border-amber-500/40': col.color === 'amber',
'border-blue-500/40': col.color === 'blue',
'border-emerald-500/40': col.color === 'emerald'
}"
>
<!-- Column header -->
<div
class="flex items-center justify-between gap-2 px-3 py-2 border-b"
:class="{
'border-red-500/30 bg-red-500/5': col.color === 'red',
'border-amber-500/30 bg-amber-500/5': col.color === 'amber',
'border-blue-500/30 bg-blue-500/5': col.color === 'blue',
'border-emerald-500/30 bg-emerald-500/5': col.color === 'emerald'
}"
>
<div class="flex items-center gap-2 text-sm font-semibold">
<i :class="col.icon" />
{{ col.label }}
</div>
<Badge :value="byKanban[col.key].length" severity="secondary" />
</div>
<!-- Cards -->
<div class="flex flex-col gap-2 p-2 overflow-y-auto max-h-[calc(100vh-320px)]">
<div v-if="loading && !byKanban[col.key].length" class="text-xs text-[var(--text-color-secondary)] p-3 text-center">
<i class="pi pi-spin pi-spinner mr-2" />Carregando...
</div>
<div v-else-if="!byKanban[col.key].length" class="text-xs text-[var(--text-color-secondary)] p-6 text-center italic">
Nenhuma conversa.
</div>
<div
v-for="t in byKanban[col.key]"
:key="t.thread_key"
class="flex flex-col gap-1.5 p-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] cursor-pointer hover:shadow-sm hover:border-blue-500/40 transition-all"
@click="onCardClick(t)"
>
<div class="flex items-center justify-between gap-2 min-w-0">
<div class="flex items-center gap-2 min-w-0 flex-1">
<i :class="['pi', channelIcon(t.channel), 'text-[var(--text-color-secondary)]']" />
<span class="text-sm font-semibold truncate text-[var(--text-color)]">{{ contactLabel(t) }}</span>
</div>
<Badge v-if="t.unread_count > 0" :value="t.unread_count" severity="danger" />
</div>
<div class="text-xs text-[var(--text-color-secondary)] truncate">
<i v-if="t.last_message_direction === 'outbound'" class="pi pi-arrow-right text-[0.6rem] mr-1 opacity-60" />
{{ truncate(t.last_message_body) }}
</div>
<!-- Tags pills -->
<div v-if="tagsForThread(t.thread_key).length" class="flex items-center gap-1 flex-wrap">
<span
v-for="tag in tagsForThread(t.thread_key)"
:key="tag.id"
class="inline-flex items-center gap-1 px-1.5 py-px rounded-full text-[0.62rem] font-semibold leading-tight"
:style="{
background: tag.color + '20',
color: tag.color,
border: `1px solid ${tag.color}40`
}"
>
<i v-if="tag.icon" :class="tag.icon" class="text-[0.55rem]" />
{{ tag.name }}
</span>
</div>
<div class="flex items-center justify-between gap-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
<span>{{ fmtRelative(t.last_message_at) }}</span>
<span
v-if="t.assigned_to"
class="inline-flex items-center gap-1 px-1.5 py-px rounded-full text-[0.62rem] font-semibold"
:class="t.assigned_to === currentUserId
? 'bg-blue-500/15 text-blue-600 border border-blue-500/30'
: 'bg-[var(--surface-hover)] text-[var(--text-color)] border border-[var(--surface-border)]'"
v-tooltip.top="t.assigned_to === currentUserId ? 'Atribuída a mim' : 'Atribuída a ' + (memberNameMap[t.assigned_to] || '')"
>
<i class="pi pi-user text-[0.55rem]" />
{{ t.assigned_to === currentUserId ? 'Eu' : assigneeLabel(t.assigned_to) }}
</span>
<span v-else-if="!t.patient_name" class="italic">não vinculado</span>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
.aside-drawer {
position: fixed;
top: calc(56px + var(--notice-banner-height, 0px));
left: 0;
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
width: min(300px, 85vw);
z-index: 40;
overflow-y: auto;
transition:
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
visibility 0.25s;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
@media (min-width: 1280px) {
.aside-drawer {
position: fixed;
top: calc(56px + var(--notice-banner-height, 0px));
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
width: 272px;
transform: none;
visibility: visible;
box-shadow: none;
z-index: auto;
}
}
</style>
+125 -67
View File
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Menu from 'primevue/menu'
@@ -39,6 +39,13 @@ const view = ref('list') // list | create | edit
const editingTemplate = ref({})
const editingId = ref(null)
// Hero sticky
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// Mobile menu
const mobileMenuRef = ref(null)
@@ -50,7 +57,19 @@ const mobileMenuItems = [
// Lifecycle
onMounted(() => fetchTemplates(true))
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
fetchTemplates(true)
})
onBeforeUnmount(() => {
_observer?.disconnect()
})
// Acoes
@@ -148,111 +167,155 @@ function getCardMenuItems(tpl) {
</script>
<template>
<div class="px-4 py-6 max-w-[1200px] mx-auto">
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div>
<div class="flex items-center gap-2">
<Button
v-if="view !== 'list'"
icon="pi pi-arrow-left"
text
rounded
size="small"
@click="view = 'list'"
/>
<h1 class="text-xl font-bold">
<!-- Hero sticky -->
<div
ref="headerEl"
class="sticky my-3 md:mx-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/9" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Voltar (create/edit) + Brand -->
<div class="flex items-center gap-2 shrink-0">
<Button v-if="view !== 'list'" icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à lista'" @click="view = 'list'" />
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-file-edit text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">
<template v-if="view === 'list'">Templates de documentos</template>
<template v-else-if="view === 'create'">Novo template</template>
<template v-else>Editar template</template>
</h1>
</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Modelos para declarações, atestados, recibos e outros documentos</div>
</div>
<p v-if="view === 'list'" class="text-sm text-[var(--text-color-secondary)]">
Modelos para declarações, atestados, recibos e outros documentos
</p>
</div>
<div v-if="view === 'list'" class="hidden sm:flex items-center gap-2">
<Button label="Novo template" icon="pi pi-plus" size="small" @click="openCreate" />
<!-- Ações LIST (desktop) -->
<div v-if="view === 'list'" class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Atualizar'" @click="fetchTemplates(true)" />
<Button label="Novo template" icon="pi pi-plus" size="small" class="rounded-full" @click="openCreate" />
</div>
<div v-if="view === 'list'" class="sm:hidden">
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
<!-- Ações LIST (mobile) -->
<div v-if="view === 'list'" class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- List view -->
<!-- Ações CREATE/EDIT (desktop) -->
<div v-if="view !== 'list'" class="hidden sm:flex items-center gap-1 shrink-0 ml-auto">
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="view = 'list'" />
<Button :label="view === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" size="small" class="rounded-full" @click="onSave(editingTemplate)" />
</div>
<!-- Ações CREATE/EDIT (mobile ícones) -->
<div v-if="view !== 'list'" class="flex sm:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-times" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Cancelar'" @click="view = 'list'" />
<Button icon="pi pi-check" class="h-9 w-9 rounded-full" v-tooltip.bottom="view === 'create' ? 'Criar template' : 'Salvar'" @click="onSave(editingTemplate)" />
</div>
</div>
</div>
<!-- Conteúdo -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3 xl:gap-4">
<!-- LIST VIEW -->
<template v-if="view === 'list'">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<div v-if="loading" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!templates.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
<i class="pi pi-file-edit text-4xl opacity-30 mb-3" />
<div class="text-sm mb-1">Nenhum template encontrado.</div>
<Button label="Criar primeiro template" icon="pi pi-plus" text size="small" class="mt-2" @click="openCreate" />
<!-- Empty (nenhum template) -->
<div v-else-if="!templates.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Templates cadastrados</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--surface-200,#e5e7eb)] text-[var(--text-color-secondary)] text-[0.72rem] font-bold">0</span>
</div>
<div class="py-10 px-6 text-center">
<i class="pi pi-file-edit text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum template encontrado</div>
<div class="text-xs opacity-60 mt-1">Crie seu primeiro template personalizado</div>
<Button label="Criar primeiro template" icon="pi pi-plus" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="openCreate" />
</div>
</div>
<template v-else>
<!-- Templates globais (padrao) -->
<div v-if="globalTemplates.length" class="mb-6">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
Templates padrão do sistema
<!-- Templates globais (padrão) -->
<div v-if="globalTemplates.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-shield text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Templates padrão do sistema</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-blue-500/10 text-blue-600 text-[0.72rem] font-bold">{{ globalTemplates.length }}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 p-3">
<div
v-for="tpl in globalTemplates"
:key="tpl.id"
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-[var(--surface-400)] hover:bg-[var(--surface-hover)] transition-all cursor-pointer"
class="group relative flex flex-col p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] hover:border-[var(--primary-color,#6366f1)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-all duration-150 cursor-pointer"
@click="onDuplicate(tpl)"
>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center">
<span class="shrink-0 w-9 h-9 rounded-md bg-blue-500/10 grid place-items-center">
<i class="pi pi-file text-blue-500" />
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-sm font-semibold text-[var(--text-color)] line-clamp-1">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2 opacity-75">{{ tpl.descricao }}</div>
</div>
</div>
<span class="absolute top-2 right-2 text-[0.6rem] px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">
padrão
</span>
<div class="mt-2 text-[0.65rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
Clique para duplicar e personalizar
<span class="absolute top-2 right-2 text-[0.6rem] font-semibold px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600">padrão</span>
<div class="mt-2 text-[0.7rem] text-[var(--text-color-secondary)] opacity-0 group-hover:opacity-100 transition-opacity">
<i class="pi pi-copy text-[0.7rem] mr-1" />Clique para duplicar e personalizar
</div>
</div>
</div>
</div>
<!-- Templates do tenant -->
<div v-if="tenantTemplates.length">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">
Meus templates
<div v-if="tenantTemplates.length" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-user-edit text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Meus templates</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.72rem] font-bold">{{ tenantTemplates.length }}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 p-3">
<div
v-for="tpl in tenantTemplates"
:key="tpl.id"
class="group relative flex flex-col p-4 rounded-lg border border-[var(--surface-border)] hover:border-primary hover:bg-primary/5 transition-all cursor-pointer"
class="group relative flex flex-col p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] hover:border-[var(--primary-color,#6366f1)] hover:bg-[var(--primary-color,#6366f1)]/5 transition-all duration-150 cursor-pointer"
@click="openEdit(tpl)"
>
<div class="flex items-start gap-3">
<span class="flex-shrink-0 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="pi pi-file-edit text-primary" />
<span class="shrink-0 w-9 h-9 rounded-md bg-indigo-500/10 grid place-items-center">
<i class="pi pi-file-edit text-indigo-500" />
</span>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ tpl.nome_template }}</div>
<div class="text-sm font-semibold text-[var(--text-color)] line-clamp-1">{{ tpl.nome_template }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2">{{ tpl.descricao }}</div>
<div v-if="tpl.descricao" class="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-2 opacity-75">{{ tpl.descricao }}</div>
</div>
</div>
<!-- Menu de acoes -->
<!-- Menu de ações -->
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
icon="pi pi-ellipsis-v"
@@ -266,14 +329,9 @@ function getCardMenuItems(tpl) {
</div>
<div class="flex items-center gap-2 mt-2">
<span
v-if="!tpl.ativo"
class="text-[0.6rem] px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500"
>
inativo
</span>
<span class="text-[0.6rem] text-[var(--text-color-secondary)]">
{{ tpl.variaveis?.length || 0 }} variáveis
<span v-if="!tpl.ativo" class="text-[0.6rem] font-semibold px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-500">inativo</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">
<i class="pi pi-code text-[0.6rem] mr-0.5" />{{ tpl.variaveis?.length || 0 }} variáveis
</span>
</div>
</div>
@@ -282,7 +340,7 @@ function getCardMenuItems(tpl) {
</template>
</template>
<!-- Create / Edit view -->
<!-- CREATE / EDIT VIEW -->
<template v-if="view === 'create' || view === 'edit'">
<DocumentTemplateEditor
v-model="editingTemplate"
@@ -291,7 +349,7 @@ function getCardMenuItems(tpl) {
@cancel="onCancel"
/>
</template>
<ConfirmDialog />
</div>
<ConfirmDialog />
</template>
+200 -198
View File
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
@@ -75,14 +75,32 @@ const mobileMenuItems = computed(() => [
// Hero sticky
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// Mobile dialogs
const filtersDlgOpen = ref(false)
// Lifecycle
onMounted(async () => {
if (!props.embedded) {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
}
await Promise.all([fetchDocuments(), fetchUsedTags()])
})
onBeforeUnmount(() => {
_observer?.disconnect()
})
// Acoes
async function onUploaded({ file, meta }) {
@@ -158,220 +176,204 @@ watch(filters, () => fetchDocuments(), { deep: true })
</script>
<template>
<div :class="embedded ? '' : 'px-4 py-6 max-w-[1200px] mx-auto'">
<!-- Hero header -->
<div
v-if="!embedded"
ref="headerEl"
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6"
>
<div>
<h1 class="text-xl font-bold">Documentos</h1>
<p class="text-sm text-[var(--text-color-secondary)]">
{{ resolvedPatientId ? patientName || 'Paciente' : 'Todos os pacientes' }}
</p>
</div>
<!-- Desktop actions -->
<div class="hidden sm:flex items-center gap-2">
<Button
label="Gerar documento"
icon="pi pi-file-pdf"
outlined
size="small"
@click="generateDlg = true"
:disabled="!resolvedPatientId"
/>
<Button
label="Upload"
icon="pi pi-upload"
size="small"
@click="uploadDlg = true"
:disabled="!resolvedPatientId"
/>
</div>
<!-- Mobile menu -->
<div class="sm:hidden">
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Embedded header (dentro do prontuario) -->
<div v-else class="flex items-center justify-between gap-2 mb-4">
<!--
EMBEDDED MODE dentro do prontuário (sem hero, layout compacto)
-->
<div v-if="embedded">
<!-- Header compacto -->
<div class="flex items-center justify-between gap-2 mb-4">
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
<div class="flex gap-1.5">
<Button
icon="pi pi-file-pdf"
text
rounded
size="small"
v-tooltip.top="'Gerar documento'"
@click="generateDlg = true"
/>
<Button
icon="pi pi-upload"
text
rounded
size="small"
v-tooltip.top="'Upload'"
@click="uploadDlg = true"
/>
<Button icon="pi pi-file-pdf" text rounded size="small" v-tooltip.top="'Gerar documento'" @click="generateDlg = true" />
<Button icon="pi pi-upload" text rounded size="small" v-tooltip.top="'Upload'" @click="uploadDlg = true" />
</div>
</div>
<!-- Quick stats -->
<div v-if="!embedded && documents.length" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ stats.total }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Total</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ formatSize(stats.tamanhoTotal) }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tamanho</span>
</div>
<div class="flex flex-col items-center p-3 rounded-lg bg-[var(--surface-ground)] border border-[var(--surface-border)]">
<span class="text-lg font-bold">{{ Object.keys(stats.porTipo).length }}</span>
<span class="text-[0.65rem] text-[var(--text-color-secondary)] uppercase tracking-wider">Tipos</span>
</div>
<div v-if="stats.pendentesRevisao" class="flex flex-col items-center p-3 rounded-lg bg-amber-500/5 border border-amber-500/20">
<span class="text-lg font-bold text-amber-600">{{ stats.pendentesRevisao }}</span>
<span class="text-[0.65rem] text-amber-600 uppercase tracking-wider">Pendentes</span>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2 mb-4">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filters.search"
placeholder="Buscar..."
class="!w-[200px]"
size="small"
/>
</IconField>
<Select
v-model="filters.tipo_documento"
:options="TIPOS_DOCUMENTO"
optionLabel="label"
optionValue="value"
placeholder="Tipo"
showClear
class="!w-[160px]"
size="small"
/>
<Select
v-if="usedTags.length"
v-model="filters.tag"
:options="usedTags.map(t => ({ label: t, value: t }))"
optionLabel="label"
optionValue="value"
placeholder="Tag"
showClear
class="!w-[140px]"
size="small"
/>
<Button
v-if="hasActiveFilter"
icon="pi pi-filter-slash"
text
rounded
size="small"
v-tooltip.top="'Limpar filtros'"
@click="clearFilters(); fetchDocuments()"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<div v-if="loading" class="flex items-center justify-center py-12">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="flex flex-col items-center justify-center py-16 text-[var(--text-color-secondary)]">
<i class="pi pi-inbox text-4xl opacity-30 mb-3" />
<div class="text-sm mb-1">
{{ hasActiveFilter ? 'Nenhum documento encontrado com esses filtros.' : 'Nenhum documento ainda.' }}
</div>
<Button
v-if="resolvedPatientId && !hasActiveFilter"
label="Enviar primeiro documento"
icon="pi pi-upload"
text
size="small"
class="mt-2"
@click="uploadDlg = true"
/>
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum documento ainda</div>
<div class="text-xs opacity-60 mt-1">Faça upload do primeiro documento deste paciente</div>
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
</div>
<!-- Lista de documentos -->
<!-- Lista -->
<div v-else class="flex flex-col gap-2">
<DocumentCard
v-for="doc in documents"
:key="doc.id"
:doc="doc"
@preview="onPreview"
@download="onDownload"
@edit="onEdit"
@delete="onDelete"
@share="onShare"
@sign="onSign"
/>
<DocumentCard v-for="doc in documents" :key="doc.id" :doc="doc" @preview="onPreview" @download="onDownload" @edit="onEdit" @delete="onDelete" @share="onShare" @sign="onSign" />
</div>
<!-- Error -->
<div v-if="error" class="mt-4 p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<div v-if="error" class="mt-4 p-3 rounded-md bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
</div>
<!-- Dialogs -->
<DocumentUploadDialog
:visible="uploadDlg"
@update:visible="uploadDlg = $event"
:patientId="resolvedPatientId"
:patientName="patientName"
:usedTags="usedTags"
@uploaded="onUploaded"
/>
<DocumentPreviewDialog
:visible="previewDlg"
@update:visible="previewDlg = $event"
:doc="selectedDoc"
:previewUrl="previewUrl"
@download="onDownload"
@edit="onEdit"
@delete="d => { previewDlg = false; onDelete(d) }"
@share="d => { previewDlg = false; onShare(d) }"
@sign="d => { previewDlg = false; onSign(d) }"
/>
<DocumentGenerateDialog
:visible="generateDlg"
@update:visible="generateDlg = $event"
:patientId="resolvedPatientId"
:patientName="patientName"
@generated="onGenerated"
/>
<DocumentSignatureDialog
:visible="signatureDlg"
@update:visible="signatureDlg = $event"
:doc="selectedDoc"
/>
<DocumentShareDialog
:visible="shareDlg"
@update:visible="shareDlg = $event"
:doc="selectedDoc"
/>
<ConfirmDialog />
</div>
<!--
PÁGINA FULL hero sticky + stats + lista em card
-->
<template v-else>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref="headerEl"
class="sticky my-3 md:mx-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/9" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Voltar + Brand -->
<div class="flex items-center gap-2 shrink-0">
<Button v-if="resolvedPatientId" icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar'" @click="router.back()" />
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-file text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Documentos</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">
{{ resolvedPatientId ? (patientName || 'Paciente') : 'Todos os pacientes' }}
</div>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Atualizar'" @click="fetchDocuments" />
<Button label="Gerar" icon="pi pi-file-pdf" severity="secondary" outlined size="small" class="rounded-full" :disabled="!resolvedPatientId" @click="generateDlg = true" />
<Button label="Upload" icon="pi pi-upload" size="small" class="rounded-full" :disabled="!resolvedPatientId" @click="uploadDlg = true" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-filter" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Filtros'" @click="filtersDlgOpen = true" />
<Button icon="pi pi-upload" class="h-9 w-9 rounded-full" :disabled="!resolvedPatientId" @click="uploadDlg = true" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- Dialog filtros mobile -->
<Dialog v-model:visible="filtersDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Filtros" class="w-[94vw] max-w-sm">
<div class="flex flex-col gap-3 pt-1">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters.search" placeholder="Buscar..." class="w-full" />
</IconField>
<Select v-model="filters.tipo_documento" :options="TIPOS_DOCUMENTO" optionLabel="label" optionValue="value" placeholder="Tipo" showClear class="w-full" />
<Select v-if="usedTags.length" v-model="filters.tag" :options="usedTags.map(t => ({ label: t, value: t }))" optionLabel="label" optionValue="value" placeholder="Tag" showClear class="w-full" />
</div>
<template #footer>
<Button v-if="hasActiveFilter" label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined class="rounded-full mr-2" @click="clearFilters(); fetchDocuments()" />
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="filtersDlgOpen = false" />
</template>
</Dialog>
<!-- Conteúdo -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3 xl:gap-4">
<!-- Stats -->
<div v-if="documents.length" class="grid grid-cols-2 md:grid-cols-4 gap-2">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ stats.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Total</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ formatSize(stats.tamanhoTotal) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Tamanho</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ Object.keys(stats.porTipo).length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Tipos</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border" :class="stats.pendentesRevisao ? 'border-amber-500/30 bg-amber-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'">
<div class="text-[1.35rem] font-bold leading-none" :class="stats.pendentesRevisao ? 'text-amber-600' : 'text-[var(--text-color)]'">{{ stats.pendentesRevisao || 0 }}</div>
<div class="text-[1rem] opacity-75 truncate" :class="stats.pendentesRevisao ? 'text-amber-600' : 'text-[var(--text-color-secondary)]'">Pendentes</div>
</div>
</div>
<!-- Tabela (card) -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Header da tabela -->
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-list text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Documentos</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.72rem] font-bold">{{ documents.length }}</span>
</div>
<!-- Filtros desktop -->
<div class="hidden md:flex flex-wrap items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="filters.search" placeholder="Buscar..." class="!w-[200px]" size="small" />
</IconField>
<Select v-model="filters.tipo_documento" :options="TIPOS_DOCUMENTO" optionLabel="label" optionValue="value" placeholder="Tipo" showClear class="!w-[160px]" size="small" />
<Select v-if="usedTags.length" v-model="filters.tag" :options="usedTags.map(t => ({ label: t, value: t }))" optionLabel="label" optionValue="value" placeholder="Tag" showClear class="!w-[140px]" size="small" />
<Button v-if="hasActiveFilter" icon="pi pi-filter-slash" severity="danger" text rounded size="small" v-tooltip.top="'Limpar filtros'" @click="clearFilters(); fetchDocuments()" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl text-[var(--text-color-secondary)]" />
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
</div>
<div class="text-xs opacity-60 mt-1">
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca' : resolvedPatientId ? 'Faça upload do primeiro documento' : 'Selecione um paciente para adicionar documentos' }}
</div>
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-2 p-3">
<DocumentCard v-for="doc in documents" :key="doc.id" :doc="doc" @preview="onPreview" @download="onDownload" @edit="onEdit" @delete="onDelete" @share="onShare" @sign="onSign" />
</div>
</div>
<!-- Error -->
<div v-if="error" class="p-3 rounded-md bg-red-500/5 border border-red-500/20 text-sm text-red-500">
<i class="pi pi-exclamation-circle mr-1" /> {{ error }}
</div>
</div>
</template>
<!--
Dialogs comuns (usados em ambos os modos)
-->
<DocumentUploadDialog :visible="uploadDlg" @update:visible="uploadDlg = $event" :patientId="resolvedPatientId" :patientName="patientName" :usedTags="usedTags" @uploaded="onUploaded" />
<DocumentPreviewDialog
:visible="previewDlg"
@update:visible="previewDlg = $event"
:doc="selectedDoc"
:previewUrl="previewUrl"
@download="onDownload"
@edit="onEdit"
@delete="d => { previewDlg = false; onDelete(d) }"
@share="d => { previewDlg = false; onShare(d) }"
@sign="d => { previewDlg = false; onSign(d) }"
/>
<DocumentGenerateDialog :visible="generateDlg" @update:visible="generateDlg = $event" :patientId="resolvedPatientId" :patientName="patientName" @generated="onGenerated" />
<DocumentSignatureDialog :visible="signatureDlg" @update:visible="signatureDlg = $event" :doc="selectedDoc" />
<DocumentShareDialog :visible="shareDlg" @update:visible="shareDlg = $event" :doc="selectedDoc" />
<ConfirmDialog />
</template>
@@ -90,118 +90,126 @@ function onSave() {
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Header: nome e tipo -->
<div class="grid grid-cols-1 sm:grid-cols-[1fr_200px] gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Nome do template</label>
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
<div class="flex flex-col gap-3 xl:gap-4">
<!-- Card: Identificação -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<i class="pi pi-tag text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Identificação</span>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Tipo</label>
<Select
v-model="form.tipo"
:options="TIPOS_TEMPLATE"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Descrição</label>
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
</div>
<!-- Tabs: Editor / Preview -->
<div class="flex items-center gap-1 border-b border-[var(--surface-border)]">
<button
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'editor' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = 'editor'"
>
Editor
</button>
<button
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors"
:class="activeTab === 'preview' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
@click="activeTab = 'preview'"
>
Preview
</button>
</div>
<!-- Editor -->
<div v-show="activeTab === 'editor'" class="flex flex-col lg:flex-row gap-4">
<!-- Campos HTML -->
<div class="flex-1 flex flex-col gap-3">
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
<div class="p-4 flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_220px] gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Nome do template</label>
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Tipo</label>
<Select v-model="form.tipo" :options="TIPOS_TEMPLATE" optionLabel="label" optionValue="value" class="w-full" />
</div>
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Rodapé</label>
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Descrição</label>
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
</div>
</div>
</div>
<!-- Painel de variaveis -->
<div class="w-full lg:w-[220px] flex-shrink-0">
<div class="sticky top-0">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">
Variáveis
</div>
<div class="text-[0.65rem] text-[var(--text-color-secondary)] mb-3">
Clique para inserir no campo ativo
<!-- Card: Conteúdo -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Conteúdo do documento</span>
</div>
<!-- Tabs: Editor / Preview -->
<div class="flex items-center gap-1">
<Button
:label="'Editor'"
icon="pi pi-pencil"
:severity="activeTab === 'editor' ? undefined : 'secondary'"
:outlined="activeTab !== 'editor'"
size="small"
class="rounded-full"
@click="activeTab = 'editor'"
/>
<Button
:label="'Preview'"
icon="pi pi-eye"
:severity="activeTab === 'preview' ? undefined : 'secondary'"
:outlined="activeTab !== 'preview'"
size="small"
class="rounded-full"
@click="activeTab = 'preview'"
/>
</div>
</div>
<!-- Editor -->
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
<!-- Campos HTML -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Cabeçalho</label>
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
</div>
<div class="flex flex-col gap-3 max-h-[500px] overflow-y-auto pr-1">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
<div class="text-[0.65rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
<div class="flex flex-col gap-0.5">
<button
v-for="v in vars"
:key="v.key"
class="text-left text-xs px-2 py-1 rounded hover:bg-primary/10 hover:text-primary transition-colors truncate"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="font-mono text-[0.65rem] opacity-60">&lbrace;&lbrace;</span>
{{ v.label }}
<span class="font-mono text-[0.65rem] opacity-60">&rbrace;&rbrace;</span>
</button>
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Corpo do documento</label>
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Rodapé</label>
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
</div>
</div>
<!-- Painel de variáveis -->
<div class="w-full lg:w-[240px] shrink-0">
<div class="lg:sticky lg:top-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50 overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 border-b border-[var(--surface-border,#e2e8f0)]">
<i class="pi pi-code text-[var(--text-color-secondary)] opacity-60 text-xs" />
<span class="font-semibold text-xs">Variáveis</span>
</div>
<div class="px-3 pt-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75 italic">Clique para inserir no campo ativo</div>
<div class="flex flex-col gap-3 p-3 max-h-[500px] overflow-y-auto">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
<div class="text-[0.62rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
<div class="flex flex-col gap-0.5">
<button
v-for="v in vars"
:key="v.key"
class="text-left text-xs px-2 py-1 rounded-md bg-transparent border-none hover:bg-[var(--primary-color,#6366f1)]/10 hover:text-[var(--primary-color,#6366f1)] transition-colors truncate cursor-pointer"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="font-mono text-[0.62rem] opacity-60">&lbrace;&lbrace;</span>
{{ v.label }}
<span class="font-mono text-[0.62rem] opacity-60">&rbrace;&rbrace;</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
<div class="min-h-[300px]" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
<!-- Preview -->
<div v-show="activeTab === 'preview'" class="p-4">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-white overflow-hidden">
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
<div class="min-h-[300px]" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
</div>
</div>
</div>
</div>
<!-- Acoes -->
<div class="flex items-center justify-end gap-2 pt-2">
<Button label="Cancelar" text @click="emit('cancel')" />
<Button :label="mode === 'create' ? 'Criar template' : 'Salvar'" icon="pi pi-check" @click="onSave" />
</div>
</div>
</template>

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