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:
+12
-1
@@ -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
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
-- ==========================================================================
|
||||
+7094
-755
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,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])))
|
||||
|
||||
@@ -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)))
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+1461
-44
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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 já 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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 (só 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 (só 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 (só em edição) -->
|
||||
<Button
|
||||
v-if="patientId"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
<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" class="rounded-full mt-2" @click="goBack" />
|
||||
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" size="small" class="rounded-full mt-3" @click="goBack" />
|
||||
</div>
|
||||
</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>
|
||||
@@ -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>
|
||||
<p v-if="view === 'list'" class="text-sm text-[var(--text-color-secondary)]">
|
||||
Modelos para declarações, atestados, recibos e outros documentos
|
||||
</p>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Modelos para declarações, atestados, recibos e outros documentos</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- List view -->
|
||||
<!-- Ações CREATE/EDIT (mobile — só í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>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<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 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>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,134 +176,153 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="embedded ? '' : 'px-4 py-6 max-w-[1200px] mx-auto'">
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero header -->
|
||||
<!-- Loading -->
|
||||
<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="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 -->
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
PÁGINA FULL — hero sticky + stats + lista em card
|
||||
══════════════════════════════════════════════════════════════ -->
|
||||
<template v-else>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
v-if="!embedded"
|
||||
ref="headerEl"
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6"
|
||||
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)' }"
|
||||
>
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 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>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="sm:hidden">
|
||||
<Button icon="pi pi-ellipsis-v" text rounded @click="mobileMenuRef.toggle($event)" />
|
||||
<!-- 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>
|
||||
|
||||
<!-- Embedded header (dentro do prontuario) -->
|
||||
<div v-else 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"
|
||||
/>
|
||||
</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">
|
||||
<!-- 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-[200px]"
|
||||
size="small"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Select
|
||||
v-model="filters.tipo_documento"
|
||||
:options="TIPOS_DOCUMENTO"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Tipo"
|
||||
showClear
|
||||
class="!w-[160px]"
|
||||
size="small"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<!-- 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>
|
||||
|
||||
<Button
|
||||
v-if="hasActiveFilter"
|
||||
icon="pi pi-filter-slash"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
v-tooltip.top="'Limpar filtros'"
|
||||
@click="clearFilters(); fetchDocuments()"
|
||||
/>
|
||||
<!-- 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 -->
|
||||
@@ -294,51 +331,34 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
||||
</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 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>
|
||||
<Button
|
||||
v-if="resolvedPatientId && !hasActiveFilter"
|
||||
label="Enviar primeiro documento"
|
||||
icon="pi pi-upload"
|
||||
text
|
||||
size="small"
|
||||
class="mt-2"
|
||||
@click="uploadDlg = true"
|
||||
/>
|
||||
<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 de documentos -->
|
||||
<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"
|
||||
/>
|
||||
<!-- 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="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="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 -->
|
||||
<DocumentUploadDialog
|
||||
:visible="uploadDlg"
|
||||
@update:visible="uploadDlg = $event"
|
||||
:patientId="resolvedPatientId"
|
||||
:patientName="patientName"
|
||||
:usedTags="usedTags"
|
||||
@uploaded="onUploaded"
|
||||
/>
|
||||
<!-- ══════════════════════════════════════════════════════════════
|
||||
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"
|
||||
@@ -352,26 +372,8 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
||||
@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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -90,97 +90,107 @@ 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-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="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-medium text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<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-medium text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<Select
|
||||
v-model="form.tipo"
|
||||
:options="TIPOS_TEMPLATE"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- ══ 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>
|
||||
|
||||
<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">
|
||||
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 flex flex-col gap-3">
|
||||
<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-medium text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<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-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<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-medium text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<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 class="flex flex-col gap-1">
|
||||
<label class="text-xs font-medium text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</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
|
||||
<!-- 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="text-[0.65rem] text-[var(--text-color-secondary)] mb-3">
|
||||
Clique para inserir no campo ativo
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 max-h-[500px] overflow-y-auto pr-1">
|
||||
<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.65rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<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 hover:bg-primary/10 hover:text-primary transition-colors truncate"
|
||||
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.65rem] opacity-60">{{</span>
|
||||
<span class="font-mono text-[0.62rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.65rem] opacity-60">}}</span>
|
||||
<span class="font-mono text-[0.62rem] opacity-60">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,18 +200,16 @@ function onSave() {
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="border border-[var(--surface-border)] rounded-lg bg-white overflow-hidden">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user