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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Stores: conversationDrawerStore

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

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

Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 07:05:24 -03:00
parent 037ba3721f
commit 2644e60bb6
191 changed files with 38629 additions and 3756 deletions
File diff suppressed because one or more lines are too long
+43 -7
View File
@@ -86,7 +86,9 @@
"billing_contracts", "entitlements_invalidation"
],
"Addons / Créditos": [
"addon_products", "addon_credits", "addon_transactions"
"addon_products", "addon_credits", "addon_transactions",
"whatsapp_credits_balance", "whatsapp_credits_transactions",
"whatsapp_credit_packages", "whatsapp_credit_purchases"
],
"Tenants / Multi-tenant": [
"tenants", "profiles", "user_settings",
@@ -99,7 +101,9 @@
"patient_groups", "patient_group_patient",
"patient_tags", "patient_patient_tag",
"patient_discounts", "patient_intake_requests", "patient_invites",
"patient_status_history", "patient_timeline"
"patient_status_history", "patient_timeline",
"contact_types", "contact_phones",
"contact_email_types", "contact_emails"
],
"Agenda / Agendamento": [
"agenda_eventos", "agenda_bloqueios", "agenda_configuracoes", "agenda_excecoes",
@@ -130,6 +134,17 @@
"notifications", "notice_dismissals", "global_notices", "login_carousel_slides",
"twilio_subaccount_usage"
],
"CRM Conversas (WhatsApp)": [
"conversation_messages", "conversation_threads",
"conversation_notes",
"conversation_tags", "conversation_thread_tags",
"conversation_optouts", "conversation_optout_keywords",
"conversation_autoreply_settings", "conversation_autoreply_log",
"session_reminder_settings", "session_reminder_logs"
],
"Segurança / Rate limiting": [
"submission_rate_limits"
],
"Central SaaS (docs/FAQ)": [
"saas_docs", "saas_doc_votos", "saas_faq", "saas_faq_itens"
],
@@ -147,6 +162,8 @@
"Serviços / Prontuários": "#34d399",
"Documentos": "#0ea5e9",
"Comunicação / Notificações": "#fbbf24",
"CRM Conversas (WhatsApp)": "#25d366",
"Segurança / Rate limiting": "#ef4444",
"Central SaaS (docs/FAQ)": "#c084fc",
"Estrutura / Calendário": "#fb923c"
},
@@ -201,17 +218,36 @@
"items": [
{
"name": "Evolution API",
"role": "Integração WhatsApp Business (envio/recebimento)",
"role": "WhatsApp self-hosted via Baileys (tier gratuito do SaaS — 'WhatsApp Pessoal')",
"env": "Local (Docker)",
"status": "ativo",
"notes": "Container via evolution-api/. whatsapp_instances e notification_channels já cadastrados. Integração real está sendo costurada."
"notes": "Container via evolution-api/docker-compose.yml. Uso do usuário conecta via QR code no celular real. Sem SLA, Meta pode banir número. Envio sem custo. Edge functions: evolution-whatsapp-inbound, evolution-webhook-provision, send-whatsapp-message."
},
{
"name": "Twilio (SMS/Voz)",
"role": "Provedor de SMS e voz para notificações",
"name": "Twilio WhatsApp Business API",
"role": "WhatsApp oficial (tier pago rebrandeado como 'WhatsApp Oficial AgenciaPSI')",
"env": "Cloud",
"status": "ativo",
"notes": "twilio_subaccount_usage rastreia consumo por tenant. SaasTwilioWhatsappPage gerencia contas."
"notes": "API oficial Meta, zero risco de ban. Credenciais em notification_channels (twilio_subaccount_sid + credentials.subaccount_auth_token). Envio consome 1 crédito via RPC deduct_whatsapp_credits (atômico + rollback em falha). Provisionamento: supabase/functions/twilio-whatsapp-provision/."
}
]
},
"Pagamentos / Billing": {
"color": "#fb923c",
"items": [
{
"name": "Asaas (gateway PIX/cartão/boleto)",
"role": "Processamento de pagamentos pra compra de créditos WhatsApp (Marco B)",
"env": "Cloud — sandbox.asaas.com em dev, api.asaas.com em prod",
"status": "ativo",
"notes": "API key em ASAAS_API_KEY (env secret). URL em ASAAS_API_URL. Webhook token opcional em ASAAS_WEBHOOK_TOKEN. Edge functions: create-whatsapp-credit-charge (cria customer + PIX), asaas-webhook (recebe PAYMENT_RECEIVED/CONFIRMED e credita saldo via add_whatsapp_credits)."
},
{
"name": "ngrok (dev only — tunnel pro webhook)",
"role": "Expõe edge functions locais pra Asaas alcançar via internet",
"env": "Local (dev)",
"status": "opcional",
"notes": "Uso: ngrok http 54321 → copia URL e cadastra em Asaas → Integrações → Webhooks → /functions/v1/asaas-webhook. Necessário só pra testar fluxo completo local incluindo confirmação de pagamento."
}
]
},
@@ -0,0 +1,163 @@
-- ============================================================
-- Fix: Remove templates com keys em inglês (WhatsApp/SMS)
-- Agência PSI — 2026-04-22
-- ============================================================
-- Ambiente de desenvolvimento sem dados reais: DELETE físico.
-- Mantém apenas as keys canônicas em português definidas
-- pelo seed_014_global_data.sql. Se alguma key PT estiver
-- faltando após rodar esta migration, rode o Step 3 de reseed.
--
-- Idempotente: rodar de novo não causa erro (DELETE simples
-- encontra 0 linhas).
-- ============================================================
BEGIN;
-- ── Step 1: Snapshot ANTES ────────────────────────────────────────────
-- Útil pra conferir o que vai ser apagado
SELECT
'BEFORE' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active,
deleted_at
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 2: DELETE físico de todas as keys em inglês ─────────────────
DELETE FROM public.notification_templates
WHERE key IN (
-- WhatsApp — variantes em inglês
'session.reminder.whatsapp',
'session.reminder_2h.whatsapp',
'session.confirmation.whatsapp',
'session.cancellation.whatsapp',
'session.reschedule.whatsapp',
'session.rescheduled.whatsapp',
'billing.pending.whatsapp',
'system.welcome.whatsapp',
-- SMS — variantes em inglês
'session.reminder.sms',
'session.reminder_2h.sms',
'session.confirmation.sms',
'session.cancellation.sms',
'session.reschedule.sms',
'session.rescheduled.sms',
'billing.pending.sms',
'system.welcome.sms'
);
-- ── Step 3: Re-seed (inserção idempotente) das keys PT canônicas ─────
-- Garante que todas as keys esperadas existem como globais ativas.
-- Usa INSERT … ON CONFLICT DO UPDATE para ser idempotente.
-- Os body_text são placeholders padrão; se quiser textos diferentes,
-- edite depois via /configuracoes/whatsapp-templates ou /saas/notification-templates.
INSERT INTO public.notification_templates
(tenant_id, owner_id, key, domain, channel, event_type, body_text, variables, is_default, is_active)
VALUES
-- ── WhatsApp ─────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Lembrete: sua sessão com {{therapist_name}} é amanhã às {{session_time}}. Até lá!',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.whatsapp', 'session', 'whatsapp', 'lembrete_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} começa em 2 horas ({{session_time}}). Até já!',
'["patient_name","therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.whatsapp', 'session', 'whatsapp', 'confirmacao_sessao',
'Olá {{patient_name}}! Sua sessão com {{therapist_name}} foi confirmada para {{session_date}} às {{session_time}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.whatsapp', 'session', 'whatsapp', 'cancelamento_sessao',
'Olá {{patient_name}}. Sua sessão de {{session_date}} às {{session_time}} foi cancelada. Entre em contato para remarcar.',
'["patient_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.whatsapp', 'session', 'whatsapp', 'reagendamento',
'Olá {{patient_name}}! Sua sessão foi reagendada para {{session_date}} às {{session_time}} com {{therapist_name}}.',
'["patient_name","therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.whatsapp', 'billing', 'whatsapp', 'cobranca_pendente',
'Olá {{patient_name}}! Identificamos um pagamento pendente de {{valor}} com vencimento em {{vencimento}}. Qualquer dúvida, estou à disposição.',
'["patient_name","valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.whatsapp', 'system', 'whatsapp', 'boas_vindas_paciente',
'Olá {{patient_name}}! Bem-vindo(a) à {{clinic_name}}. Seu terapeuta {{therapist_name}} está à disposição.',
'["patient_name","clinic_name","therapist_name"]'::jsonb, true, true),
-- ── SMS ──────────────────────────────────────────────────────────────
(NULL, NULL, 'session.lembrete.sms', 'session', 'sms', 'lembrete_sessao',
'Lembrete: sua sessao com {{therapist_name}} e amanha as {{session_time}}.',
'["therapist_name","session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.lembrete_2h.sms', 'session', 'sms', 'lembrete_sessao',
'Sua sessao com {{therapist_name}} comeca em 2h ({{session_time}}).',
'["therapist_name","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.confirmacao.sms', 'session', 'sms', 'confirmacao_sessao',
'Sua sessao foi confirmada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.cancelamento.sms', 'session', 'sms', 'cancelamento_sessao',
'Sua sessao de {{session_date}} as {{session_time}} foi cancelada.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'session.reagendamento.sms', 'session', 'sms', 'reagendamento',
'Sua sessao foi reagendada para {{session_date}} as {{session_time}}.',
'["session_date","session_time"]'::jsonb, true, true),
(NULL, NULL, 'cobranca.pendente.sms', 'billing', 'sms', 'cobranca_pendente',
'Pagamento pendente: {{valor}}, venc. {{vencimento}}.',
'["valor","vencimento"]'::jsonb, true, true),
(NULL, NULL, 'sistema.boas_vindas.sms', 'system', 'sms', 'boas_vindas_paciente',
'Bem-vindo a {{clinic_name}}! Seu terapeuta e {{therapist_name}}.',
'["clinic_name","therapist_name"]'::jsonb, true, true)
ON CONFLICT (tenant_id, owner_id, key, deleted_at)
DO UPDATE SET
body_text = EXCLUDED.body_text,
variables = EXCLUDED.variables,
is_default = EXCLUDED.is_default,
is_active = EXCLUDED.is_active,
domain = EXCLUDED.domain,
event_type = EXCLUDED.event_type,
updated_at = now();
-- ── Step 4: Snapshot DEPOIS ──────────────────────────────────────────
SELECT
'AFTER' AS stage,
key,
channel,
event_type,
tenant_id IS NULL AS is_global,
is_default,
is_active
FROM public.notification_templates
WHERE channel IN ('whatsapp', 'sms')
AND deleted_at IS NULL
ORDER BY channel, event_type, tenant_id NULLS FIRST, key;
-- ── Step 5: Verificação — esperado 0 linhas ativas em EN ─────────────
SELECT
count(*) AS remaining_english_keys
FROM public.notification_templates
WHERE deleted_at IS NULL
AND key IN (
'session.reminder.whatsapp', 'session.reminder_2h.whatsapp', 'session.confirmation.whatsapp',
'session.cancellation.whatsapp', 'session.reschedule.whatsapp', 'session.rescheduled.whatsapp',
'billing.pending.whatsapp', 'system.welcome.whatsapp',
'session.reminder.sms', 'session.reminder_2h.sms', 'session.confirmation.sms',
'session.cancellation.sms', 'session.reschedule.sms', 'session.rescheduled.sms',
'billing.pending.sms', 'system.welcome.sms'
);
COMMIT;
@@ -0,0 +1,107 @@
-- =============================================================================
-- Migration: 20260420000001_patient_intake_invite_info_rpc
-- A#31 — RPC read-only de lookup público do terapeuta/clínica a partir do
-- token do patient_invite. Consumida pela edge function get-intake-invite-info
-- para alimentar o "hero header" da página /cadastro/paciente.
--
-- Segurança:
-- • SECURITY DEFINER (ignora RLS de profiles/company_profiles)
-- • Valida token: existe, ativo, não-expirado, dentro do max_uses
-- • Retorna APENAS campos explicitamente seguros (não-sensíveis)
-- • Execute revogado de PUBLIC/anon; grantado só para service_role (edge)
-- e authenticated (usos internos futuros)
--
-- Payload devolvido:
-- { ok: true, info: { therapist: {...}, clinic: {...}|null } }
-- { error: 'invalid-token' } — token inválido/expirado/esgotado
-- { error: 'missing-token' } — input vazio
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_patient_intake_invite_info(p_token text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_token_clean text;
v_invite RECORD;
v_result jsonb;
BEGIN
v_token_clean := nullif(trim(coalesce(p_token, '')), '');
IF v_token_clean IS NULL THEN
RETURN jsonb_build_object('error', 'missing-token');
END IF;
SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
INTO v_invite
FROM public.patient_invites pi
WHERE pi.token = v_token_clean
LIMIT 1;
IF v_invite.owner_id IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.active IS DISTINCT FROM true THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
SELECT jsonb_build_object(
'therapist', jsonb_build_object(
'display_name', coalesce(
nullif(trim(p.full_name), ''),
nullif(trim(p.nickname), ''),
'Profissional'
),
'avatar_url', nullif(trim(coalesce(p.avatar_url, '')), ''),
'work_description', nullif(trim(coalesce(p.work_description, '')), ''),
'bio', nullif(trim(coalesce(p.bio, '')), ''),
'phone', nullif(trim(coalesce(p.phone, '')), ''),
'site_url', nullif(trim(coalesce(p.site_url, '')), ''),
'instagram', nullif(trim(coalesce(p.social_instagram, '')), '')
),
'clinic', CASE
WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
'name', nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
'logo_url', nullif(trim(coalesce(cp.logo_url, '')), ''),
'email', nullif(trim(coalesce(cp.email, '')), ''),
'phone', nullif(trim(coalesce(cp.telefone, '')), ''),
'site', nullif(trim(coalesce(cp.site, '')), ''),
'city', nullif(trim(coalesce(cp.cidade, '')), ''),
'state', nullif(trim(coalesce(cp.estado, '')), ''),
'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
'street', nullif(trim(coalesce(cp.logradouro, '')), ''),
'number', nullif(trim(coalesce(cp.numero, '')), ''),
'social', coalesce(cp.redes_sociais, '[]'::jsonb)
)
ELSE NULL
END
)
INTO v_result
FROM public.profiles p
LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
WHERE p.id = v_invite.owner_id
LIMIT 1;
IF v_result IS NULL THEN
RETURN jsonb_build_object('error', 'invalid-token');
END IF;
RETURN jsonb_build_object('ok', true, 'info', v_result);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_patient_intake_invite_info(text) TO authenticated, service_role;
COMMENT ON FUNCTION public.get_patient_intake_invite_info(text) IS
'A#31 — Lookup público read-only (via edge function) dos dados de apresentação do terapeuta/clínica dono do link de cadastro externo. Só retorna campos não-sensíveis.';
@@ -0,0 +1,199 @@
-- =============================================================================
-- Migration: 20260420000002_audit_logs_lgpd
-- Sessao 11 - Fase 2a (Opcao C).
--
-- Resolve: LGPD Art. 37 - registro das operacoes de tratamento.
-- Projeto ja tinha logs pontuais (document_access_logs, patient_status_history,
-- notification_logs, addon_transactions) mas nao registrava:
-- - Edicao de dados do paciente (nome/CPF/endereco)
-- - CRUD de sessoes na agenda
-- - CRUD de registros financeiros
-- - CRUD de documentos (metadata)
-- - Mudancas de permissao / members do tenant
--
-- Cria tabela audit_logs imutavel + funcao trigger generica + triggers nas
-- tabelas criticas. RLS: tenant member le; ninguem INSERT/UPDATE/DELETE direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela audit_logs
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
action TEXT NOT NULL CHECK (action IN ('insert', 'update', 'delete')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created
ON public.audit_logs (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity
ON public.audit_logs (entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created
ON public.audit_logs (user_id, created_at DESC) WHERE user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_fields
ON public.audit_logs USING GIN (changed_fields);
COMMENT ON TABLE public.audit_logs IS
'Registro imutavel de operacoes de tratamento (LGPD Art. 37). INSERT apenas via trigger SECURITY DEFINER.';
-- ---------------------------------------------------------------------------
-- Funcao trigger generica
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.log_audit_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_tenant_id UUID;
v_entity_id TEXT;
v_old JSONB;
v_new JSONB;
v_changed TEXT[];
v_heavy_fields TEXT[] := ARRAY[
'content', 'content_html', 'content_json', 'raw_data',
'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
];
v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
BEGIN
IF TG_OP = 'DELETE' THEN
v_tenant_id := OLD.tenant_id;
v_entity_id := OLD.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := NULL;
ELSIF TG_OP = 'INSERT' THEN
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := NULL;
v_new := to_jsonb(NEW) - v_heavy_fields;
ELSE -- UPDATE
v_tenant_id := NEW.tenant_id;
v_entity_id := NEW.id::TEXT;
v_old := to_jsonb(OLD) - v_heavy_fields;
v_new := to_jsonb(NEW) - v_heavy_fields;
-- calcular campos realmente alterados
SELECT array_agg(key ORDER BY key) INTO v_changed
FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;
-- se nada mudou, ignora
IF v_changed IS NULL THEN
RETURN NEW;
END IF;
-- se mudou apenas campos de ruido (ex: updated_at), ignora
IF v_changed <@ v_noise_fields THEN
RETURN NEW;
END IF;
END IF;
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields
) VALUES (
v_tenant_id,
auth.uid(),
TG_TABLE_NAME,
v_entity_id,
lower(TG_OP),
v_old,
v_new,
v_changed
);
RETURN COALESCE(NEW, OLD);
END;
$$;
COMMENT ON FUNCTION public.log_audit_change() IS
'Trigger generica de audit. Filtra campos pesados (content, signature_data) e ruido (updated_at).';
-- ---------------------------------------------------------------------------
-- Triggers nas tabelas criticas
-- ---------------------------------------------------------------------------
-- patients
DROP TRIGGER IF EXISTS trg_audit_patients ON public.patients;
CREATE TRIGGER trg_audit_patients
AFTER INSERT OR UPDATE OR DELETE ON public.patients
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- agenda_eventos
DROP TRIGGER IF EXISTS trg_audit_agenda_eventos ON public.agenda_eventos;
CREATE TRIGGER trg_audit_agenda_eventos
AFTER INSERT OR UPDATE OR DELETE ON public.agenda_eventos
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- financial_records
DROP TRIGGER IF EXISTS trg_audit_financial_records ON public.financial_records;
CREATE TRIGGER trg_audit_financial_records
AFTER INSERT OR UPDATE OR DELETE ON public.financial_records
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- documents
DROP TRIGGER IF EXISTS trg_audit_documents ON public.documents;
CREATE TRIGGER trg_audit_documents
AFTER INSERT OR UPDATE OR DELETE ON public.documents
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- tenant_members (mudanca de permissao)
DROP TRIGGER IF EXISTS trg_audit_tenant_members ON public.tenant_members;
CREATE TRIGGER trg_audit_tenant_members
AFTER INSERT OR UPDATE OR DELETE ON public.tenant_members
FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; saas_admin le tudo; ninguem escreve direto
-- ---------------------------------------------------------------------------
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "audit_logs: select tenant" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: saas_admin all" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct insert" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct update" ON public.audit_logs;
DROP POLICY IF EXISTS "audit_logs: no direct delete" ON public.audit_logs;
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Explicitamente NEGA insert/update/delete via API
-- (SECURITY DEFINER na funcao trigger bypassa RLS; app nao consegue escrever direto)
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs
FOR UPDATE TO authenticated
USING (false) WITH CHECK (false);
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs
FOR DELETE TO authenticated
USING (false);
-- ---------------------------------------------------------------------------
-- Marca hardening na auditoria
-- ---------------------------------------------------------------------------
COMMENT ON COLUMN public.audit_logs.old_values IS 'Estado anterior (jsonb); NULL em INSERT; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.new_values IS 'Estado posterior (jsonb); NULL em DELETE; campos pesados removidos';
COMMENT ON COLUMN public.audit_logs.changed_fields IS 'Lista de campos alterados em UPDATE (NULL em INSERT/DELETE)';
@@ -0,0 +1,148 @@
-- =============================================================================
-- Migration: 20260420000003_audit_logs_unified_view
-- Sessao 11 - Fase 2a (Opcao C).
--
-- View audit_log_unified que junta:
-- - audit_logs (nova, trigger generico em patients/agenda/etc)
-- - document_access_logs (visualizacao/download/impressao de documento)
-- - patient_status_history (mudancas de status de paciente)
-- - notification_logs (envio de SMS/email/WhatsApp)
-- - addon_transactions (compras/consumos de recursos extras)
--
-- RLS: aplica-se das tabelas base. View usa security_invoker para herdar.
-- =============================================================================
DROP VIEW IF EXISTS public.audit_log_unified CASCADE;
CREATE VIEW public.audit_log_unified
WITH (security_invoker = true)
AS
-- 1) audit_logs (trigger generico)
SELECT
'audit:' || al.id::text AS uid,
al.tenant_id AS tenant_id,
al.user_id AS user_id,
al.entity_type AS entity_type,
al.entity_id AS entity_id,
al.action AS action,
CASE al.action
WHEN 'insert' THEN 'Criou ' || al.entity_type
WHEN 'update' THEN 'Alterou ' || al.entity_type
|| COALESCE(' (' || array_to_string(al.changed_fields, ', ') || ')', '')
WHEN 'delete' THEN 'Excluiu ' || al.entity_type
END AS description,
al.created_at AS occurred_at,
'audit_logs' AS source,
jsonb_build_object(
'old_values', al.old_values,
'new_values', al.new_values,
'changed_fields', al.changed_fields
) AS details
FROM public.audit_logs al
UNION ALL
-- 2) document_access_logs
SELECT
'doc_access:' || dal.id::text,
dal.tenant_id,
dal.user_id,
'document' AS entity_type,
dal.documento_id::text AS entity_id,
dal.acao AS action,
CASE dal.acao
WHEN 'visualizou' THEN 'Visualizou documento'
WHEN 'baixou' THEN 'Baixou documento'
WHEN 'imprimiu' THEN 'Imprimiu documento'
WHEN 'compartilhou' THEN 'Compartilhou documento'
WHEN 'assinou' THEN 'Assinou documento'
ELSE dal.acao
END AS description,
dal.acessado_em AS occurred_at,
'document_access_logs' AS source,
jsonb_build_object(
'ip', dal.ip::text,
'user_agent', dal.user_agent
) AS details
FROM public.document_access_logs dal
UNION ALL
-- 3) patient_status_history
SELECT
'psh:' || psh.id::text,
psh.tenant_id,
psh.alterado_por,
'patient_status' AS entity_type,
psh.patient_id::text AS entity_id,
'status_change' AS action,
'Status do paciente: '
|| COALESCE(psh.status_anterior, '') || '' || psh.status_novo
|| COALESCE(' (' || psh.motivo || ')', '') AS description,
psh.alterado_em AS occurred_at,
'patient_status_history' AS source,
jsonb_build_object(
'status_anterior', psh.status_anterior,
'status_novo', psh.status_novo,
'motivo', psh.motivo,
'encaminhado_para', psh.encaminhado_para,
'data_saida', psh.data_saida
) AS details
FROM public.patient_status_history psh
UNION ALL
-- 4) notification_logs
SELECT
'notif:' || nl.id::text,
nl.tenant_id,
nl.owner_id AS user_id,
'notification' AS entity_type,
nl.patient_id::text AS entity_id,
nl.status AS action,
'Notificação ' || nl.channel || ' '
|| nl.status
|| COALESCE(' para ' || nl.recipient_address, '') AS description,
nl.created_at AS occurred_at,
'notification_logs' AS source,
jsonb_build_object(
'channel', nl.channel,
'template_key', nl.template_key,
'status', nl.status,
'provider', nl.provider,
'failure_reason', nl.failure_reason
) AS details
FROM public.notification_logs nl
UNION ALL
-- 5) addon_transactions
SELECT
'addon:' || at.id::text,
at.tenant_id,
at.admin_user_id AS user_id,
'addon_transaction' AS entity_type,
at.id::text AS entity_id,
at.type AS action,
CASE at.type
WHEN 'purchase' THEN 'Compra de ' || at.amount || ' créditos de ' || at.addon_type
WHEN 'consumption' THEN 'Consumo de ' || abs(at.amount) || ' crédito(s) ' || at.addon_type
WHEN 'adjustment' THEN 'Ajuste de créditos ' || at.addon_type
WHEN 'refund' THEN 'Reembolso de ' || abs(at.amount) || ' créditos ' || at.addon_type
ELSE at.type || ' ' || at.addon_type
END AS description,
at.created_at AS occurred_at,
'addon_transactions' AS source,
jsonb_build_object(
'addon_type', at.addon_type,
'amount', at.amount,
'balance_after', at.balance_after,
'price_cents', at.price_cents,
'payment_reference', at.payment_reference
) AS details
FROM public.addon_transactions at;
COMMENT ON VIEW public.audit_log_unified IS
'Timeline unificada de eventos auditaveis (LGPD). Herda RLS das tabelas base via security_invoker.';
GRANT SELECT ON public.audit_log_unified TO authenticated;
@@ -0,0 +1,225 @@
-- =============================================================================
-- Migration: 20260420000004_lgpd_export_patient_rpc
-- Sessao 11 - Fase 2b (Opcao C).
--
-- Implementa LGPD Art. 18 - direito de portabilidade do titular.
-- RPC export_patient_data(p_patient_id uuid) retorna jsonb com todos os dados
-- relacionados ao paciente. Registra o evento em audit_logs para rastreabilidade.
--
-- Seguranca:
-- - SECURITY DEFINER permite agregar tabelas diversas bypassando RLS
-- - Verificacao explicita: caller deve ser tenant_member ativo da clinica do paciente
-- - Proibido acesso cross-tenant
-- =============================================================================
CREATE OR REPLACE FUNCTION public.export_patient_data(p_patient_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_patient RECORD;
v_tenant_id UUID;
v_caller UUID;
v_is_member BOOLEAN;
v_result JSONB;
BEGIN
v_caller := auth.uid();
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
END IF;
-- carrega paciente
SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
END IF;
v_tenant_id := v_patient.tenant_id;
-- verifica se caller e membro ativo do tenant do paciente
SELECT EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.tenant_id = v_tenant_id
AND tm.user_id = v_caller
AND tm.status = 'active'
) OR public.is_saas_admin() INTO v_is_member;
IF NOT v_is_member THEN
RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
END IF;
-- monta o payload
v_result := jsonb_build_object(
'export_metadata', jsonb_build_object(
'generated_at', now(),
'generated_by', v_caller,
'tenant_id', v_tenant_id,
'patient_id', p_patient_id,
'lgpd_basis', 'Art. 18, II - portabilidade dos dados do titular',
'controller', 'AgenciaPSI - Clinica responsavel',
'format_version', '1.0'
),
'paciente', to_jsonb(v_patient),
'contatos', COALESCE((
SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at)
FROM public.patient_contacts pc
WHERE pc.patient_id = p_patient_id
), '[]'::jsonb),
'contatos_apoio', COALESCE((
SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at)
FROM public.patient_support_contacts psc
WHERE psc.patient_id = p_patient_id
), '[]'::jsonb),
'historico_status', COALESCE((
SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em)
FROM public.patient_status_history psh
WHERE psh.patient_id = p_patient_id
), '[]'::jsonb),
'timeline', COALESCE((
SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em)
FROM public.patient_timeline pt
WHERE pt.patient_id = p_patient_id
), '[]'::jsonb),
'descontos', COALESCE((
SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at)
FROM public.patient_discounts pd
WHERE pd.patient_id = p_patient_id
), '[]'::jsonb),
'eventos_agenda', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', ae.id,
'tipo', ae.tipo,
'inicio_em', ae.inicio_em,
'fim_em', ae.fim_em,
'status', ae.status,
'observacoes', ae.observacoes,
'created_at', ae.created_at
) ORDER BY ae.inicio_em
)
FROM public.agenda_eventos ae
WHERE ae.patient_id = p_patient_id
), '[]'::jsonb),
'registros_financeiros', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', fr.id,
'amount', fr.amount,
'discount_amount', fr.discount_amount,
'final_amount', fr.final_amount,
'status', fr.status,
'due_date', fr.due_date,
'paid_at', fr.paid_at,
'payment_method', fr.payment_method,
'notes', fr.notes,
'created_at', fr.created_at
) ORDER BY fr.created_at
)
FROM public.financial_records fr
WHERE fr.patient_id = p_patient_id
), '[]'::jsonb),
'documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', d.id,
'nome_original', d.nome_original,
'tipo_documento', d.tipo_documento,
'categoria', d.categoria,
'descricao', d.descricao,
'mime_type', d.mime_type,
'tamanho_bytes', d.tamanho_bytes,
'status_revisao', d.status_revisao,
'visibilidade', d.visibilidade,
'uploaded_at', d.uploaded_at,
'created_at', d.created_at
) ORDER BY d.created_at
)
FROM public.documents d
WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
), '[]'::jsonb),
'notificacoes_enviadas', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', nl.id,
'channel', nl.channel,
'template_key', nl.template_key,
'recipient_address', nl.recipient_address,
'status', nl.status,
'sent_at', nl.sent_at,
'delivered_at', nl.delivered_at,
'read_at', nl.read_at,
'failure_reason', nl.failure_reason,
'created_at', nl.created_at
) ORDER BY nl.created_at
)
FROM public.notification_logs nl
WHERE nl.patient_id = p_patient_id
), '[]'::jsonb),
'audit_trail', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', al.id,
'action', al.action,
'entity_type', al.entity_type,
'changed_fields', al.changed_fields,
'user_id', al.user_id,
'created_at', al.created_at
) ORDER BY al.created_at
)
FROM public.audit_logs al
WHERE al.tenant_id = v_tenant_id
AND al.entity_type = 'patients'
AND al.entity_id = p_patient_id::text
), '[]'::jsonb),
'acessos_a_documentos', COALESCE((
SELECT jsonb_agg(
jsonb_build_object(
'id', dal.id,
'documento_id', dal.documento_id,
'acao', dal.acao,
'user_id', dal.user_id,
'acessado_em', dal.acessado_em
) ORDER BY dal.acessado_em
)
FROM public.document_access_logs dal
WHERE dal.documento_id IN (
SELECT id FROM public.documents WHERE patient_id = p_patient_id
)
), '[]'::jsonb),
'grupos', COALESCE((
SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
FROM public.patient_group_patient pgp
WHERE pgp.patient_id = p_patient_id
), '[]'::jsonb),
'tags', COALESCE((
SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
FROM public.patient_patient_tag ppt
WHERE ppt.patient_id = p_patient_id
), '[]'::jsonb)
);
-- registra o export como evento auditavel
INSERT INTO public.audit_logs (
tenant_id, user_id, entity_type, entity_id, action,
old_values, new_values, changed_fields, metadata
) VALUES (
v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
NULL, NULL, ARRAY['__lgpd_export__'],
jsonb_build_object(
'action_kind', 'lgpd_export',
'lgpd_basis', 'Art. 18, II',
'patient_name', v_patient.nome_completo
)
);
RETURN v_result;
END;
$$;
COMMENT ON FUNCTION public.export_patient_data(UUID) IS
'LGPD Art. 18, II - exporta todos os dados do paciente em jsonb portavel. Registra evento em audit_logs.';
REVOKE ALL ON FUNCTION public.export_patient_data(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.export_patient_data(UUID) TO authenticated;
@@ -0,0 +1,263 @@
-- =============================================================================
-- Migration: 20260420000005_conversation_messages
-- Sessao 11 - Fase 5a (CRM de WhatsApp / inbox).
--
-- Cria infraestrutura para receber mensagens inbound de WhatsApp (Twilio e
-- Evolution API) e exibir num Kanban de conversas.
--
-- - conversation_messages — todas as mensagens (in/out) com link opcional
-- ao paciente via telefone matching
-- - function match_patient_by_phone(tenant_id, phone) — encontra paciente
-- - view conversation_threads — agregado por paciente/numero pra UI Kanban
--
-- RLS: tenant members leem; service_role (edge function) escreve via SECURITY
-- DEFINER match_and_insert. App nao escreve direto.
-- =============================================================================
-- ---------------------------------------------------------------------------
-- Tabela de mensagens
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_messages (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
channel TEXT NOT NULL CHECK (channel IN ('whatsapp', 'sms', 'email')),
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
from_number TEXT,
to_number TEXT,
body TEXT,
media_url TEXT,
media_mime TEXT,
provider TEXT NOT NULL CHECK (provider IN ('twilio', 'evolution', 'manual')),
provider_message_id TEXT,
provider_raw JSONB,
-- estado Kanban
kanban_status TEXT NOT NULL DEFAULT 'awaiting_us'
CHECK (kanban_status IN ('urgent', 'awaiting_us', 'awaiting_patient', 'resolved')),
priority INT NOT NULL DEFAULT 0,
read_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_msg_tenant_created
ON public.conversation_messages (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_patient
ON public.conversation_messages (patient_id, created_at DESC) WHERE patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_msg_from_number
ON public.conversation_messages (tenant_id, from_number);
CREATE INDEX IF NOT EXISTS idx_conv_msg_kanban
ON public.conversation_messages (tenant_id, kanban_status, priority DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_conv_msg_provider_msg_id
ON public.conversation_messages (provider_message_id) WHERE provider_message_id IS NOT NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_messages_updated_at ON public.conversation_messages;
CREATE TRIGGER trg_conv_messages_updated_at
BEFORE UPDATE ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_messages IS
'Mensagens in/out de WhatsApp/SMS/email. Timeline de conversas do tenant com pacientes.';
-- ---------------------------------------------------------------------------
-- Funcao: normaliza telefone BR (remove tudo que nao seja digito, tira DDI 55)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.normalize_phone_br(p_phone TEXT)
RETURNS TEXT
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_digits TEXT;
BEGIN
IF p_phone IS NULL THEN RETURN NULL; END IF;
-- remove tudo que nao seja digito
v_digits := regexp_replace(p_phone, '\D', '', 'g');
-- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
v_digits := substr(v_digits, 3);
END IF;
-- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
IF length(v_digits) > 11 THEN
v_digits := right(v_digits, 11);
END IF;
RETURN v_digits;
END;
$$;
COMMENT ON FUNCTION public.normalize_phone_br(TEXT) IS
'Normaliza telefone BR para os ultimos 11 digitos (DDD+numero), removendo DDI +55 e formatacao.';
-- ---------------------------------------------------------------------------
-- Funcao: match paciente por telefone dentro de um tenant
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.match_patient_by_phone(p_tenant_id UUID, p_phone TEXT)
RETURNS UUID
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_normalized TEXT;
v_patient_id UUID;
BEGIN
v_normalized := public.normalize_phone_br(p_phone);
IF v_normalized IS NULL OR length(v_normalized) < 10 THEN
RETURN NULL;
END IF;
-- prioridade: telefone principal, depois alternativo, depois responsavel
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_alternativo) = v_normalized
LIMIT 1;
IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;
SELECT id INTO v_patient_id FROM public.patients
WHERE tenant_id = p_tenant_id
AND public.normalize_phone_br(telefone_responsavel) = v_normalized
LIMIT 1;
RETURN v_patient_id;
END;
$$;
COMMENT ON FUNCTION public.match_patient_by_phone(UUID, TEXT) IS
'Encontra patient_id do tenant cujo telefone (principal/alternativo/responsavel) bate com o numero.';
-- ---------------------------------------------------------------------------
-- View: threads agrupadas por paciente ou numero anonimo
-- ---------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown'
)) AS thread_key
FROM public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status
FROM latest l
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
LEFT JOIN public.patients p ON p.id = l.patient_id;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou por numero anonimo. Base do Kanban.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ---------------------------------------------------------------------------
-- RLS: tenant member le; ninguem escreve direto (so via edge function service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_msg: select tenant" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: update kanban" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct insert" ON public.conversation_messages;
DROP POLICY IF EXISTS "conv_msg: no direct delete" ON public.conversation_messages;
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages
FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- tenant member pode atualizar apenas kanban_status/read_at/responded_at/resolved_at
-- (nao pode mexer em body, provider, etc)
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages
FOR UPDATE TO authenticated
USING (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
WITH CHECK (
tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages
FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages
FOR DELETE TO authenticated
USING (false);
@@ -0,0 +1,275 @@
-- =============================================================================
-- Migration: 20260420000005_search_global_rpc
-- Busca global do topbar — RPC única que retorna resultados agrupados por
-- entidade (pacientes, agendamentos, documentos, serviços).
--
-- Segurança:
-- • SECURITY INVOKER → respeita RLS do chamador (terapeuta vê só os dele,
-- clínica vê do tenant, saas_admin vê global). Sem reinvenção de permissão.
-- • GRANT apenas para `authenticated` (paciente anônimo não tem busca global).
--
-- Índices trigram:
-- • patients(nome_completo, email_principal, cpf)
-- • services(name)
-- • agenda_eventos(titulo, titulo_custom)
-- • documents(nome_original) — já existe em 06_indexes/indexes.sql (skip)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Índices trigram (GIN) pra ILIKE/similarity performarem
-- pg_trgm instalado em schema `extensions`; ops class vive em `public`.
-- -----------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_patients_nome_trgm
ON public.patients USING gin (nome_completo public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_patients_email_trgm
ON public.patients USING gin (email_principal public.gin_trgm_ops)
WHERE email_principal IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patients_cpf_trgm
ON public.patients USING gin (cpf public.gin_trgm_ops)
WHERE cpf IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_services_name_trgm
ON public.services USING gin (name public.gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_trgm
ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops)
WHERE titulo IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_agenda_eventos_titulo_custom_trgm
ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops)
WHERE titulo_custom IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_patient_intake_requests_nome_trgm
ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops)
WHERE status = 'new';
-- -----------------------------------------------------------------------------
-- RPC principal
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.search_global(
p_q text,
p_scope text[] DEFAULT NULL,
p_limit int DEFAULT 8
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
STABLE
SET search_path = public, pg_temp
AS $$
DECLARE
v_q text;
v_pattern text;
v_limit int;
v_patients jsonb := '[]'::jsonb;
v_appointments jsonb := '[]'::jsonb;
v_documents jsonb := '[]'::jsonb;
v_services jsonb := '[]'::jsonb;
v_intakes jsonb := '[]'::jsonb;
BEGIN
-- Sanitize + length guards
v_q := nullif(btrim(coalesce(p_q, '')), '');
IF v_q IS NULL OR length(v_q) < 2 THEN
RETURN jsonb_build_object(
'patients', '[]'::jsonb,
'appointments', '[]'::jsonb,
'documents', '[]'::jsonb,
'services', '[]'::jsonb,
'intakes', '[]'::jsonb
);
END IF;
v_q := left(v_q, 80);
v_pattern := '%' || v_q || '%';
v_limit := GREATEST(1, LEAST(coalesce(p_limit, 8), 20));
-- ─────────────────────────────────────────────────────────────────────
-- Pacientes
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
p.id,
p.nome_completo,
p.email_principal,
p.telefone,
p.avatar_url,
GREATEST(
similarity(coalesce(p.nome_completo, ''), v_q),
similarity(coalesce(p.email_principal, ''), v_q) * 0.7,
similarity(coalesce(p.telefone, ''), v_q) * 0.5,
similarity(coalesce(p.cpf, ''), v_q) * 0.6
) AS score
FROM public.patients p
WHERE p.nome_completo ILIKE v_pattern
OR p.email_principal ILIKE v_pattern
OR p.telefone ILIKE v_pattern
OR p.cpf ILIKE v_pattern
ORDER BY score DESC, p.nome_completo ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_completo,
'sublabel', coalesce(nullif(email_principal, ''), nullif(telefone, ''), ''),
'avatar_url', avatar_url,
'deeplink', '/therapist/patients/cadastro/' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_patients
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Agendamentos (com nome do paciente via join)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
e.id,
coalesce(nullif(e.titulo_custom, ''), nullif(e.titulo, ''), 'Sessão') AS label,
e.inicio_em,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(e.titulo, ''), v_q),
similarity(coalesce(e.titulo_custom, ''), v_q),
similarity(coalesce(pat.nome_completo, ''), v_q) * 0.9
) AS score
FROM public.agenda_eventos e
LEFT JOIN public.patients pat ON pat.id = e.patient_id
WHERE e.titulo ILIKE v_pattern
OR e.titulo_custom ILIKE v_pattern
OR pat.nome_completo ILIKE v_pattern
ORDER BY score DESC, e.inicio_em DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', label,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| to_char(inicio_em, 'DD/MM/YYYY HH24:MI')),
'deeplink', '/therapist/agenda?event=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_appointments
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Documentos
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
d.id,
d.patient_id,
d.nome_original,
d.tipo_documento,
pat.nome_completo AS patient_name,
GREATEST(
similarity(coalesce(d.nome_original, ''), v_q),
similarity(coalesce(d.descricao, ''), v_q) * 0.7
) AS score
FROM public.documents d
LEFT JOIN public.patients pat ON pat.id = d.patient_id
WHERE d.nome_original ILIKE v_pattern
OR d.descricao ILIKE v_pattern
ORDER BY score DESC, d.nome_original ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', nome_original,
'sublabel', trim(both ' · ' from
coalesce(patient_name, '') || ' · '
|| coalesce(tipo_documento, '')),
'deeplink', '/therapist/patients/' || patient_id::text || '/documents',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_documents
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Serviços (ativos)
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
s.id,
s.name,
s.price,
s.duration_min,
GREATEST(
similarity(coalesce(s.name, ''), v_q),
similarity(coalesce(s.description, ''), v_q) * 0.7
) AS score
FROM public.services s
WHERE s.active IS TRUE
AND (s.name ILIKE v_pattern
OR s.description ILIKE v_pattern)
ORDER BY score DESC, s.name ASC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', name,
'sublabel', trim(both ' · ' from
'R$ ' || to_char(price, 'FM999G999G990D00') || ' · '
|| coalesce(duration_min::text || ' min', '')),
'deeplink', '/configuracoes/precificacao',
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_services
FROM ranked;
END IF;
-- ─────────────────────────────────────────────────────────────────────
-- Intakes pendentes (patient_intake_requests com status='new')
-- ─────────────────────────────────────────────────────────────────────
IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
WITH ranked AS (
SELECT
r.id,
r.nome_completo,
r.email_principal,
r.telefone,
r.created_at,
GREATEST(
similarity(coalesce(r.nome_completo, ''), v_q),
similarity(coalesce(r.email_principal, ''), v_q) * 0.7,
similarity(coalesce(r.telefone, ''), v_q) * 0.5
) AS score
FROM public.patient_intake_requests r
WHERE r.status = 'new'
AND (r.nome_completo ILIKE v_pattern
OR r.email_principal ILIKE v_pattern
OR r.telefone ILIKE v_pattern)
ORDER BY score DESC, r.created_at DESC
LIMIT v_limit
)
SELECT coalesce(jsonb_agg(jsonb_build_object(
'id', id,
'label', coalesce(nullif(trim(nome_completo), ''), '(sem nome)'),
'sublabel', trim(both ' · ' from
coalesce(nullif(email_principal, ''), nullif(telefone, ''), '') || ' · '
|| 'recebido ' || to_char(created_at, 'DD/MM/YYYY')),
'deeplink', '/therapist/patients/cadastro/recebidos?id=' || id::text,
'score', round(score::numeric, 3)
)), '[]'::jsonb) INTO v_intakes
FROM ranked;
END IF;
RETURN jsonb_build_object(
'patients', v_patients,
'appointments', v_appointments,
'documents', v_documents,
'services', v_services,
'intakes', v_intakes
);
END;
$$;
REVOKE EXECUTE ON FUNCTION public.search_global(text, text[], int) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.search_global(text, text[], int) TO authenticated;
COMMENT ON FUNCTION public.search_global(text, text[], int) IS
'Busca global do topbar — retorna jsonb agrupado por entidade. SECURITY INVOKER (RLS do chamador aplica).';
@@ -0,0 +1,117 @@
-- =============================================================================
-- Migration: 20260420000006_conv_messages_notifications
-- Sessao 11 - Fase 5a (extensao).
--
-- Integra conversation_messages ao sistema de notifications existente:
-- - Adiciona 'inbound_message' ao CHECK do notifications.type
-- - Trigger em conversation_messages: quando chega inbound, fan-out para
-- members do tenant apropriados (responsible_member do paciente, ou
-- todos tenant_admin/clinic_admin/therapist ativos se sem vinculo)
-- =============================================================================
-- Ajusta CHECK do type
ALTER TABLE public.notifications
DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE public.notifications
ADD CONSTRAINT notifications_type_check
CHECK (type = ANY (ARRAY[
'new_scheduling',
'new_patient',
'recurrence_alert',
'session_status',
'inbound_message'
]));
-- ---------------------------------------------------------------------------
-- Trigger function: fan-out mensagem inbound para notifications dos members
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fanout_inbound_message_to_notifications()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_target_user UUID;
v_title TEXT;
v_detail TEXT;
v_initials TEXT;
v_deeplink TEXT;
v_patient_name TEXT;
v_payload JSONB;
BEGIN
-- so processa inbound
IF NEW.direction <> 'inbound' THEN
RETURN NEW;
END IF;
-- busca nome do paciente (se vinculado)
IF NEW.patient_id IS NOT NULL THEN
SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
END IF;
-- titulo e detalhes
v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');
-- iniciais
IF v_patient_name IS NOT NULL THEN
v_initials := upper(left(v_patient_name, 1)) ||
COALESCE(upper(left(split_part(v_patient_name, ' ', 2), 1)), '');
ELSE
v_initials := '?';
END IF;
-- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
v_deeplink := '/admin/conversas';
v_payload := jsonb_build_object(
'title', v_title,
'detail', v_detail,
'avatar_initials', v_initials,
'deeplink', v_deeplink,
'channel', NEW.channel,
'conversation_message_id', NEW.id,
'patient_id', NEW.patient_id,
'from_number', NEW.from_number
);
-- ─── decide destinatarios ─────────────────────────────────────────────
-- Caso 1: paciente vinculado e tem responsible_member_id
IF NEW.patient_id IS NOT NULL THEN
SELECT tm.user_id INTO v_target_user
FROM public.patients p
JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
WHERE p.id = NEW.patient_id
AND tm.status = 'active'
LIMIT 1;
IF v_target_user IS NOT NULL THEN
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
RETURN NEW;
END IF;
END IF;
-- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
FROM public.tenant_members tm
WHERE tm.tenant_id = NEW.tenant_id
AND tm.status = 'active'
AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');
RETURN NEW;
END;
$$;
-- Trigger
DROP TRIGGER IF EXISTS trg_fanout_inbound_to_notifications ON public.conversation_messages;
CREATE TRIGGER trg_fanout_inbound_to_notifications
AFTER INSERT ON public.conversation_messages
FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
COMMENT ON FUNCTION public.fanout_inbound_message_to_notifications() IS
'Cria registros em notifications pra members apropriados quando chega mensagem inbound. Respeita responsible_member do paciente.';
@@ -0,0 +1,26 @@
-- =============================================================================
-- Migration: 20260420000007_notif_channels_saas_admin_insert
--
-- Fix: SaaS admin nao conseguia INSERT em notification_channels via /saas/whatsapp
-- porque a policy de insert exigia owner_id = auth.uid() e o saas_admin esta
-- inserindo em nome do tenant_admin (outro user). As policies de update/delete
-- ja tinham OR is_saas_admin() — o insert foi esquecido.
-- =============================================================================
DROP POLICY IF EXISTS "notif_channels_insert" ON public.notification_channels;
CREATE POLICY "notif_channels_insert" ON public.notification_channels
FOR INSERT TO authenticated
WITH CHECK (
public.is_saas_admin()
OR (
owner_id = auth.uid()
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
)
);
COMMENT ON POLICY "notif_channels_insert" ON public.notification_channels IS
'SaaS admin pode inserir em nome de qualquer tenant; tenant_member insere pra si mesmo.';
@@ -0,0 +1,12 @@
-- =============================================================================
-- Migration: 20260420000008_conv_messages_realtime
--
-- Adiciona conversation_messages na publicacao supabase_realtime para que
-- INSERT/UPDATE sejam entregues ao subscribe do frontend.
-- =============================================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_messages;
-- REPLICA IDENTITY FULL permite que o payload do Realtime traga a row completa
-- (necessario pra usar filters e receber old/new em UPDATEs)
ALTER TABLE public.conversation_messages REPLICA IDENTITY FULL;
@@ -0,0 +1,17 @@
-- =============================================================================
-- Migration: 20260420000009_conv_messages_delivery_status
--
-- Adiciona colunas para rastrear status de entrega/leitura das mensagens
-- outbound (envio pelo sistema). Evolution dispara evento messages.update
-- com status = SENT | DELIVERED | READ que vamos capturar.
-- =============================================================================
ALTER TABLE public.conversation_messages
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS read_by_recipient_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS delivery_status TEXT
CHECK (delivery_status IS NULL OR delivery_status IN ('pending','sent','delivered','read','failed'));
CREATE INDEX IF NOT EXISTS idx_conv_msg_delivery_status
ON public.conversation_messages (tenant_id, delivery_status)
WHERE direction = 'outbound';
@@ -0,0 +1,91 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Bucket para midia de WhatsApp
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Cria bucket privado `whatsapp-media` para armazenar audio/imagem/video/
-- documentos recebidos via Evolution API. URLs do WhatsApp sao encriptadas
-- com mediaKey da Meta — precisamos decriptar via Evolution getBase64 e
-- subir pro nosso storage para playback no browser.
--
-- Privacidade LGPD:
-- - Bucket privado (public=false)
-- - Upload apenas via service_role (edge function)
-- - Leitura via signed URLs gerados on-demand pelo frontend (expiracao curta)
-- - Paths tenant-scoped: <tenant_id>/<yyyy>/<mm>/<uuid>.<ext>
-- ==========================================================================
-- Bucket whatsapp-media
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'whatsapp-media',
'whatsapp-media',
false,
26214400, -- 25 MB (WhatsApp aceita ate 16MB audio/video, margem extra)
ARRAY[
-- Audio
'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/wav', 'audio/webm',
-- Imagem
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
-- Video
'video/mp4', 'video/3gpp', 'video/quicktime', 'video/webm',
-- Documento
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'application/zip',
'application/octet-stream'
]
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — whatsapp-media
-- --------------------------------------------------------------------------
-- Politica: APENAS service_role faz upload (edge function).
-- Usuarios autenticados leem se forem membros ativos do tenant no path[0].
-- --------------------------------------------------------------------------
-- Read: membro ativo do tenant cujo id e o primeiro segmento do path
CREATE POLICY "whatsapp-media: read tenant members"
ON storage.objects
FOR SELECT
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND (
-- SaaS admins leem qualquer tenant
public.is_saas_admin()
OR
-- Membros ativos do tenant (tenant_id e o primeiro segmento do path)
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.status = 'active'
AND (storage.foldername(name))[1] = tm.tenant_id::text
)
)
);
-- Insert: bloqueado para authenticated (apenas service_role sobe)
-- Sem policy de INSERT para authenticated = bloqueado por default no RLS.
-- Delete: SaaS admin pode deletar (retention policy futura)
CREATE POLICY "whatsapp-media: delete saas admin"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'whatsapp-media'
AND public.is_saas_admin()
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,116 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Notas internas de conversa (CRM Grupo 3.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Notas internas da equipe em cada thread de conversa (WhatsApp/SMS/etc).
-- NAO sao enviadas ao paciente — apenas visiveis aos membros do tenant.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - READ/CREATE: qualquer membro ativo do tenant
-- - UPDATE/DELETE: apenas o criador da nota OU saas_admin
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
body TEXT NOT NULL CHECK (length(body) > 0 AND length(body) <= 4000),
created_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_conv_notes_tenant_thread
ON public.conversation_notes (tenant_id, thread_key, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_patient
ON public.conversation_notes (patient_id, created_at DESC)
WHERE deleted_at IS NULL AND patient_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_notes_created_by
ON public.conversation_notes (created_by, created_at DESC)
WHERE deleted_at IS NULL;
-- Trigger de updated_at (usa funcao existente set_updated_at)
DROP TRIGGER IF EXISTS trg_conv_notes_updated_at ON public.conversation_notes;
CREATE TRIGGER trg_conv_notes_updated_at
BEFORE UPDATE ON public.conversation_notes
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_notes IS
'Notas internas por thread de conversa. Visiveis apenas aos membros do tenant; nao enviadas ao paciente.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
-- SELECT: membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_notes: select tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: select tenant members"
ON public.conversation_notes
FOR SELECT
TO authenticated
USING (
deleted_at IS NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- INSERT: membro ativo do tenant, created_by deve ser o proprio usuario
DROP POLICY IF EXISTS "conv_notes: insert tenant members" ON public.conversation_notes;
CREATE POLICY "conv_notes: insert tenant members"
ON public.conversation_notes
FOR INSERT
TO authenticated
WITH CHECK (
created_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_notes.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: apenas criador OU saas_admin
DROP POLICY IF EXISTS "conv_notes: update creator or saas" ON public.conversation_notes;
CREATE POLICY "conv_notes: update creator or saas"
ON public.conversation_notes
FOR UPDATE
TO authenticated
USING (
deleted_at IS NULL
AND (created_by = auth.uid() OR public.is_saas_admin())
)
WITH CHECK (
created_by = (SELECT created_by FROM public.conversation_notes WHERE id = conversation_notes.id)
);
-- DELETE: soft delete via UPDATE deleted_at (nao permite hard delete)
-- Mantemos politica de DELETE bloqueada por default (sem policy = nao permitido)
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Tags de conversa (CRM Grupo 3.1)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Tags aplicaveis a uma thread de conversa (urgente, primeira consulta,
-- remarcacao, etc). Cada tenant pode criar tags custom alem das padrao.
--
-- Tabelas:
-- - conversation_tags — definicoes (system com tenant_id NULL + custom)
-- - conversation_thread_tags — join (tenant_id, thread_key, tag_id)
--
-- thread_key: mesma logica de conversation_threads
-- - '<uuid>' → thread de paciente
-- - 'anon:<phone>' → thread anonima
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: conversation_tags (definicoes)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system tag
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
color TEXT NOT NULL DEFAULT '#6366f1' CHECK (color ~ '^#[0-9a-fA-F]{6}$'),
icon TEXT, -- classe de icone primeicons (ex: 'pi pi-exclamation-triangle')
position INT NOT NULL DEFAULT 100,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: (tenant_id, slug). Para tenant_id NULL (system), um indice parcial separado.
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_tenant_slug
ON public.conversation_tags (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_tags_system_slug
ON public.conversation_tags (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_tags_tenant
ON public.conversation_tags (tenant_id, position);
DROP TRIGGER IF EXISTS trg_conv_tags_updated_at ON public.conversation_tags;
CREATE TRIGGER trg_conv_tags_updated_at
BEFORE UPDATE ON public.conversation_tags
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_tags IS
'Definicoes de tags aplicaveis a threads. tenant_id NULL = tag do sistema (todos veem).';
-- ---------------------------------------------------------------------------
-- Tabela: conversation_thread_tags (join many-to-many)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_thread_tags (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
tag_id UUID NOT NULL REFERENCES public.conversation_tags(id) ON DELETE CASCADE,
tagged_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
tagged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tenant_thread
ON public.conversation_thread_tags (tenant_id, thread_key);
CREATE INDEX IF NOT EXISTS idx_conv_thread_tags_tag
ON public.conversation_thread_tags (tag_id);
COMMENT ON TABLE public.conversation_thread_tags IS
'Join de tags aplicadas a cada thread de conversa.';
-- ---------------------------------------------------------------------------
-- Seed de tags padrao (system)
-- ---------------------------------------------------------------------------
INSERT INTO public.conversation_tags (tenant_id, name, slug, color, icon, position, is_system)
VALUES
(NULL, 'Urgente', 'urgente', '#ef4444', 'pi pi-exclamation-triangle', 10, true),
(NULL, 'Primeira consulta','primeira-consulta','#0ea5e9', 'pi pi-user-plus', 20, true),
(NULL, 'Remarcação', 'remarcacao', '#f59e0b', 'pi pi-calendar-times', 30, true),
(NULL, 'Confirmada', 'confirmada', '#22c55e', 'pi pi-check-circle', 40, true),
(NULL, 'Follow-up', 'follow-up', '#a855f7', 'pi pi-reply', 50, true)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- RLS: conversation_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
-- SELECT: system tags (tenant_id NULL) = todos; custom = membros ativos do tenant
DROP POLICY IF EXISTS "conv_tags: select" ON public.conversation_tags;
CREATE POLICY "conv_tags: select"
ON public.conversation_tags
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membros ativos do tenant criam custom. Nao podem criar system (tenant_id NULL)
DROP POLICY IF EXISTS "conv_tags: insert custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: insert custom"
ON public.conversation_tags
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- UPDATE: tenant members para tags proprias (custom). Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: update custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: update custom"
ON public.conversation_tags
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
)
WITH CHECK (is_system = false);
-- DELETE: tenant members removem tags custom. Sistema bloqueado.
DROP POLICY IF EXISTS "conv_tags: delete custom" ON public.conversation_tags;
CREATE POLICY "conv_tags: delete custom"
ON public.conversation_tags
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_tags.tenant_id
AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: conversation_thread_tags
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "conv_thread_tags: select" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: select"
ON public.conversation_thread_tags
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "conv_thread_tags: insert" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: insert"
ON public.conversation_thread_tags
FOR INSERT
TO authenticated
WITH CHECK (
tagged_by = auth.uid()
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "conv_thread_tags: delete" ON public.conversation_thread_tags;
CREATE POLICY "conv_thread_tags: delete"
ON public.conversation_thread_tags
FOR DELETE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_thread_tags.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,143 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Auto-reply fora do horario (CRM Grupo 2.3)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente manda mensagem fora do horario de atendimento, dispara
-- resposta automatica configuravel. Anti-spam via cooldown por thread.
--
-- Modos de horario:
-- - 'agenda' → usa agenda_regras_semanais dos membros do tenant
-- - 'business_hours' → janela semanal do tenant (clinica inteira)
-- - 'custom' → janela semanal especifica deste auto-reply
--
-- business_hours e custom_window usam mesma estrutura JSONB:
-- [{ "dow": 0-6, "start": "HH:MM", "end": "HH:MM" }, ...]
-- Multiplas entradas por dia permitidas (ex: 08:00-12:00 + 13:00-18:00)
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
message TEXT NOT NULL DEFAULT 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!'
CHECK (length(message) > 0 AND length(message) <= 2000),
cooldown_minutes INT NOT NULL DEFAULT 180
CHECK (cooldown_minutes >= 0 AND cooldown_minutes <= 43200), -- 0 min a 30 dias
schedule_mode TEXT NOT NULL DEFAULT 'agenda'
CHECK (schedule_mode IN ('agenda', 'business_hours', 'custom')),
-- Janela de funcionamento da clinica (reutilizavel por outras features)
-- Ex: [{ "dow": 1, "start": "08:00", "end": "18:00" }, ...]
business_hours JSONB NOT NULL DEFAULT '[]'::jsonb,
-- Janela especifica deste auto-reply (quando schedule_mode = 'custom')
custom_window JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_conv_autoreply_settings_updated_at ON public.conversation_autoreply_settings;
CREATE TRIGGER trg_conv_autoreply_settings_updated_at
BEFORE UPDATE ON public.conversation_autoreply_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_autoreply_settings IS
'Configuracao por tenant do auto-reply fora do horario.';
-- ---------------------------------------------------------------------------
-- Log (anti-spam: uma resposta auto por thread por cooldown)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_autoreply_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
message_id UUID -- referencia opcional pra message na tabela conversation_messages
);
CREATE INDEX IF NOT EXISTS idx_autoreply_log_cooldown
ON public.conversation_autoreply_log (tenant_id, thread_key, sent_at DESC);
COMMENT ON TABLE public.conversation_autoreply_log IS
'Log de auto-replies enviados. Usado pra respeitar cooldown por thread.';
-- ---------------------------------------------------------------------------
-- RLS: settings
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_settings: select" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: select"
ON public.conversation_autoreply_settings
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: insert" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: insert"
ON public.conversation_autoreply_settings
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "autoreply_settings: update" ON public.conversation_autoreply_settings;
CREATE POLICY "autoreply_settings: update"
ON public.conversation_autoreply_settings
FOR UPDATE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_settings.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: log (read-only pra tenant members; escrita via service_role)
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "autoreply_log: select" ON public.conversation_autoreply_log;
CREATE POLICY "autoreply_log: select"
ON public.conversation_autoreply_log
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_autoreply_log.tenant_id
AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,226 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Opt-out de conversas (CRM Grupo 5.2, LGPD)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Quando paciente envia keyword de opt-out (PARAR, SAIR, CANCELAR, STOP...),
-- bloqueia envios automaticos (auto-reply + futuros lembretes).
--
-- LGPD: direito de oposicao (Art. 18, §2). Pedido de interrupcao deve ser
-- respeitado. Mensagens manuais do terapeuta nao sao bloqueadas — relacao
-- terapeutica existe.
--
-- Phone e normalizado (apenas digitos) pra matching consistente.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_optouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
phone TEXT NOT NULL CHECK (phone ~ '^\d{6,15}$'),
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
-- 'keyword' = detectado automaticamente por palavra-chave
-- 'manual' = adicionado manualmente pelo terapeuta/admin
source TEXT NOT NULL DEFAULT 'keyword'
CHECK (source IN ('keyword', 'manual')),
keyword_matched TEXT, -- palavra/frase que disparou (quando source='keyword')
original_message TEXT, -- texto completo da msg original (truncado)
notes TEXT, -- observacao do terapeuta (quando manual)
blocked_by UUID REFERENCES auth.users(id) ON DELETE SET NULL, -- quem adicionou manual
opted_out_at TIMESTAMPTZ NOT NULL DEFAULT now(),
opted_back_in_at TIMESTAMPTZ, -- quando usuario restaurou (opt-in)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Unique: um registro ativo por tenant+phone. Permite historico se fizer opt-in e depois opt-out de novo.
-- Active = opted_back_in_at IS NULL
CREATE UNIQUE INDEX IF NOT EXISTS uq_conv_optouts_active
ON public.conversation_optouts (tenant_id, phone)
WHERE opted_back_in_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_conv_optouts_tenant_phone
ON public.conversation_optouts (tenant_id, phone);
CREATE INDEX IF NOT EXISTS idx_conv_optouts_patient
ON public.conversation_optouts (patient_id)
WHERE patient_id IS NOT NULL;
DROP TRIGGER IF EXISTS trg_conv_optouts_updated_at ON public.conversation_optouts;
CREATE TRIGGER trg_conv_optouts_updated_at
BEFORE UPDATE ON public.conversation_optouts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_optouts IS
'Numeros que pediram pra nao receber mensagens automaticas. LGPD Art. 18 Sec.2.';
-- ---------------------------------------------------------------------------
-- Keywords de opt-out — lista do tenant (reutilizavel)
-- Cada tenant pode customizar suas palavras-chave. Default aplicado via seed.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.conversation_optout_keywords (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system (todos)
keyword TEXT NOT NULL CHECK (length(keyword) > 0 AND length(keyword) <= 100),
enabled BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_conv_optout_kw_tenant
ON public.conversation_optout_keywords (tenant_id)
WHERE enabled = true;
-- Seed keywords padrao (system, tenant_id NULL, todos veem)
INSERT INTO public.conversation_optout_keywords (tenant_id, keyword, is_system, enabled) VALUES
(NULL, 'parar', true, true),
(NULL, 'sair', true, true),
(NULL, 'cancelar', true, true),
(NULL, 'stop', true, true),
(NULL, 'descadastrar', true, true),
(NULL, 'remover', true, true),
(NULL, 'nao quero mais', true, true),
(NULL, 'não quero mais', true, true),
(NULL, 'desinscrever', true, true),
(NULL, 'unsubscribe', true, true)
ON CONFLICT DO NOTHING;
COMMENT ON TABLE public.conversation_optout_keywords IS
'Palavras-chave que disparam opt-out quando paciente envia. Sistema (tenant_id NULL) + custom do tenant.';
-- ---------------------------------------------------------------------------
-- RLS: optouts
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optouts: select" ON public.conversation_optouts;
CREATE POLICY "optouts: select"
ON public.conversation_optouts
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: insert" ON public.conversation_optouts;
CREATE POLICY "optouts: insert"
ON public.conversation_optouts
FOR INSERT
TO authenticated
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optouts: update" ON public.conversation_optouts;
CREATE POLICY "optouts: update"
ON public.conversation_optouts
FOR UPDATE
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optouts.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- RLS: keywords
-- ---------------------------------------------------------------------------
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "optout_kw: select" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: select"
ON public.conversation_optout_keywords
FOR SELECT
TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "optout_kw: insert custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: insert custom"
ON public.conversation_optout_keywords
FOR INSERT
TO authenticated
WITH CHECK (
tenant_id IS NOT NULL
AND is_system = false
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: update/delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: update/delete custom"
ON public.conversation_optout_keywords
FOR UPDATE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "optout_kw: delete custom" ON public.conversation_optout_keywords;
CREATE POLICY "optout_kw: delete custom"
ON public.conversation_optout_keywords
FOR DELETE
TO authenticated
USING (
is_system = false
AND tenant_id IS NOT NULL
AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_optout_keywords.tenant_id
AND tm.status = 'active'
)
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,152 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Lembretes automáticos de sessão (CRM Grupo 2.4)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Envia WhatsApp automatico antes das sessoes agendadas (24h e 2h antes).
-- Respeita opt-out (LGPD), quiet hours, canal ativo do tenant.
--
-- Arquitetura:
-- - pg_cron agenda edge function `send-session-reminders` a cada 15 min
-- - edge function busca eventos na janela de lembretes, envia + registra log
-- - UNIQUE (event_id, reminder_type) no log impede envio duplicado
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Settings per-tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_settings (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
-- Lead times (quais lembretes enviar)
send_24h BOOLEAN NOT NULL DEFAULT true,
send_2h BOOLEAN NOT NULL DEFAULT true,
-- Templates com variaveis: {{nome_paciente}}, {{data_sessao}}, {{hora_sessao}}, {{nome_clinica}}, {{modalidade}}
template_24h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!'
CHECK (length(template_24h) > 0 AND length(template_24h) <= 2000),
template_2h TEXT NOT NULL DEFAULT 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊'
CHECK (length(template_2h) > 0 AND length(template_2h) <= 2000),
-- Quiet hours (não envia lembretes nessa janela, mesmo se a sessão estiver na janela)
-- Format: 'HH:MM'. Se start > end, janela atravessa a meia-noite (ex: 22:00 → 08:00).
quiet_hours_enabled BOOLEAN NOT NULL DEFAULT true,
quiet_hours_start TIME NOT NULL DEFAULT '22:00',
quiet_hours_end TIME NOT NULL DEFAULT '08:00',
-- Respeita opt-out (LGPD)? default true, mas expomos pra caso haja tenant com regra especifica.
respect_opt_out BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_session_reminder_settings_updated_at ON public.session_reminder_settings;
CREATE TRIGGER trg_session_reminder_settings_updated_at
BEFORE UPDATE ON public.session_reminder_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.session_reminder_settings IS
'Configuracao por tenant dos lembretes automaticos de sessao via WhatsApp.';
-- ---------------------------------------------------------------------------
-- Log (anti-duplicata + auditoria)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.session_reminder_logs (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES public.agenda_eventos(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
reminder_type TEXT NOT NULL CHECK (reminder_type IN ('24h', '2h')),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
provider TEXT, -- 'evolution', 'twilio', 'skipped'
skip_reason TEXT, -- quando provider='skipped': opted_out, quiet_hours, no_phone, etc
to_phone TEXT,
provider_message_id TEXT,
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_session_reminder_event_type
ON public.session_reminder_logs (event_id, reminder_type);
CREATE INDEX IF NOT EXISTS idx_session_reminder_tenant_sent
ON public.session_reminder_logs (tenant_id, sent_at DESC);
COMMENT ON TABLE public.session_reminder_logs IS
'Log de lembretes disparados. UNIQUE (event_id, reminder_type) previne duplicata.';
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.session_reminder_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_logs ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "reminder_settings: tenant members all" ON public.session_reminder_settings;
CREATE POLICY "reminder_settings: tenant members all"
ON public.session_reminder_settings
FOR ALL
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_settings.tenant_id
AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "reminder_logs: tenant members select" ON public.session_reminder_logs;
CREATE POLICY "reminder_logs: tenant members select"
ON public.session_reminder_logs
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = session_reminder_logs.tenant_id
AND tm.status = 'active'
)
);
-- ---------------------------------------------------------------------------
-- pg_cron: agenda send-session-reminders a cada 15 minutos
-- ---------------------------------------------------------------------------
-- Uses pg_net.http_post to hit the edge function. O secret do service_role
-- deve estar configurado em app.settings.service_role_key (ou use Vault).
--
-- Como alternativa mais simples: o user pode configurar um cron externo
-- (ex: Supabase Dashboard → Database → Cron) apontando pra edge function.
-- Deixo o schedule abaixo comentado; descomentar em produção quando
-- app.settings.service_role_key e app.settings.supabase_url estiverem setados.
-- ---------------------------------------------------------------------------
-- SELECT cron.schedule(
-- 'session-reminders-every-15min',
-- '*/15 * * * *',
-- $$
-- SELECT net.http_post(
-- url := current_setting('app.settings.supabase_url') || '/functions/v1/send-session-reminders',
-- headers := jsonb_build_object(
-- 'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key'),
-- 'Content-Type', 'application/json'
-- ),
-- body := '{}'::jsonb
-- );
-- $$
-- );
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,342 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Sistema de créditos WhatsApp (Marco B)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Modelo:
-- - whatsapp_credits_balance → saldo atual por tenant (snapshot)
-- - whatsapp_credits_transactions → extrato (purchase, usage, topup, adj)
-- - whatsapp_credit_packages → pacotes oferecidos (SaaS-managed)
-- - whatsapp_credit_purchases → ordens de compra via Asaas
--
-- Helpers (RPC):
-- - add_whatsapp_credits(tenant, amount, kind, ...) → novo saldo
-- - deduct_whatsapp_credits(tenant, amount, message_id) → boolean
--
-- Creditos so sao deduzidos quando o tenant usa provider='twilio'
-- (Evolution e free).
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Saldo por tenant
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_balance (
tenant_id UUID PRIMARY KEY REFERENCES public.tenants(id) ON DELETE CASCADE,
balance INT NOT NULL DEFAULT 0 CHECK (balance >= 0),
lifetime_purchased INT NOT NULL DEFAULT 0,
lifetime_used INT NOT NULL DEFAULT 0,
low_balance_threshold INT NOT NULL DEFAULT 20 CHECK (low_balance_threshold >= 0),
low_balance_alerted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credits_balance_updated_at ON public.whatsapp_credits_balance;
CREATE TRIGGER trg_wa_credits_balance_updated_at
BEFORE UPDATE ON public.whatsapp_credits_balance
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.whatsapp_credits_balance IS
'Saldo atual de creditos WhatsApp por tenant. 1 credito = 1 mensagem Twilio.';
-- ---------------------------------------------------------------------------
-- Extrato (transações)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credits_transactions (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('purchase', 'usage', 'topup_manual', 'refund', 'adjustment')),
amount INT NOT NULL, -- positivo = credito, negativo = debito
balance_after INT NOT NULL,
-- Referencias opcionais
conversation_message_id BIGINT REFERENCES public.conversation_messages(id) ON DELETE SET NULL,
purchase_id UUID,
admin_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_tenant_created
ON public.whatsapp_credits_transactions (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credits_tx_kind
ON public.whatsapp_credits_transactions (tenant_id, kind, created_at DESC);
COMMENT ON TABLE public.whatsapp_credits_transactions IS
'Extrato de creditos WhatsApp. Append-only — nao editar/deletar.';
-- ---------------------------------------------------------------------------
-- Pacotes (global, gerenciado pelo SaaS admin)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 100),
description TEXT,
credits INT NOT NULL CHECK (credits > 0),
price_brl NUMERIC(10,2) NOT NULL CHECK (price_brl > 0),
is_active BOOLEAN NOT NULL DEFAULT true,
is_featured BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_packages_updated_at ON public.whatsapp_credit_packages;
CREATE TRIGGER trg_wa_credit_packages_updated_at
BEFORE UPDATE ON public.whatsapp_credit_packages
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_packages_active
ON public.whatsapp_credit_packages (is_active, position, price_brl)
WHERE is_active = true;
COMMENT ON TABLE public.whatsapp_credit_packages IS
'Pacotes de creditos disponiveis pra compra. Gerenciado pelo SaaS admin.';
-- Seed: pacotes padrao
INSERT INTO public.whatsapp_credit_packages (name, description, credits, price_brl, is_featured, position) VALUES
('Iniciante', 'Ideal pra conhecer a plataforma', 100, 49.90, false, 10),
('Profissional', 'Mais vendido pra clínicas pequenas', 500, 199.90, true, 20),
('Clínica', 'Pra clínicas com alto volume', 1500, 499.90, false, 30),
('Enterprise', 'Pacote grande com desconto', 5000, 1499.90, false, 40)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Ordens de compra (Asaas integration)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.whatsapp_credit_purchases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
package_id UUID REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL,
-- Snapshot do pacote no momento da compra (caso mude de preço/creditos depois)
package_name TEXT NOT NULL,
credits INT NOT NULL CHECK (credits > 0),
amount_brl NUMERIC(10,2) NOT NULL CHECK (amount_brl > 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'refunded', 'cancelled')),
-- Asaas integration
asaas_customer_id TEXT,
asaas_payment_id TEXT,
asaas_payment_link TEXT,
asaas_pix_qrcode TEXT, -- base64 da imagem
asaas_pix_copy_paste TEXT, -- codigo PIX copia-cola
paid_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS trg_wa_credit_purchases_updated_at ON public.whatsapp_credit_purchases;
CREATE TRIGGER trg_wa_credit_purchases_updated_at
BEFORE UPDATE ON public.whatsapp_credit_purchases
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_tenant
ON public.whatsapp_credit_purchases (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_status
ON public.whatsapp_credit_purchases (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_credit_purchases_asaas_payment
ON public.whatsapp_credit_purchases (asaas_payment_id)
WHERE asaas_payment_id IS NOT NULL;
-- FK pra transactions.purchase_id (circular, então define depois)
ALTER TABLE public.whatsapp_credits_transactions
DROP CONSTRAINT IF EXISTS whatsapp_credits_transactions_purchase_id_fkey;
ALTER TABLE public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey
FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL;
COMMENT ON TABLE public.whatsapp_credit_purchases IS
'Ordens de compra de creditos via Asaas. Webhook atualiza status.';
-- ---------------------------------------------------------------------------
-- RPC: add_whatsapp_credits (SECURITY DEFINER — atualiza saldo + registra tx)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.add_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_kind TEXT,
p_purchase_id UUID DEFAULT NULL,
p_admin_id UUID DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN
RAISE EXCEPTION 'invalid kind for credit: %', p_kind;
END IF;
INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased)
VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END)
ON CONFLICT (tenant_id) DO UPDATE SET
balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
lifetime_purchased = whatsapp_credits_balance.lifetime_purchased
+ CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END,
low_balance_alerted_at = NULL -- reset alerta quando recebe creditos
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, purchase_id, admin_id, note)
VALUES
(p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.add_whatsapp_credits(UUID, INT, TEXT, UUID, UUID, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RPC: deduct_whatsapp_credits (atomico, falha se saldo insuficiente)
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.deduct_whatsapp_credits(
p_tenant_id UUID,
p_amount INT,
p_conversation_message_id BIGINT DEFAULT NULL,
p_note TEXT DEFAULT NULL
)
RETURNS INT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_new_balance INT;
v_row RECORD;
BEGIN
IF p_amount <= 0 THEN
RAISE EXCEPTION 'amount must be positive';
END IF;
-- Lock a linha e valida saldo
SELECT balance, low_balance_threshold
INTO v_row
FROM public.whatsapp_credits_balance
WHERE tenant_id = p_tenant_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
IF v_row.balance < p_amount THEN
RAISE EXCEPTION 'insufficient_credits';
END IF;
UPDATE public.whatsapp_credits_balance
SET balance = balance - p_amount,
lifetime_used = lifetime_used + p_amount
WHERE tenant_id = p_tenant_id
RETURNING balance INTO v_new_balance;
INSERT INTO public.whatsapp_credits_transactions
(tenant_id, kind, amount, balance_after, conversation_message_id, note)
VALUES
(p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note);
RETURN v_new_balance;
END;
$$;
REVOKE ALL ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.deduct_whatsapp_credits(UUID, INT, BIGINT, TEXT) TO service_role;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
-- Balance: members do tenant leem, saas_admin tudo
DROP POLICY IF EXISTS "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: select tenant"
ON public.whatsapp_credits_balance FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Settings update: members do tenant podem alterar low_balance_threshold
DROP POLICY IF EXISTS "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance;
CREATE POLICY "wa_credits_balance: update tenant"
ON public.whatsapp_credits_balance FOR UPDATE TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_balance.tenant_id AND tm.status = 'active'
)
);
-- Transactions: read-only pra tenant members, write via RPC
DROP POLICY IF EXISTS "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions;
CREATE POLICY "wa_credits_tx: select tenant"
ON public.whatsapp_credits_transactions FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credits_transactions.tenant_id AND tm.status = 'active'
)
);
-- Packages: todos leem os ativos; saas_admin gerencia
DROP POLICY IF EXISTS "wa_packages: select active" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: select active"
ON public.whatsapp_credit_packages FOR SELECT TO authenticated
USING (is_active = true OR public.is_saas_admin());
DROP POLICY IF EXISTS "wa_packages: manage saas admin" ON public.whatsapp_credit_packages;
CREATE POLICY "wa_packages: manage saas admin"
ON public.whatsapp_credit_packages FOR ALL TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- Purchases: members do tenant leem as proprias
DROP POLICY IF EXISTS "wa_purchases: select tenant" ON public.whatsapp_credit_purchases;
CREATE POLICY "wa_purchases: select tenant"
ON public.whatsapp_credit_purchases FOR SELECT TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = whatsapp_credit_purchases.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,356 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Telefones polimorficos com tipo + principal
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Substitui campos fixos de telefone (patients.telefone, medicos.telefone_*)
-- por estrutura flexivel:
--
-- - contact_types → tipos configuraveis (Celular, Fixo, WhatsApp, ...)
-- System (tenant_id NULL) + custom por tenant
-- - contact_phones → telefones polimorficos (entity_type + entity_id)
-- Suporta patient, medico, futuramente emergency, etc
--
-- Ate 1 telefone marcado como is_primary por entidade (UNIQUE parcial).
-- Triggers mantem patients.telefone, telefone_alternativo, medicos.telefone_*
-- sincronizados pra nao quebrar codigo legado.
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: contact_types
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE, -- NULL = system
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
icon TEXT, -- classe primeicons (ex: 'pi pi-mobile')
is_mobile BOOLEAN NOT NULL DEFAULT true, -- true = mascara celular; false = mascara fixo
is_system BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_types_tenant_slug
ON public.contact_types (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_types_system_slug
ON public.contact_types (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_contact_types_tenant
ON public.contact_types (tenant_id, position);
DROP TRIGGER IF EXISTS trg_contact_types_updated_at ON public.contact_types;
CREATE TRIGGER trg_contact_types_updated_at
BEFORE UPDATE ON public.contact_types
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_types IS
'Tipos de contato (Celular, Fixo, WhatsApp, ...). System (tenant_id NULL) visiveis a todos; custom por tenant.';
-- Seed: tipos system padrao
INSERT INTO public.contact_types (tenant_id, name, slug, icon, is_mobile, is_system, position) VALUES
(NULL, 'Celular', 'celular', 'pi pi-mobile', true, true, 10),
(NULL, 'WhatsApp', 'whatsapp', 'pi pi-whatsapp', true, true, 20),
(NULL, 'Fixo', 'fixo', 'pi pi-phone', false, true, 30),
(NULL, 'Residencial', 'residencial', 'pi pi-home', false, true, 40),
(NULL, 'Comercial', 'comercial', 'pi pi-building', true, true, 50),
(NULL, 'Fax', 'fax', 'pi pi-print', false, true, 60)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Tabela: contact_phones (polimorfica)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_phones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('patient', 'medico')),
entity_id UUID NOT NULL,
contact_type_id UUID NOT NULL REFERENCES public.contact_types(id) ON DELETE RESTRICT,
number TEXT NOT NULL CHECK (number ~ '^\d{8,15}$'), -- digits only, 8-15 (DDI+DDD+num)
is_primary BOOLEAN NOT NULL DEFAULT false,
-- Vinculado automaticamente via drawer de conversa (CRM 3.5)
whatsapp_linked_at TIMESTAMPTZ,
notes TEXT,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contact_phones_entity
ON public.contact_phones (tenant_id, entity_type, entity_id, position);
CREATE INDEX IF NOT EXISTS idx_contact_phones_number
ON public.contact_phones (tenant_id, number);
-- Partial unique: apenas 1 primary por entidade
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_phones_primary
ON public.contact_phones (entity_type, entity_id)
WHERE is_primary = true;
DROP TRIGGER IF EXISTS trg_contact_phones_updated_at ON public.contact_phones;
CREATE TRIGGER trg_contact_phones_updated_at
BEFORE UPDATE ON public.contact_phones
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_phones IS
'Telefones polimorficos (patients, medicos, ...). Max 1 primary por entidade. Triggers sincronizam campos legados.';
-- ---------------------------------------------------------------------------
-- Helper: pega o telefone primary (ou primeiro) de uma entidade
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_entity_primary_phone(
p_entity_type TEXT,
p_entity_id UUID
) RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT number FROM public.contact_phones
WHERE entity_type = p_entity_type
AND entity_id = p_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
$$;
REVOKE ALL ON FUNCTION public.get_entity_primary_phone(TEXT, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.get_entity_primary_phone(TEXT, UUID) TO authenticated, service_role;
-- ---------------------------------------------------------------------------
-- Trigger: sincroniza campos legados de patients/medicos apos mudanca
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.sync_legacy_phone_fields() RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
v_whatsapp_slug TEXT;
v_whatsapp TEXT;
BEGIN
-- Identifica entidade afetada (pode ser OLD em delete)
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
-- Pega primary (ou primeiro)
SELECT number INTO v_primary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
-- Pega segundo (depois do primary)
SELECT number INTO v_secondary
FROM public.contact_phones
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
-- Sincroniza campos legados
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET telefone = v_primary,
telefone_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
-- Medicos: telefone_profissional = primary; telefone_pessoal = secundario
UPDATE public.medicos
SET telefone_profissional = v_primary,
telefone_pessoal = v_secondary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
ELSE
RETURN NEW;
END IF;
END;
$$;
DROP TRIGGER IF EXISTS trg_contact_phones_sync_legacy ON public.contact_phones;
CREATE TRIGGER trg_contact_phones_sync_legacy
AFTER INSERT OR UPDATE OR DELETE ON public.contact_phones
FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields();
-- ---------------------------------------------------------------------------
-- Backfill: migra dados existentes pra contact_phones
-- ---------------------------------------------------------------------------
-- Patients: telefone → Celular primary, telefone_alternativo → Fixo
DO $$
DECLARE
v_celular_id UUID;
v_fixo_id UUID;
v_profissional_id UUID;
BEGIN
SELECT id INTO v_celular_id FROM public.contact_types WHERE slug = 'celular' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_fixo_id FROM public.contact_types WHERE slug = 'fixo' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_profissional_id FROM public.contact_types WHERE slug = 'comercial' AND tenant_id IS NULL LIMIT 1;
-- Patients.telefone → Celular primary
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
p.tenant_id,
'patient',
p.id,
v_celular_id,
regexp_replace(p.telefone, '\D', '', 'g'),
true,
10
FROM public.patients p
WHERE p.telefone IS NOT NULL
AND length(regexp_replace(p.telefone, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient' AND cp.entity_id = p.id
)
ON CONFLICT DO NOTHING;
-- Patients.telefone_alternativo → Fixo
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
p.tenant_id,
'patient',
p.id,
v_fixo_id,
regexp_replace(p.telefone_alternativo, '\D', '', 'g'),
false,
20
FROM public.patients p
WHERE p.telefone_alternativo IS NOT NULL
AND length(regexp_replace(p.telefone_alternativo, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient' AND cp.entity_id = p.id
AND cp.number = regexp_replace(p.telefone_alternativo, '\D', '', 'g')
)
ON CONFLICT DO NOTHING;
-- Medicos.telefone_profissional → Comercial primary
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
m.tenant_id,
'medico',
m.id,
v_profissional_id,
regexp_replace(m.telefone_profissional, '\D', '', 'g'),
true,
10
FROM public.medicos m
WHERE m.telefone_profissional IS NOT NULL
AND length(regexp_replace(m.telefone_profissional, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'medico' AND cp.entity_id = m.id
)
ON CONFLICT DO NOTHING;
-- Medicos.telefone_pessoal → Celular
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, position)
SELECT
m.tenant_id,
'medico',
m.id,
v_celular_id,
regexp_replace(m.telefone_pessoal, '\D', '', 'g'),
false,
20
FROM public.medicos m
WHERE m.telefone_pessoal IS NOT NULL
AND length(regexp_replace(m.telefone_pessoal, '\D', '', 'g')) BETWEEN 8 AND 15
AND NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'medico' AND cp.entity_id = m.id
AND cp.number = regexp_replace(m.telefone_pessoal, '\D', '', 'g')
)
ON CONFLICT DO NOTHING;
END $$;
-- ---------------------------------------------------------------------------
-- RLS: contact_types
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_types: select" ON public.contact_types;
CREATE POLICY "contact_types: select"
ON public.contact_types FOR SELECT TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "contact_types: manage custom" ON public.contact_types;
CREATE POLICY "contact_types: manage custom"
ON public.contact_types FOR ALL TO authenticated
USING (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
)
)
WITH CHECK (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_types.tenant_id AND tm.status = 'active'
)
)
);
-- ---------------------------------------------------------------------------
-- RLS: contact_phones
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_phones: all tenant" ON public.contact_phones;
CREATE POLICY "contact_phones: all tenant"
ON public.contact_phones FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_phones.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_phones.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,64 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Retroativa WhatsApp-linked em contact_phones
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Pacientes que foram vinculados via drawer de conversa ANTES do refactor
-- polimorfico de telefones tiveram o numero preservado em patients.telefone
-- mas sem marca "vinculado WhatsApp" (nao existia o conceito).
--
-- Este script:
-- 1. Detecta pacientes com conversation_messages (direction=inbound)
-- 2. Encontra o contact_phone que match o numero da conversa
-- 3. Muda o contact_type_id pra 'whatsapp' + seta whatsapp_linked_at
-- pra first_message_received_at
--
-- Nao destrutivo: so altera phones que claramente foram vinculados via CRM.
-- Se paciente tem multiplos phones, apenas o que match o numero da conversa
-- e afetado.
-- ==========================================================================
DO $$
DECLARE
v_whatsapp_type_id UUID;
BEGIN
SELECT id INTO v_whatsapp_type_id
FROM public.contact_types
WHERE slug = 'whatsapp' AND tenant_id IS NULL
LIMIT 1;
IF v_whatsapp_type_id IS NULL THEN
RAISE NOTICE 'Contact type WhatsApp nao encontrado — pule a migration retroativa';
RETURN;
END IF;
-- Atualiza contact_phones que match conversation_messages inbound
WITH convs AS (
SELECT
cm.patient_id,
regexp_replace(cm.from_number, '\D', '', 'g') AS phone_digits,
MIN(COALESCE(cm.received_at, cm.created_at)) AS first_msg_at
FROM public.conversation_messages cm
WHERE cm.patient_id IS NOT NULL
AND cm.direction = 'inbound'
AND cm.from_number IS NOT NULL
AND cm.channel = 'whatsapp'
GROUP BY cm.patient_id, regexp_replace(cm.from_number, '\D', '', 'g')
)
UPDATE public.contact_phones cp
SET
contact_type_id = v_whatsapp_type_id,
whatsapp_linked_at = COALESCE(cp.whatsapp_linked_at, convs.first_msg_at)
FROM convs
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits;
-- Log
RAISE NOTICE 'Retroactive WhatsApp link complete';
END $$;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,95 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Retroativa WhatsApp-linked v2
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Complementa a migration 20260421000009_retroactive_whatsapp_link:
--
-- Pacientes vinculados via drawer antes do refactor ficaram sem a info
-- de "vinculado WhatsApp". Algumas pecularidades:
--
-- 1. Mensagens podem ser outbound (to_number) ou inbound (from_number)
-- 2. O numero da conversa pode NAO existir ainda em contact_phones
-- (paciente tinha outro telefone cadastrado, CRM WhatsApp linkou um numero diferente)
--
-- Estrategia:
-- - Pra cada paciente com conversa no channel 'whatsapp', coleta TODOS
-- os numeros unicos (from_number + to_number conforme direction)
-- - Pra cada numero:
-- - Se paciente JA tem um contact_phone com esse numero → update pra WhatsApp + linked
-- - Se NAO tem → insere novo com type='whatsapp' + linked
-- ==========================================================================
DO $$
DECLARE
v_whatsapp_type_id UUID;
BEGIN
SELECT id INTO v_whatsapp_type_id
FROM public.contact_types
WHERE slug = 'whatsapp' AND tenant_id IS NULL
LIMIT 1;
IF v_whatsapp_type_id IS NULL THEN
RAISE NOTICE 'Contact type WhatsApp nao encontrado';
RETURN;
END IF;
-- CTE: extrai (patient_id, phone_digits, first_msg_at) de conversation_messages
WITH convs AS (
SELECT
cm.tenant_id,
cm.patient_id,
CASE
WHEN cm.direction = 'inbound' THEN regexp_replace(cm.from_number, '\D', '', 'g')
WHEN cm.direction = 'outbound' THEN regexp_replace(cm.to_number, '\D', '', 'g')
END AS phone_digits,
MIN(COALESCE(cm.received_at, cm.responded_at, cm.created_at)) AS first_msg_at
FROM public.conversation_messages cm
WHERE cm.patient_id IS NOT NULL
AND cm.channel = 'whatsapp'
AND (cm.from_number IS NOT NULL OR cm.to_number IS NOT NULL)
GROUP BY cm.tenant_id, cm.patient_id, 3
HAVING CASE
WHEN cm.direction = 'inbound' THEN regexp_replace(cm.from_number, '\D', '', 'g')
WHEN cm.direction = 'outbound' THEN regexp_replace(cm.to_number, '\D', '', 'g')
END IS NOT NULL
),
-- Atualiza phones que ja existem
updated AS (
UPDATE public.contact_phones cp
SET
contact_type_id = v_whatsapp_type_id,
whatsapp_linked_at = COALESCE(cp.whatsapp_linked_at, convs.first_msg_at)
FROM convs
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits
RETURNING cp.entity_id, cp.number
)
-- Insere phones que nao existem ainda pro paciente
INSERT INTO public.contact_phones (tenant_id, entity_type, entity_id, contact_type_id, number, is_primary, whatsapp_linked_at, position)
SELECT
convs.tenant_id,
'patient',
convs.patient_id,
v_whatsapp_type_id,
convs.phone_digits,
false, -- nao e primary (paciente ja tem outro)
convs.first_msg_at,
100 -- final da lista
FROM convs
WHERE NOT EXISTS (
SELECT 1 FROM public.contact_phones cp
WHERE cp.entity_type = 'patient'
AND cp.entity_id = convs.patient_id
AND cp.number = convs.phone_digits
)
AND length(convs.phone_digits) BETWEEN 8 AND 15;
RAISE NOTICE 'Retroactive WhatsApp link v2 complete';
END $$;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,266 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Emails polimorficos com tipo + principal
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Mesmo padrao dos telefones (migration 20260421000008):
-- - contact_email_types → tipos configuraveis (Principal, Comercial, Pessoal, ...)
-- - contact_emails → emails polimorficos (entity_type + entity_id)
--
-- Triggers mantem patients.email_principal/email_alternativo e medicos.email
-- sincronizados pra nao quebrar codigo legado.
-- ==========================================================================
-- ---------------------------------------------------------------------------
-- Tabela: contact_email_types
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_email_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) > 0 AND length(name) <= 40),
slug TEXT NOT NULL CHECK (slug ~ '^[a-z0-9_-]{1,40}$'),
icon TEXT,
is_system BOOLEAN NOT NULL DEFAULT false,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_email_types_tenant_slug
ON public.contact_email_types (tenant_id, slug)
WHERE tenant_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_email_types_system_slug
ON public.contact_email_types (slug)
WHERE tenant_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_contact_email_types_tenant
ON public.contact_email_types (tenant_id, position);
DROP TRIGGER IF EXISTS trg_contact_email_types_updated_at ON public.contact_email_types;
CREATE TRIGGER trg_contact_email_types_updated_at
BEFORE UPDATE ON public.contact_email_types
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_email_types IS
'Tipos de email (Principal, Comercial, Pessoal, ...). System (tenant_id NULL) + custom.';
-- Seed
INSERT INTO public.contact_email_types (tenant_id, name, slug, icon, is_system, position) VALUES
(NULL, 'Principal', 'principal', 'pi pi-envelope', true, 10),
(NULL, 'Pessoal', 'pessoal', 'pi pi-user', true, 20),
(NULL, 'Comercial', 'comercial', 'pi pi-building', true, 30),
(NULL, 'Faturamento', 'faturamento', 'pi pi-dollar', true, 40),
(NULL, 'Alternativo', 'alternativo', 'pi pi-reply', true, 50)
ON CONFLICT DO NOTHING;
-- ---------------------------------------------------------------------------
-- Tabela: contact_emails (polimorfica)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contact_emails (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL CHECK (entity_type IN ('patient', 'medico')),
entity_id UUID NOT NULL,
contact_email_type_id UUID NOT NULL REFERENCES public.contact_email_types(id) ON DELETE RESTRICT,
email TEXT NOT NULL CHECK (email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'),
is_primary BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
position INT NOT NULL DEFAULT 100,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_contact_emails_entity
ON public.contact_emails (tenant_id, entity_type, entity_id, position);
CREATE INDEX IF NOT EXISTS idx_contact_emails_email
ON public.contact_emails (tenant_id, email);
CREATE UNIQUE INDEX IF NOT EXISTS uq_contact_emails_primary
ON public.contact_emails (entity_type, entity_id)
WHERE is_primary = true;
DROP TRIGGER IF EXISTS trg_contact_emails_updated_at ON public.contact_emails;
CREATE TRIGGER trg_contact_emails_updated_at
BEFORE UPDATE ON public.contact_emails
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.contact_emails IS
'Emails polimorficos (patients, medicos, ...). Max 1 primary por entidade. Triggers sincronizam campos legados.';
-- ---------------------------------------------------------------------------
-- Trigger: sincroniza campos legados apos mudanca
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.sync_legacy_email_fields() RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entity_type TEXT;
v_entity_id UUID;
v_primary TEXT;
v_secondary TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
v_entity_type := OLD.entity_type;
v_entity_id := OLD.entity_id;
ELSE
v_entity_type := NEW.entity_type;
v_entity_id := NEW.entity_id;
END IF;
SELECT email INTO v_primary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
ORDER BY is_primary DESC, position ASC, created_at ASC
LIMIT 1;
SELECT email INTO v_secondary
FROM public.contact_emails
WHERE entity_type = v_entity_type AND entity_id = v_entity_id
AND is_primary = false
ORDER BY position ASC, created_at ASC
OFFSET 0
LIMIT 1;
IF v_entity_type = 'patient' THEN
UPDATE public.patients
SET email_principal = v_primary,
email_alternativo = v_secondary
WHERE id = v_entity_id;
ELSIF v_entity_type = 'medico' THEN
UPDATE public.medicos
SET email = v_primary
WHERE id = v_entity_id;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END;
$$;
DROP TRIGGER IF EXISTS trg_contact_emails_sync_legacy ON public.contact_emails;
CREATE TRIGGER trg_contact_emails_sync_legacy
AFTER INSERT OR UPDATE OR DELETE ON public.contact_emails
FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields();
-- ---------------------------------------------------------------------------
-- Backfill: migra emails existentes
-- ---------------------------------------------------------------------------
DO $$
DECLARE
v_principal_id UUID;
v_alternativo_id UUID;
BEGIN
SELECT id INTO v_principal_id FROM public.contact_email_types WHERE slug = 'principal' AND tenant_id IS NULL LIMIT 1;
SELECT id INTO v_alternativo_id FROM public.contact_email_types WHERE slug = 'alternativo' AND tenant_id IS NULL LIMIT 1;
-- Patients.email_principal → Principal primary
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
p.tenant_id, 'patient', p.id, v_principal_id, lower(trim(p.email_principal)), true, 10
FROM public.patients p
WHERE p.email_principal IS NOT NULL
AND trim(p.email_principal) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'patient' AND ce.entity_id = p.id
)
ON CONFLICT DO NOTHING;
-- Patients.email_alternativo → Alternativo
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
p.tenant_id, 'patient', p.id, v_alternativo_id, lower(trim(p.email_alternativo)), false, 20
FROM public.patients p
WHERE p.email_alternativo IS NOT NULL
AND trim(p.email_alternativo) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'patient' AND ce.entity_id = p.id
AND ce.email = lower(trim(p.email_alternativo))
)
ON CONFLICT DO NOTHING;
-- Medicos.email → Principal primary
INSERT INTO public.contact_emails (tenant_id, entity_type, entity_id, contact_email_type_id, email, is_primary, position)
SELECT
m.tenant_id, 'medico', m.id, v_principal_id, lower(trim(m.email)), true, 10
FROM public.medicos m
WHERE m.email IS NOT NULL
AND trim(m.email) ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'
AND NOT EXISTS (
SELECT 1 FROM public.contact_emails ce
WHERE ce.entity_type = 'medico' AND ce.entity_id = m.id
)
ON CONFLICT DO NOTHING;
END $$;
-- ---------------------------------------------------------------------------
-- RLS
-- ---------------------------------------------------------------------------
ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "contact_email_types: select" ON public.contact_email_types;
CREATE POLICY "contact_email_types: select"
ON public.contact_email_types FOR SELECT TO authenticated
USING (
tenant_id IS NULL
OR public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
);
DROP POLICY IF EXISTS "contact_email_types: manage custom" ON public.contact_email_types;
CREATE POLICY "contact_email_types: manage custom"
ON public.contact_email_types FOR ALL TO authenticated
USING (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
)
)
WITH CHECK (
is_system = false AND tenant_id IS NOT NULL AND (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_email_types.tenant_id AND tm.status = 'active'
)
)
);
DROP POLICY IF EXISTS "contact_emails: all tenant" ON public.contact_emails;
CREATE POLICY "contact_emails: all tenant"
ON public.contact_emails FOR ALL TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_emails.tenant_id AND tm.status = 'active'
)
)
WITH CHECK (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.tenant_id = contact_emails.tenant_id AND tm.status = 'active'
)
);
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,203 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Atribuicao de conversa a terapeuta (CRM Grupo 3.2)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Uma linha por (tenant_id, thread_key) — UPSERT em cada reatribuicao.
-- Historico (quem atribuiu pra quem e quando) pode ser adicionado depois
-- via trigger INSERT em conversation_assignment_history se virar requisito.
--
-- thread_key segue o padrao de conversation_threads:
-- - '<uuid>' → thread de paciente conhecido
-- - 'anon:<phone>' → thread de numero nao identificado
--
-- RLS:
-- - SELECT: qualquer membro ativo do tenant
-- - INSERT/UPDATE: qualquer membro ativo do tenant (self-assign ou delegar)
-- - DELETE: nao permitido (unassign = UPDATE assigned_to=NULL)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.conversation_assignments (
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
thread_key TEXT NOT NULL,
patient_id UUID REFERENCES public.patients(id) ON DELETE SET NULL,
contact_number TEXT,
assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, thread_key)
);
CREATE INDEX IF NOT EXISTS idx_conv_assign_tenant_user
ON public.conversation_assignments (tenant_id, assigned_to)
WHERE assigned_to IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_conv_assign_patient
ON public.conversation_assignments (patient_id)
WHERE patient_id IS NOT NULL;
-- Trigger de updated_at
DROP TRIGGER IF EXISTS trg_conv_assign_updated_at ON public.conversation_assignments;
CREATE TRIGGER trg_conv_assign_updated_at
BEFORE UPDATE ON public.conversation_assignments
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
COMMENT ON TABLE public.conversation_assignments IS
'Atribuicao de threads de conversa a membros do tenant. Uma linha por (tenant_id, thread_key). assigned_to=NULL significa nao atribuida.';
-- --------------------------------------------------------------------------
-- RLS
-- --------------------------------------------------------------------------
ALTER TABLE public.conversation_assignments ENABLE ROW LEVEL SECURITY;
-- SELECT: qualquer membro ativo do tenant OU saas_admin
DROP POLICY IF EXISTS "conv_assign: select tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: select tenant"
ON public.conversation_assignments
FOR SELECT
TO authenticated
USING (
public.is_saas_admin()
OR EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
);
-- INSERT: membro ativo do tenant. assigned_by deve ser o proprio user.
-- assigned_to deve ser membro ativo do mesmo tenant (ou NULL).
DROP POLICY IF EXISTS "conv_assign: insert tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: insert tenant"
ON public.conversation_assignments
FOR INSERT
TO authenticated
WITH CHECK (
assigned_by = auth.uid()
AND EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
AND (
assigned_to IS NULL
OR EXISTS (
SELECT 1 FROM public.tenant_members tm2
WHERE tm2.user_id = conversation_assignments.assigned_to
AND tm2.tenant_id = conversation_assignments.tenant_id
AND tm2.status = 'active'
)
)
);
-- UPDATE: membro ativo do tenant pode reatribuir. Mesma validacao pra assigned_to.
DROP POLICY IF EXISTS "conv_assign: update tenant" ON public.conversation_assignments;
CREATE POLICY "conv_assign: update tenant"
ON public.conversation_assignments
FOR UPDATE
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = auth.uid()
AND tm.tenant_id = conversation_assignments.tenant_id
AND tm.status = 'active'
)
)
WITH CHECK (
assigned_by = auth.uid()
AND (
assigned_to IS NULL
OR EXISTS (
SELECT 1 FROM public.tenant_members tm2
WHERE tm2.user_id = conversation_assignments.assigned_to
AND tm2.tenant_id = conversation_assignments.tenant_id
AND tm2.status = 'active'
)
)
);
-- DELETE: bloqueado (unassign = UPDATE assigned_to=NULL)
-- --------------------------------------------------------------------------
-- Atualiza view conversation_threads pra incluir assignment
-- --------------------------------------------------------------------------
DROP VIEW IF EXISTS public.conversation_threads CASCADE;
CREATE VIEW public.conversation_threads
WITH (security_invoker = true)
AS
WITH base AS (
SELECT
cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END AS contact_number,
COALESCE(cm.patient_id::text, 'anon:' || COALESCE(
CASE WHEN cm.direction = 'inbound' THEN cm.from_number ELSE cm.to_number END,
'unknown'
)) AS thread_key
FROM public.conversation_messages cm
),
latest AS (
SELECT DISTINCT ON (tenant_id, thread_key)
tenant_id, thread_key, patient_id, channel, contact_number,
body AS last_message_body,
direction AS last_message_direction,
kanban_status,
created_at AS last_message_at
FROM base
ORDER BY tenant_id, thread_key, created_at DESC
),
counts AS (
SELECT
tenant_id, thread_key,
COUNT(*) AS message_count,
COUNT(*) FILTER (WHERE direction = 'inbound' AND read_at IS NULL) AS unread_count
FROM base
GROUP BY tenant_id, thread_key
)
SELECT
l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status,
ca.assigned_to,
ca.assigned_at
FROM latest l
JOIN counts c ON c.tenant_id = l.tenant_id AND c.thread_key = l.thread_key
LEFT JOIN public.patients p ON p.id = l.patient_id
LEFT JOIN public.conversation_assignments ca
ON ca.tenant_id = l.tenant_id AND ca.thread_key = l.thread_key;
COMMENT ON VIEW public.conversation_threads IS
'Agregado de conversas por paciente ou numero anonimo. Base do Kanban. Inclui assignment atual do thread.';
GRANT SELECT ON public.conversation_threads TO authenticated;
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
@@ -0,0 +1,31 @@
-- ==========================================================================
-- Agencia PSI — Migracao: CPF/CNPJ do tenant (fiscal / Asaas)
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-04-21 · Sao Carlos/SP — Brasil
--
-- Adiciona coluna `cpf_cnpj` em tenants para suportar geracao de cobrancas
-- Asaas (ex: credits WhatsApp via PIX) e, no futuro, notas fiscais do SaaS.
-- Armazena apenas digitos (11 pra CPF, 14 pra CNPJ). Frontend formata.
--
-- Permissoes: admin do tenant (tenant_admin) pode ler/atualizar via RLS
-- existente em tenants. Nao adiciona policy extra aqui.
-- ==========================================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cpf_cnpj TEXT;
-- Permite apenas digitos; comprimento 11 (CPF) ou 14 (CNPJ); NULL tambem ok.
ALTER TABLE public.tenants
DROP CONSTRAINT IF EXISTS tenants_cpf_cnpj_format;
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_cpf_cnpj_format
CHECK (cpf_cnpj IS NULL OR cpf_cnpj ~ '^[0-9]{11}$' OR cpf_cnpj ~ '^[0-9]{14}$');
COMMENT ON COLUMN public.tenants.cpf_cnpj IS
'CPF (11 digitos) ou CNPJ (14 digitos) do titular do tenant. Usado por gateways de pagamento (Asaas). Apenas digitos.';
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
-- Extensions
-- Gerado automaticamente em 2026-04-17T12:23:04.148Z
-- Gerado automaticamente em 2026-04-21T23:16:33.041Z
-- Total: 10
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA public;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
-- Functions: auth
-- Gerado automaticamente em 2026-04-17T12:23:05.221Z
-- Gerado automaticamente em 2026-04-21T23:16:34.941Z
-- Total: 4
CREATE FUNCTION auth.email() RETURNS text
@@ -1,5 +1,5 @@
-- Functions: extensions
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
-- Gerado automaticamente em 2026-04-21T23:16:34.942Z
-- Total: 6
CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
@@ -1,5 +1,5 @@
-- Functions: pgbouncer
-- Gerado automaticamente em 2026-04-17T12:23:05.222Z
-- Gerado automaticamente em 2026-04-21T23:16:34.943Z
-- Total: 1
CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
-- Functions: realtime
-- Gerado automaticamente em 2026-04-17T12:23:05.223Z
-- Gerado automaticamente em 2026-04-21T23:16:34.949Z
-- Total: 12
CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
@@ -1,5 +1,5 @@
-- Functions: storage
-- Gerado automaticamente em 2026-04-17T12:23:05.224Z
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Total: 15
CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
@@ -1,5 +1,5 @@
-- Functions: supabase_functions
-- Gerado automaticamente em 2026-04-17T12:23:05.224Z
-- Gerado automaticamente em 2026-04-21T23:16:34.950Z
-- Total: 1
CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
@@ -1,6 +1,6 @@
-- Tables: Addons / Créditos
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Total: 3
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 7
CREATE TABLE public.addon_credits (
id uuid DEFAULT gen_random_uuid() NOT NULL,
@@ -22,7 +22,10 @@ CREATE TABLE public.addon_credits (
expires_at timestamp with time zone,
is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
updated_at timestamp with time zone DEFAULT now(),
CONSTRAINT addon_credits_balance_nonneg_chk CHECK ((balance >= 0)),
CONSTRAINT addon_credits_consumed_nonneg_chk CHECK ((total_consumed >= 0)),
CONSTRAINT addon_credits_purchased_nonneg_chk CHECK ((total_purchased >= 0))
);
CREATE TABLE public.addon_products (
@@ -64,3 +67,70 @@ CREATE TABLE public.addon_transactions (
created_at timestamp with time zone DEFAULT now(),
metadata jsonb DEFAULT '{}'::jsonb
);
CREATE TABLE public.whatsapp_credit_packages (
id uuid DEFAULT gen_random_uuid() NOT NULL,
name text NOT NULL,
description text,
credits integer NOT NULL,
price_brl numeric(10,2) NOT NULL,
is_active boolean DEFAULT true NOT NULL,
is_featured boolean DEFAULT false NOT NULL,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credit_packages_credits_check CHECK ((credits > 0)),
CONSTRAINT whatsapp_credit_packages_name_check CHECK (((length(name) > 0) AND (length(name) <= 100))),
CONSTRAINT whatsapp_credit_packages_price_brl_check CHECK ((price_brl > (0)::numeric))
);
CREATE TABLE public.whatsapp_credit_purchases (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
package_id uuid,
package_name text NOT NULL,
credits integer NOT NULL,
amount_brl numeric(10,2) NOT NULL,
status text DEFAULT 'pending'::text NOT NULL,
asaas_customer_id text,
asaas_payment_id text,
asaas_payment_link text,
asaas_pix_qrcode text,
asaas_pix_copy_paste text,
paid_at timestamp with time zone,
expires_at timestamp with time zone,
failed_at timestamp with time zone,
created_by uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credit_purchases_amount_brl_check CHECK ((amount_brl > (0)::numeric)),
CONSTRAINT whatsapp_credit_purchases_credits_check CHECK ((credits > 0)),
CONSTRAINT whatsapp_credit_purchases_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'failed'::text, 'expired'::text, 'refunded'::text, 'cancelled'::text])))
);
CREATE TABLE public.whatsapp_credits_balance (
tenant_id uuid NOT NULL,
balance integer DEFAULT 0 NOT NULL,
lifetime_purchased integer DEFAULT 0 NOT NULL,
lifetime_used integer DEFAULT 0 NOT NULL,
low_balance_threshold integer DEFAULT 20 NOT NULL,
low_balance_alerted_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credits_balance_balance_check CHECK ((balance >= 0)),
CONSTRAINT whatsapp_credits_balance_low_balance_threshold_check CHECK ((low_balance_threshold >= 0))
);
CREATE TABLE public.whatsapp_credits_transactions (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
kind text NOT NULL,
amount integer NOT NULL,
balance_after integer NOT NULL,
conversation_message_id bigint,
purchase_id uuid,
admin_id uuid,
note text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT whatsapp_credits_transactions_kind_check CHECK ((kind = ANY (ARRAY['purchase'::text, 'usage'::text, 'topup_manual'::text, 'refund'::text, 'adjustment'::text])))
);
@@ -1,5 +1,5 @@
-- Tables: Agenda / Agendamento
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 10
CREATE TABLE public.agenda_bloqueios (
@@ -1,5 +1,5 @@
-- Tables: Central SaaS (docs/FAQ)
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
-- Total: 4
CREATE TABLE public.saas_doc_votos (
@@ -1,7 +1,36 @@
-- Tables: Comunicação / Notificações
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 14
CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL,
queue_id uuid,
agenda_evento_id uuid,
patient_id uuid NOT NULL,
channel text NOT NULL,
template_key text NOT NULL,
schedule_key text,
recipient_address text NOT NULL,
resolved_message text,
resolved_vars jsonb,
status text NOT NULL,
provider text,
provider_message_id text,
provider_status text,
provider_response jsonb,
sent_at timestamp with time zone,
delivered_at timestamp with time zone,
read_at timestamp with time zone,
failed_at timestamp with time zone,
failure_reason text,
estimated_cost_brl numeric(8,4) DEFAULT 0,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notification_logs_status_check CHECK ((status = ANY (ARRAY['sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'bounced'::text, 'opted_out'::text])))
);
CREATE TABLE public.email_layout_config (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
@@ -123,35 +152,6 @@ CREATE TABLE public.notification_channels (
CONSTRAINT notification_channels_provider_check CHECK ((provider = ANY (ARRAY['evolution_api'::text, 'meta_official'::text, 'twilio'::text, 'zenvia'::text, 'sendgrid'::text, 'resend'::text, 'smtp'::text, 'zapi'::text])))
);
CREATE TABLE public.notification_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL,
queue_id uuid,
agenda_evento_id uuid,
patient_id uuid NOT NULL,
channel text NOT NULL,
template_key text NOT NULL,
schedule_key text,
recipient_address text NOT NULL,
resolved_message text,
resolved_vars jsonb,
status text NOT NULL,
provider text,
provider_message_id text,
provider_status text,
provider_response jsonb,
sent_at timestamp with time zone,
delivered_at timestamp with time zone,
read_at timestamp with time zone,
failed_at timestamp with time zone,
failure_reason text,
estimated_cost_brl numeric(8,4) DEFAULT 0,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notification_logs_status_check CHECK ((status = ANY (ARRAY['sent'::text, 'delivered'::text, 'read'::text, 'failed'::text, 'bounced'::text, 'opted_out'::text])))
);
CREATE TABLE public.notification_preferences (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
@@ -260,7 +260,7 @@ CREATE TABLE public.notifications (
read_at timestamp with time zone,
archived boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text])))
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text, 'inbound_message'::text])))
);
CREATE TABLE public.twilio_subaccount_usage (
@@ -0,0 +1,155 @@
-- Tables: CRM Conversas (WhatsApp)
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 10
CREATE TABLE public.conversation_autoreply_log (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
sent_at timestamp with time zone DEFAULT now() NOT NULL,
message_id uuid
);
CREATE TABLE public.conversation_autoreply_settings (
tenant_id uuid NOT NULL,
enabled boolean DEFAULT false NOT NULL,
message text DEFAULT 'Olá! Nosso horário de atendimento acabou. Retornaremos sua mensagem assim que possível. Obrigado!'::text NOT NULL,
cooldown_minutes integer DEFAULT 180 NOT NULL,
schedule_mode text DEFAULT 'agenda'::text NOT NULL,
business_hours jsonb DEFAULT '[]'::jsonb NOT NULL,
custom_window jsonb DEFAULT '[]'::jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_autoreply_settings_cooldown_minutes_check CHECK (((cooldown_minutes >= 0) AND (cooldown_minutes <= 43200))),
CONSTRAINT conversation_autoreply_settings_message_check CHECK (((length(message) > 0) AND (length(message) <= 2000))),
CONSTRAINT conversation_autoreply_settings_schedule_mode_check CHECK ((schedule_mode = ANY (ARRAY['agenda'::text, 'business_hours'::text, 'custom'::text])))
);
CREATE TABLE public.conversation_messages (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
patient_id uuid,
channel text NOT NULL,
direction text NOT NULL,
from_number text,
to_number text,
body text,
media_url text,
media_mime text,
provider text NOT NULL,
provider_message_id text,
provider_raw jsonb,
kanban_status text DEFAULT 'awaiting_us'::text NOT NULL,
priority integer DEFAULT 0 NOT NULL,
read_at timestamp with time zone,
responded_at timestamp with time zone,
resolved_at timestamp with time zone,
received_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
delivered_at timestamp with time zone,
read_by_recipient_at timestamp with time zone,
delivery_status text,
CONSTRAINT conversation_messages_channel_check CHECK ((channel = ANY (ARRAY['whatsapp'::text, 'sms'::text, 'email'::text]))),
CONSTRAINT conversation_messages_delivery_status_check CHECK (((delivery_status IS NULL) OR (delivery_status = ANY (ARRAY['pending'::text, 'sent'::text, 'delivered'::text, 'read'::text, 'failed'::text])))),
CONSTRAINT conversation_messages_direction_check CHECK ((direction = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
CONSTRAINT conversation_messages_kanban_status_check CHECK ((kanban_status = ANY (ARRAY['urgent'::text, 'awaiting_us'::text, 'awaiting_patient'::text, 'resolved'::text]))),
CONSTRAINT conversation_messages_provider_check CHECK ((provider = ANY (ARRAY['twilio'::text, 'evolution'::text, 'manual'::text])))
);
CREATE TABLE public.conversation_notes (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
patient_id uuid,
contact_number text,
body text NOT NULL,
created_by uuid NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
deleted_at timestamp with time zone,
CONSTRAINT conversation_notes_body_check CHECK (((length(body) > 0) AND (length(body) <= 4000)))
);
CREATE TABLE public.conversation_optout_keywords (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
keyword text NOT NULL,
enabled boolean DEFAULT true NOT NULL,
is_system boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_optout_keywords_keyword_check CHECK (((length(keyword) > 0) AND (length(keyword) <= 100)))
);
CREATE TABLE public.conversation_optouts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
phone text NOT NULL,
patient_id uuid,
source text DEFAULT 'keyword'::text NOT NULL,
keyword_matched text,
original_message text,
notes text,
blocked_by uuid,
opted_out_at timestamp with time zone DEFAULT now() NOT NULL,
opted_back_in_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_optouts_phone_check CHECK ((phone ~ '^\d{6,15}$'::text)),
CONSTRAINT conversation_optouts_source_check CHECK ((source = ANY (ARRAY['keyword'::text, 'manual'::text])))
);
CREATE TABLE public.conversation_tags (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
name text NOT NULL,
slug text NOT NULL,
color text DEFAULT '#6366f1'::text NOT NULL,
icon text,
"position" integer DEFAULT 100 NOT NULL,
is_system boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT conversation_tags_color_check CHECK ((color ~ '^#[0-9a-fA-F]{6}$'::text)),
CONSTRAINT conversation_tags_name_check CHECK (((length(name) > 0) AND (length(name) <= 40))),
CONSTRAINT conversation_tags_slug_check CHECK ((slug ~ '^[a-z0-9_-]{1,40}$'::text))
);
CREATE TABLE public.conversation_thread_tags (
tenant_id uuid NOT NULL,
thread_key text NOT NULL,
tag_id uuid NOT NULL,
tagged_by uuid,
tagged_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.session_reminder_logs (
id bigint NOT NULL,
event_id uuid NOT NULL,
tenant_id uuid NOT NULL,
reminder_type text NOT NULL,
sent_at timestamp with time zone DEFAULT now() NOT NULL,
provider text,
skip_reason text,
to_phone text,
provider_message_id text,
conversation_message_id bigint,
CONSTRAINT session_reminder_logs_reminder_type_check CHECK ((reminder_type = ANY (ARRAY['24h'::text, '2h'::text])))
);
CREATE TABLE public.session_reminder_settings (
tenant_id uuid NOT NULL,
enabled boolean DEFAULT false NOT NULL,
send_24h boolean DEFAULT true NOT NULL,
send_2h boolean DEFAULT true NOT NULL,
template_24h text DEFAULT 'Oi {{nome_paciente}}! 👋 Lembrando da sua sessão amanhã, {{data_sessao}} às {{hora_sessao}}. Até lá!'::text NOT NULL,
template_2h text DEFAULT 'Oi {{nome_paciente}}! Sua sessão começa em 2 horas, às {{hora_sessao}}. Te espero! 😊'::text NOT NULL,
quiet_hours_enabled boolean DEFAULT true NOT NULL,
quiet_hours_start time without time zone DEFAULT '22:00:00'::time without time zone NOT NULL,
quiet_hours_end time without time zone DEFAULT '08:00:00'::time without time zone NOT NULL,
respect_opt_out boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT session_reminder_settings_template_24h_check CHECK (((length(template_24h) > 0) AND (length(template_24h) <= 2000))),
CONSTRAINT session_reminder_settings_template_2h_check CHECK (((length(template_2h) > 0) AND (length(template_2h) <= 2000)))
);
@@ -1,5 +1,5 @@
-- Tables: Documentos
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
-- Gerado automaticamente em 2026-04-21T23:16:34.955Z
-- Total: 6
CREATE TABLE public.document_access_logs (
@@ -111,6 +111,7 @@ CREATE TABLE public.documents (
retencao_ate timestamp with time zone,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now(),
content_sha256 text,
CONSTRAINT documents_status_revisao_check CHECK ((status_revisao = ANY (ARRAY['pendente'::text, 'aprovado'::text, 'rejeitado'::text]))),
CONSTRAINT documents_tipo_check CHECK ((tipo_documento = ANY (ARRAY['laudo'::text, 'receita'::text, 'exame'::text, 'termo_assinado'::text, 'relatorio_externo'::text, 'identidade'::text, 'convenio'::text, 'declaracao'::text, 'atestado'::text, 'recibo'::text, 'outro'::text]))),
CONSTRAINT documents_visibilidade_check CHECK ((visibilidade = ANY (ARRAY['privado'::text, 'compartilhado_supervisor'::text, 'compartilhado_portal'::text])))
@@ -1,5 +1,5 @@
-- Tables: Estrutura / Calendário
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 1
CREATE TABLE public.feriados (
@@ -1,11 +1,11 @@
-- Tables: Financeiro
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 10
CREATE TABLE public.financial_records (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid,
tenant_id uuid NOT NULL,
type public.financial_record_type DEFAULT 'receita'::public.financial_record_type NOT NULL,
amount numeric(10,2) NOT NULL,
description text,
@@ -35,6 +35,7 @@ CREATE TABLE public.financial_records (
CONSTRAINT financial_records_clinic_fee_amount_check CHECK ((clinic_fee_amount >= (0)::numeric)),
CONSTRAINT financial_records_clinic_fee_pct_check CHECK (((clinic_fee_pct >= (0)::numeric) AND (clinic_fee_pct <= (100)::numeric))),
CONSTRAINT financial_records_discount_amount_check CHECK ((discount_amount >= (0)::numeric)),
CONSTRAINT financial_records_fee_lte_amount_chk CHECK (((clinic_fee_amount IS NULL) OR ((clinic_fee_amount >= (0)::numeric) AND (clinic_fee_amount <= amount)))),
CONSTRAINT financial_records_final_amount_check CHECK ((final_amount >= (0)::numeric)),
CONSTRAINT financial_records_installments_check CHECK ((installments >= 1)),
CONSTRAINT financial_records_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'partial'::text, 'overdue'::text, 'cancelled'::text, 'refunded'::text])))
+258 -2
View File
@@ -1,6 +1,6 @@
-- Tables: outros
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Total: 1
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 17
CREATE TABLE public._db_migrations (
id integer NOT NULL,
@@ -9,3 +9,259 @@ CREATE TABLE public._db_migrations (
category text DEFAULT 'migration'::text NOT NULL,
applied_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.audit_logs (
id bigint NOT NULL,
tenant_id uuid NOT NULL,
user_id uuid,
entity_type text NOT NULL,
entity_id text,
action text NOT NULL,
old_values jsonb,
new_values jsonb,
changed_fields text[],
metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT audit_logs_action_check CHECK ((action = ANY (ARRAY['insert'::text, 'update'::text, 'delete'::text])))
);
CREATE TABLE public.dev_auditoria_items (
id bigint NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao_problema text,
solucao text,
severidade character varying(20),
status character varying(20) DEFAULT 'aberto'::character varying NOT NULL,
resolvido_em date,
sessao_resolucao character varying(160),
arquivo_afetado text,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_auditoria_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_auditoria_items_status_check CHECK (((status)::text = ANY ((ARRAY['aberto'::character varying, 'em_analise'::character varying, 'resolvido'::character varying, 'wontfix'::character varying, 'duplicado'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_competitor_status (
id bigint NOT NULL,
comparison_id bigint NOT NULL,
competitor_id bigint NOT NULL,
status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nota text,
fonte character varying(20),
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_competitor_status_fonte_check CHECK (((fonte IS NULL) OR ((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))),
CONSTRAINT dev_comparison_competitor_status_status_check CHECK (((status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_comparison_matrix (
id bigint NOT NULL,
dominio character varying(120),
feature text NOT NULL,
nosso_status character varying(20) DEFAULT 'a_definir'::character varying NOT NULL,
nossa_nota text,
importancia character varying(20),
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_comparison_matrix_importancia_check CHECK (((importancia IS NULL) OR ((importancia)::text = ANY ((ARRAY['alta'::character varying, 'media'::character varying, 'baixa'::character varying])::text[])))),
CONSTRAINT dev_comparison_matrix_nosso_status_check CHECK (((nosso_status)::text = ANY ((ARRAY['tem'::character varying, 'parcial'::character varying, 'gap'::character varying, 'na'::character varying, 'a_definir'::character varying])::text[])))
);
CREATE TABLE public.dev_competitor_features (
id bigint NOT NULL,
competitor_id bigint NOT NULL,
categoria character varying(120),
nome text NOT NULL,
descricao text,
fonte character varying(20) DEFAULT 'publico'::character varying NOT NULL,
fonte_url text,
data_fonte date,
destaque boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
CONSTRAINT dev_competitor_features_fonte_check CHECK (((fonte)::text = ANY ((ARRAY['fetched'::character varying, 'observacao'::character varying, 'publico'::character varying, 'hipotese'::character varying])::text[])))
);
CREATE TABLE public.dev_competitors (
id bigint NOT NULL,
slug character varying(80) NOT NULL,
nome character varying(160) NOT NULL,
pais character varying(40),
foco character varying(160),
pricing text,
posicionamento text,
url text,
ultima_pesquisa date,
notas text,
ativo boolean DEFAULT true NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_generation_log (
id bigint NOT NULL,
tipo character varying(40) NOT NULL,
comando text,
sucesso boolean DEFAULT false NOT NULL,
stdout text,
stderr text,
duration_ms integer,
metadata jsonb DEFAULT '{}'::jsonb,
trigger_user_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.dev_roadmap_items (
id bigint NOT NULL,
phase_id bigint NOT NULL,
numero integer,
bloco character varying(160),
feature text NOT NULL,
descricao text,
esforco character varying(4),
prioridade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
notas text,
assignee character varying(120),
data_inicio date,
data_conclusao date,
ordem integer DEFAULT 0 NOT NULL,
tags text[] DEFAULT '{}'::text[],
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_items_esforco_check CHECK (((esforco IS NULL) OR ((esforco)::text = ANY ((ARRAY['S'::character varying, 'M'::character varying, 'L'::character varying, 'XL'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_prioridade_check CHECK (((prioridade IS NULL) OR ((prioridade)::text = ANY ((ARRAY['bloqueador'::character varying, 'alta'::character varying, 'media'::character varying, 'diferencial'::character varying])::text[])))),
CONSTRAINT dev_roadmap_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'em_andamento'::character varying, 'concluido'::character varying, 'cancelado'::character varying, 'bloqueado'::character varying])::text[])))
);
CREATE TABLE public.dev_roadmap_phases (
id bigint NOT NULL,
numero integer NOT NULL,
nome character varying(160) NOT NULL,
objetivo text,
timeline_sugerida character varying(160),
criterio_saida text,
status character varying(20) DEFAULT 'planejada'::character varying NOT NULL,
data_inicio date,
data_fim date,
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_roadmap_phases_status_check CHECK (((status)::text = ANY ((ARRAY['planejada'::character varying, 'em_andamento'::character varying, 'concluida'::character varying, 'arquivada'::character varying])::text[])))
);
CREATE TABLE public.dev_test_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
arquivo text,
descricao text,
total_tests integer DEFAULT 0,
passing integer DEFAULT 0,
failing integer DEFAULT 0,
skipped integer DEFAULT 0,
cobertura_pct numeric(5,2),
status character varying(20) DEFAULT 'ok'::character varying NOT NULL,
last_run_at timestamp with time zone,
sessao_criacao character varying(160),
notas text,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_test_items_status_check CHECK (((status)::text = ANY ((ARRAY['ok'::character varying, 'falhando'::character varying, 'pendente'::character varying, 'obsoleto'::character varying, 'a_escrever'::character varying])::text[])))
);
CREATE TABLE public.dev_verificacoes_items (
id bigint NOT NULL,
area character varying(80) NOT NULL,
categoria character varying(120),
titulo text NOT NULL,
descricao text,
resultado text,
acao_sugerida text,
severidade character varying(20),
status character varying(20) DEFAULT 'pendente'::character varying NOT NULL,
verificado_em date,
sessao_verificacao character varying(160),
arquivo_afetado text,
auditoria_item_id bigint,
tags text[] DEFAULT '{}'::text[],
ordem integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT dev_verificacoes_items_severidade_check CHECK (((severidade IS NULL) OR ((severidade)::text = ANY ((ARRAY['critico'::character varying, 'alto'::character varying, 'medio'::character varying, 'baixo'::character varying])::text[])))),
CONSTRAINT dev_verificacoes_items_status_check CHECK (((status)::text = ANY ((ARRAY['pendente'::character varying, 'verificando'::character varying, 'ok'::character varying, 'problema'::character varying, 'corrigido'::character varying, 'wontfix'::character varying])::text[])))
);
CREATE TABLE public.math_challenges (
id uuid DEFAULT gen_random_uuid() NOT NULL,
question text NOT NULL,
answer integer NOT NULL,
used boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone DEFAULT (now() + '00:05:00'::interval) NOT NULL
);
CREATE TABLE public.patient_invite_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
token text NOT NULL,
ok boolean NOT NULL,
error_code text,
error_msg text,
client_info text,
owner_id uuid,
tenant_id uuid,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.public_submission_attempts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
endpoint text NOT NULL,
ip_hash text,
success boolean NOT NULL,
error_code text,
error_msg text,
blocked_by text,
user_agent text,
metadata jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.saas_security_config (
id boolean DEFAULT true NOT NULL,
honeypot_enabled boolean DEFAULT true NOT NULL,
rate_limit_enabled boolean DEFAULT true NOT NULL,
rate_limit_window_min integer DEFAULT 10 NOT NULL,
rate_limit_max_attempts integer DEFAULT 5 NOT NULL,
captcha_after_failures integer DEFAULT 3 NOT NULL,
captcha_required_globally boolean DEFAULT false NOT NULL,
block_duration_min integer DEFAULT 30 NOT NULL,
captcha_required_window_min integer DEFAULT 60 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_security_config_singleton CHECK ((id = true))
);
CREATE TABLE public.saas_twilio_config (
id boolean DEFAULT true NOT NULL,
account_sid text,
whatsapp_webhook_url text,
usd_brl_rate numeric(10,4) DEFAULT 5.5 NOT NULL,
margin_multiplier numeric(10,4) DEFAULT 1.4 NOT NULL,
notes text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by uuid,
CONSTRAINT saas_twilio_config_mult_chk CHECK (((margin_multiplier >= (1)::numeric) AND (margin_multiplier <= (10)::numeric))),
CONSTRAINT saas_twilio_config_rate_chk CHECK (((usd_brl_rate > (0)::numeric) AND (usd_brl_rate < (100)::numeric))),
CONSTRAINT saas_twilio_config_sid_chk CHECK (((account_sid IS NULL) OR (account_sid ~ '^AC[a-zA-Z0-9]{32}$'::text))),
CONSTRAINT saas_twilio_config_singleton CHECK ((id = true)),
CONSTRAINT saas_twilio_config_url_chk CHECK (((whatsapp_webhook_url IS NULL) OR (whatsapp_webhook_url ~ '^https?://'::text)))
);
+155 -93
View File
@@ -1,6 +1,159 @@
-- Tables: Pacientes
-- Gerado automaticamente em 2026-04-17T12:23:05.230Z
-- Total: 12
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 16
CREATE TABLE public.patient_status_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status_anterior text,
status_novo text NOT NULL,
motivo text,
encaminhado_para text,
data_saida date,
alterado_por uuid,
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT psh_status_novo_check CHECK ((status_novo = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
CREATE TABLE public.contact_email_types (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
name text NOT NULL,
slug text NOT NULL,
icon text,
is_system boolean DEFAULT false NOT NULL,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_email_types_name_check CHECK (((length(name) > 0) AND (length(name) <= 40))),
CONSTRAINT contact_email_types_slug_check CHECK ((slug ~ '^[a-z0-9_-]{1,40}$'::text))
);
CREATE TABLE public.contact_emails (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
entity_type text NOT NULL,
entity_id uuid NOT NULL,
contact_email_type_id uuid NOT NULL,
email text NOT NULL,
is_primary boolean DEFAULT false NOT NULL,
notes text,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_emails_email_check CHECK ((email ~* '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$'::text)),
CONSTRAINT contact_emails_entity_type_check CHECK ((entity_type = ANY (ARRAY['patient'::text, 'medico'::text])))
);
CREATE TABLE public.contact_phones (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid NOT NULL,
entity_type text NOT NULL,
entity_id uuid NOT NULL,
contact_type_id uuid NOT NULL,
number text NOT NULL,
is_primary boolean DEFAULT false NOT NULL,
whatsapp_linked_at timestamp with time zone,
notes text,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_phones_entity_type_check CHECK ((entity_type = ANY (ARRAY['patient'::text, 'medico'::text]))),
CONSTRAINT contact_phones_number_check CHECK ((number ~ '^\d{8,15}$'::text))
);
CREATE TABLE public.contact_types (
id uuid DEFAULT gen_random_uuid() NOT NULL,
tenant_id uuid,
name text NOT NULL,
slug text NOT NULL,
icon text,
is_mobile boolean DEFAULT true NOT NULL,
is_system boolean DEFAULT false NOT NULL,
"position" integer DEFAULT 100 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT contact_types_name_check CHECK (((length(name) > 0) AND (length(name) <= 40))),
CONSTRAINT contact_types_slug_check CHECK ((slug ~ '^[a-z0-9_-]{1,40}$'::text))
);
CREATE TABLE public.patients (
id uuid DEFAULT gen_random_uuid() NOT NULL,
nome_completo text NOT NULL,
email_principal text,
telefone text,
created_at timestamp with time zone DEFAULT now(),
owner_id uuid,
avatar_url text,
status text DEFAULT 'Ativo'::text,
last_attended_at timestamp with time zone,
is_native boolean DEFAULT false,
naturalidade text,
data_nascimento date,
rg text,
cpf text,
identification_color text,
genero text,
estado_civil text,
email_alternativo text,
pais text DEFAULT 'Brasil'::text,
cep text,
cidade text,
estado text,
endereco text,
numero text,
bairro text,
complemento text,
escolaridade text,
profissao text,
nome_parente text,
grau_parentesco text,
telefone_alternativo text,
onde_nos_conheceu text,
encaminhado_por text,
nome_responsavel text,
telefone_responsavel text,
cpf_responsavel text,
observacao_responsavel text,
cobranca_no_responsavel boolean DEFAULT false,
observacoes text,
notas_internas text,
updated_at timestamp with time zone DEFAULT now(),
telefone_parente text,
tenant_id uuid NOT NULL,
responsible_member_id uuid NOT NULL,
user_id uuid,
patient_scope text DEFAULT 'clinic'::text NOT NULL,
therapist_member_id uuid,
nome_social text,
pronomes text,
etnia text,
religiao text,
faixa_renda text,
canal_preferido text DEFAULT 'whatsapp'::text,
horario_contato_inicio time without time zone DEFAULT '08:00:00'::time without time zone,
horario_contato_fim time without time zone DEFAULT '20:00:00'::time without time zone,
idioma text DEFAULT 'pt-BR'::text,
origem text,
metodo_pagamento_preferido text,
motivo_saida text,
data_saida date,
encaminhado_para text,
risco_elevado boolean DEFAULT false NOT NULL,
risco_nota text,
risco_sinalizado_em timestamp with time zone,
risco_sinalizado_por uuid,
horario_contato text,
convenio text,
convenio_id uuid,
CONSTRAINT cpf_responsavel_format_check CHECK (((cpf_responsavel IS NULL) OR (cpf_responsavel ~ '^\d{11}$'::text))),
CONSTRAINT patients_cpf_format_check CHECK (((cpf IS NULL) OR (cpf ~ '^\d{11}$'::text))),
CONSTRAINT patients_faixa_renda_check CHECK (((faixa_renda IS NULL) OR (faixa_renda = ANY (ARRAY['ate_1sm'::text, '1_3sm'::text, '3_6sm'::text, '6_10sm'::text, 'acima_10sm'::text, 'nao_informado'::text])))),
CONSTRAINT patients_metodo_pagamento_check CHECK (((metodo_pagamento_preferido IS NULL) OR (metodo_pagamento_preferido = ANY (ARRAY['pix'::text, 'cartao'::text, 'dinheiro'::text, 'deposito'::text, 'convenio'::text])))),
CONSTRAINT patients_risco_consistency_check CHECK (((risco_elevado = false) OR ((risco_elevado = true) AND (risco_nota IS NOT NULL) AND (risco_sinalizado_por IS NOT NULL)))),
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Em espera'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
CREATE TABLE public.patient_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
@@ -117,20 +270,6 @@ CREATE TABLE public.patient_patient_tag (
tenant_id uuid NOT NULL
);
CREATE TABLE public.patient_status_history (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL,
tenant_id uuid NOT NULL,
status_anterior text,
status_novo text NOT NULL,
motivo text,
encaminhado_para text,
data_saida date,
alterado_por uuid,
alterado_em timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT psh_status_novo_check CHECK ((status_novo = ANY (ARRAY['Ativo'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
CREATE TABLE public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL,
@@ -172,80 +311,3 @@ CREATE TABLE public.patient_timeline (
CONSTRAINT pt_evento_tipo_check CHECK ((evento_tipo = ANY (ARRAY['primeira_sessao'::text, 'sessao_realizada'::text, 'sessao_cancelada'::text, 'falta'::text, 'status_alterado'::text, 'risco_sinalizado'::text, 'risco_removido'::text, 'documento_assinado'::text, 'documento_adicionado'::text, 'escala_respondida'::text, 'escala_enviada'::text, 'pagamento_vencido'::text, 'pagamento_recebido'::text, 'tarefa_combinada'::text, 'contato_adicionado'::text, 'prontuario_editado'::text, 'nota_adicionada'::text, 'manual'::text]))),
CONSTRAINT pt_icone_cor_check CHECK ((icone_cor = ANY (ARRAY['green'::text, 'blue'::text, 'amber'::text, 'red'::text, 'gray'::text, 'purple'::text])))
);
CREATE TABLE public.patients (
id uuid DEFAULT gen_random_uuid() NOT NULL,
nome_completo text NOT NULL,
email_principal text,
telefone text,
created_at timestamp with time zone DEFAULT now(),
owner_id uuid,
avatar_url text,
status text DEFAULT 'Ativo'::text,
last_attended_at timestamp with time zone,
is_native boolean DEFAULT false,
naturalidade text,
data_nascimento date,
rg text,
cpf text,
identification_color text,
genero text,
estado_civil text,
email_alternativo text,
pais text DEFAULT 'Brasil'::text,
cep text,
cidade text,
estado text,
endereco text,
numero text,
bairro text,
complemento text,
escolaridade text,
profissao text,
nome_parente text,
grau_parentesco text,
telefone_alternativo text,
onde_nos_conheceu text,
encaminhado_por text,
nome_responsavel text,
telefone_responsavel text,
cpf_responsavel text,
observacao_responsavel text,
cobranca_no_responsavel boolean DEFAULT false,
observacoes text,
notas_internas text,
updated_at timestamp with time zone DEFAULT now(),
telefone_parente text,
tenant_id uuid NOT NULL,
responsible_member_id uuid NOT NULL,
user_id uuid,
patient_scope text DEFAULT 'clinic'::text NOT NULL,
therapist_member_id uuid,
nome_social text,
pronomes text,
etnia text,
religiao text,
faixa_renda text,
canal_preferido text DEFAULT 'whatsapp'::text,
horario_contato_inicio time without time zone DEFAULT '08:00:00'::time without time zone,
horario_contato_fim time without time zone DEFAULT '20:00:00'::time without time zone,
idioma text DEFAULT 'pt-BR'::text,
origem text,
metodo_pagamento_preferido text,
motivo_saida text,
data_saida date,
encaminhado_para text,
risco_elevado boolean DEFAULT false NOT NULL,
risco_nota text,
risco_sinalizado_em timestamp with time zone,
risco_sinalizado_por uuid,
horario_contato text,
convenio text,
convenio_id uuid,
CONSTRAINT cpf_responsavel_format_check CHECK (((cpf_responsavel IS NULL) OR (cpf_responsavel ~ '^\d{11}$'::text))),
CONSTRAINT patients_cpf_format_check CHECK (((cpf IS NULL) OR (cpf ~ '^\d{11}$'::text))),
CONSTRAINT patients_faixa_renda_check CHECK (((faixa_renda IS NULL) OR (faixa_renda = ANY (ARRAY['ate_1sm'::text, '1_3sm'::text, '3_6sm'::text, '6_10sm'::text, 'acima_10sm'::text, 'nao_informado'::text])))),
CONSTRAINT patients_metodo_pagamento_check CHECK (((metodo_pagamento_preferido IS NULL) OR (metodo_pagamento_preferido = ANY (ARRAY['pix'::text, 'cartao'::text, 'dinheiro'::text, 'deposito'::text, 'convenio'::text])))),
CONSTRAINT patients_risco_consistency_check CHECK (((risco_elevado = false) OR ((risco_elevado = true) AND (risco_nota IS NOT NULL) AND (risco_sinalizado_por IS NOT NULL)))),
CONSTRAINT patients_status_check CHECK ((status = ANY (ARRAY['Ativo'::text, 'Em espera'::text, 'Inativo'::text, 'Alta'::text, 'Encaminhado'::text, 'Arquivado'::text])))
);
@@ -1,5 +1,5 @@
-- Tables: SaaS / Planos
-- Gerado automaticamente em 2026-04-17T12:23:05.227Z
-- Gerado automaticamente em 2026-04-21T23:16:34.953Z
-- Total: 18
CREATE TABLE public.subscriptions (
@@ -66,7 +66,8 @@ CREATE TABLE public.features (
description text,
created_at timestamp with time zone DEFAULT now() NOT NULL,
descricao text DEFAULT ''::text NOT NULL,
name text DEFAULT ''::text NOT NULL
name text DEFAULT ''::text NOT NULL,
is_active boolean DEFAULT true NOT NULL
);
CREATE TABLE public.module_features (
@@ -0,0 +1,15 @@
-- Tables: Segurança / Rate limiting
-- Gerado automaticamente em 2026-04-21T23:16:34.957Z
-- Total: 1
CREATE TABLE public.submission_rate_limits (
ip_hash text NOT NULL,
endpoint text NOT NULL,
attempt_count integer DEFAULT 0 NOT NULL,
fail_count integer DEFAULT 0 NOT NULL,
window_start timestamp with time zone DEFAULT now() NOT NULL,
blocked_until timestamp with time zone,
requires_captcha_until timestamp with time zone,
last_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
@@ -1,5 +1,5 @@
-- Tables: Serviços / Prontuários
-- Gerado automaticamente em 2026-04-17T12:23:05.229Z
-- Gerado automaticamente em 2026-04-21T23:16:34.956Z
-- Total: 8
CREATE TABLE public.commitment_services (
@@ -1,5 +1,5 @@
-- Tables: Tenants / Multi-tenant
-- Gerado automaticamente em 2026-04-17T12:23:05.228Z
-- Gerado automaticamente em 2026-04-21T23:16:34.954Z
-- Total: 10
CREATE TABLE public.tenant_members (
+138 -2
View File
@@ -1,6 +1,142 @@
-- Views
-- Gerado automaticamente em 2026-04-17T12:23:05.233Z
-- Total: 27
-- Gerado automaticamente em 2026-04-21T23:16:34.958Z
-- Total: 29
CREATE VIEW public.audit_log_unified WITH (security_invoker='true') AS
SELECT ('audit:'::text || (al.id)::text) AS uid,
al.tenant_id,
al.user_id,
al.entity_type,
al.entity_id,
al.action,
CASE al.action
WHEN 'insert'::text THEN ('Criou '::text || al.entity_type)
WHEN 'update'::text THEN (('Alterou '::text || al.entity_type) || COALESCE(((' ('::text || array_to_string(al.changed_fields, ', '::text)) || ')'::text), ''::text))
WHEN 'delete'::text THEN ('Excluiu '::text || al.entity_type)
ELSE NULL::text
END AS description,
al.created_at AS occurred_at,
'audit_logs'::text AS source,
jsonb_build_object('old_values', al.old_values, 'new_values', al.new_values, 'changed_fields', al.changed_fields) AS details
FROM public.audit_logs al
UNION ALL
SELECT ('doc_access:'::text || (dal.id)::text) AS uid,
dal.tenant_id,
dal.user_id,
'document'::text AS entity_type,
(dal.documento_id)::text AS entity_id,
dal.acao AS action,
CASE dal.acao
WHEN 'visualizou'::text THEN 'Visualizou documento'::text
WHEN 'baixou'::text THEN 'Baixou documento'::text
WHEN 'imprimiu'::text THEN 'Imprimiu documento'::text
WHEN 'compartilhou'::text THEN 'Compartilhou documento'::text
WHEN 'assinou'::text THEN 'Assinou documento'::text
ELSE dal.acao
END AS description,
dal.acessado_em AS occurred_at,
'document_access_logs'::text AS source,
jsonb_build_object('ip', (dal.ip)::text, 'user_agent', dal.user_agent) AS details
FROM public.document_access_logs dal
UNION ALL
SELECT ('psh:'::text || (psh.id)::text) AS uid,
psh.tenant_id,
psh.alterado_por AS user_id,
'patient_status'::text AS entity_type,
(psh.patient_id)::text AS entity_id,
'status_change'::text AS action,
(((('Status do paciente: '::text || COALESCE(psh.status_anterior, ''::text)) || ''::text) || psh.status_novo) || COALESCE(((' ('::text || psh.motivo) || ')'::text), ''::text)) AS description,
psh.alterado_em AS occurred_at,
'patient_status_history'::text AS source,
jsonb_build_object('status_anterior', psh.status_anterior, 'status_novo', psh.status_novo, 'motivo', psh.motivo, 'encaminhado_para', psh.encaminhado_para, 'data_saida', psh.data_saida) AS details
FROM public.patient_status_history psh
UNION ALL
SELECT ('notif:'::text || (nl.id)::text) AS uid,
nl.tenant_id,
nl.owner_id AS user_id,
'notification'::text AS entity_type,
(nl.patient_id)::text AS entity_id,
nl.status AS action,
(((('Notificação '::text || nl.channel) || ' '::text) || nl.status) || COALESCE((' para '::text || nl.recipient_address), ''::text)) AS description,
nl.created_at AS occurred_at,
'notification_logs'::text AS source,
jsonb_build_object('channel', nl.channel, 'template_key', nl.template_key, 'status', nl.status, 'provider', nl.provider, 'failure_reason', nl.failure_reason) AS details
FROM public.notification_logs nl
UNION ALL
SELECT ('addon:'::text || (at.id)::text) AS uid,
at.tenant_id,
at.admin_user_id AS user_id,
'addon_transaction'::text AS entity_type,
(at.id)::text AS entity_id,
at.type AS action,
CASE at.type
WHEN 'purchase'::text THEN ((('Compra de '::text || at.amount) || ' créditos de '::text) || at.addon_type)
WHEN 'consumption'::text THEN ((('Consumo de '::text || abs(at.amount)) || ' crédito(s) '::text) || at.addon_type)
WHEN 'adjustment'::text THEN ('Ajuste de créditos '::text || at.addon_type)
WHEN 'refund'::text THEN ((('Reembolso de '::text || abs(at.amount)) || ' créditos '::text) || at.addon_type)
ELSE ((at.type || ' '::text) || at.addon_type)
END AS description,
at.created_at AS occurred_at,
'addon_transactions'::text AS source,
jsonb_build_object('addon_type', at.addon_type, 'amount', at.amount, 'balance_after', at.balance_after, 'price_cents', at.price_cents, 'payment_reference', at.payment_reference) AS details
FROM public.addon_transactions at;
CREATE VIEW public.conversation_threads WITH (security_invoker='true') AS
WITH base AS (
SELECT cm.id,
cm.tenant_id,
cm.patient_id,
cm.channel,
cm.body,
cm.direction,
cm.kanban_status,
cm.read_at,
cm.created_at,
CASE
WHEN (cm.direction = 'inbound'::text) THEN cm.from_number
ELSE cm.to_number
END AS contact_number,
COALESCE((cm.patient_id)::text, ('anon:'::text || COALESCE(
CASE
WHEN (cm.direction = 'inbound'::text) THEN cm.from_number
ELSE cm.to_number
END, 'unknown'::text))) AS thread_key
FROM public.conversation_messages cm
), latest AS (
SELECT DISTINCT ON (base.tenant_id, base.thread_key) base.tenant_id,
base.thread_key,
base.patient_id,
base.channel,
base.contact_number,
base.body AS last_message_body,
base.direction AS last_message_direction,
base.kanban_status,
base.created_at AS last_message_at
FROM base
ORDER BY base.tenant_id, base.thread_key, base.created_at DESC
), counts AS (
SELECT base.tenant_id,
base.thread_key,
count(*) AS message_count,
count(*) FILTER (WHERE ((base.direction = 'inbound'::text) AND (base.read_at IS NULL))) AS unread_count
FROM base
GROUP BY base.tenant_id, base.thread_key
)
SELECT l.tenant_id,
l.thread_key,
l.patient_id,
p.nome_completo AS patient_name,
l.contact_number,
l.channel,
c.message_count,
c.unread_count,
l.last_message_at,
l.last_message_body,
l.last_message_direction,
l.kanban_status
FROM ((latest l
JOIN counts c ON (((c.tenant_id = l.tenant_id) AND (c.thread_key = l.thread_key))))
LEFT JOIN public.patients p ON ((p.id = l.patient_id)));
CREATE VIEW public.current_tenant_id AS
SELECT current_setting('request.jwt.claim.tenant_id'::text, true) AS current_setting;
+184 -2
View File
@@ -1,6 +1,6 @@
-- Indexes
-- Gerado automaticamente em 2026-04-17T12:23:05.235Z
-- Total: 270
-- Gerado automaticamente em 2026-04-21T23:16:34.961Z
-- Total: 361
CREATE INDEX agenda_bloqueios_owner_data_idx ON public.agenda_bloqueios USING btree (owner_id, data_inicio, data_fim);
@@ -166,10 +166,126 @@ CREATE INDEX idx_addon_tx_type ON public.addon_transactions USING btree (type);
CREATE INDEX idx_agenda_eventos_determined_commitment_id ON public.agenda_eventos USING btree (determined_commitment_id);
CREATE INDEX idx_agenda_eventos_titulo_custom_trgm ON public.agenda_eventos USING gin (titulo_custom public.gin_trgm_ops) WHERE (titulo_custom IS NOT NULL);
CREATE INDEX idx_agenda_eventos_titulo_trgm ON public.agenda_eventos USING gin (titulo public.gin_trgm_ops) WHERE (titulo IS NOT NULL);
CREATE INDEX idx_agenda_excecoes_owner_data ON public.agenda_excecoes USING btree (owner_id, data);
CREATE INDEX idx_agenda_slots_regras_owner_dia ON public.agenda_slots_regras USING btree (owner_id, dia_semana);
CREATE INDEX idx_audit_logs_changed_fields ON public.audit_logs USING gin (changed_fields);
CREATE INDEX idx_audit_logs_entity ON public.audit_logs USING btree (entity_type, entity_id);
CREATE INDEX idx_audit_logs_tenant_created ON public.audit_logs USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_audit_logs_user_created ON public.audit_logs USING btree (user_id, created_at DESC) WHERE (user_id IS NOT NULL);
CREATE INDEX idx_autoreply_log_cooldown ON public.conversation_autoreply_log USING btree (tenant_id, thread_key, sent_at DESC);
CREATE INDEX idx_contact_email_types_tenant ON public.contact_email_types USING btree (tenant_id, "position");
CREATE INDEX idx_contact_emails_email ON public.contact_emails USING btree (tenant_id, email);
CREATE INDEX idx_contact_emails_entity ON public.contact_emails USING btree (tenant_id, entity_type, entity_id, "position");
CREATE INDEX idx_contact_phones_entity ON public.contact_phones USING btree (tenant_id, entity_type, entity_id, "position");
CREATE INDEX idx_contact_phones_number ON public.contact_phones USING btree (tenant_id, number);
CREATE INDEX idx_contact_types_tenant ON public.contact_types USING btree (tenant_id, "position");
CREATE INDEX idx_conv_msg_delivery_status ON public.conversation_messages USING btree (tenant_id, delivery_status) WHERE (direction = 'outbound'::text);
CREATE INDEX idx_conv_msg_from_number ON public.conversation_messages USING btree (tenant_id, from_number);
CREATE INDEX idx_conv_msg_kanban ON public.conversation_messages USING btree (tenant_id, kanban_status, priority DESC, created_at DESC);
CREATE INDEX idx_conv_msg_patient ON public.conversation_messages USING btree (patient_id, created_at DESC) WHERE (patient_id IS NOT NULL);
CREATE INDEX idx_conv_msg_provider_msg_id ON public.conversation_messages USING btree (provider_message_id) WHERE (provider_message_id IS NOT NULL);
CREATE INDEX idx_conv_msg_tenant_created ON public.conversation_messages USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_conv_notes_created_by ON public.conversation_notes USING btree (created_by, created_at DESC) WHERE (deleted_at IS NULL);
CREATE INDEX idx_conv_notes_patient ON public.conversation_notes USING btree (patient_id, created_at DESC) WHERE ((deleted_at IS NULL) AND (patient_id IS NOT NULL));
CREATE INDEX idx_conv_notes_tenant_thread ON public.conversation_notes USING btree (tenant_id, thread_key, created_at DESC) WHERE (deleted_at IS NULL);
CREATE INDEX idx_conv_optout_kw_tenant ON public.conversation_optout_keywords USING btree (tenant_id) WHERE (enabled = true);
CREATE INDEX idx_conv_optouts_patient ON public.conversation_optouts USING btree (patient_id) WHERE (patient_id IS NOT NULL);
CREATE INDEX idx_conv_optouts_tenant_phone ON public.conversation_optouts USING btree (tenant_id, phone);
CREATE INDEX idx_conv_tags_tenant ON public.conversation_tags USING btree (tenant_id, "position");
CREATE INDEX idx_conv_thread_tags_tag ON public.conversation_thread_tags USING btree (tag_id);
CREATE INDEX idx_conv_thread_tags_tenant_thread ON public.conversation_thread_tags USING btree (tenant_id, thread_key);
CREATE INDEX idx_dev_auditoria_items_categoria ON public.dev_auditoria_items USING btree (categoria);
CREATE INDEX idx_dev_auditoria_items_ordem ON public.dev_auditoria_items USING btree (ordem);
CREATE INDEX idx_dev_auditoria_items_severidade ON public.dev_auditoria_items USING btree (severidade);
CREATE INDEX idx_dev_auditoria_items_status ON public.dev_auditoria_items USING btree (status);
CREATE INDEX idx_dev_ccs_comp ON public.dev_comparison_competitor_status USING btree (competitor_id);
CREATE INDEX idx_dev_ccs_comparison ON public.dev_comparison_competitor_status USING btree (comparison_id);
CREATE INDEX idx_dev_comparison_matrix_dominio ON public.dev_comparison_matrix USING btree (dominio);
CREATE INDEX idx_dev_comparison_matrix_status ON public.dev_comparison_matrix USING btree (nosso_status);
CREATE INDEX idx_dev_competitor_features_cat ON public.dev_competitor_features USING btree (categoria);
CREATE INDEX idx_dev_competitor_features_comp ON public.dev_competitor_features USING btree (competitor_id);
CREATE INDEX idx_dev_competitor_features_destaque ON public.dev_competitor_features USING btree (destaque);
CREATE INDEX idx_dev_competitor_features_ordem ON public.dev_competitor_features USING btree (competitor_id, ordem);
CREATE INDEX idx_dev_competitors_ativo ON public.dev_competitors USING btree (ativo);
CREATE INDEX idx_dev_competitors_pais ON public.dev_competitors USING btree (pais);
CREATE INDEX idx_dev_generation_log_created ON public.dev_generation_log USING btree (created_at DESC);
CREATE INDEX idx_dev_generation_log_tipo ON public.dev_generation_log USING btree (tipo);
CREATE INDEX idx_dev_roadmap_items_ordem ON public.dev_roadmap_items USING btree (phase_id, ordem);
CREATE INDEX idx_dev_roadmap_items_phase ON public.dev_roadmap_items USING btree (phase_id);
CREATE INDEX idx_dev_roadmap_items_prior ON public.dev_roadmap_items USING btree (prioridade);
CREATE INDEX idx_dev_roadmap_items_status ON public.dev_roadmap_items USING btree (status);
CREATE INDEX idx_dev_roadmap_phases_ordem ON public.dev_roadmap_phases USING btree (ordem);
CREATE INDEX idx_dev_roadmap_phases_status ON public.dev_roadmap_phases USING btree (status);
CREATE INDEX idx_dev_test_items_area ON public.dev_test_items USING btree (area);
CREATE INDEX idx_dev_test_items_ordem ON public.dev_test_items USING btree (area, ordem);
CREATE INDEX idx_dev_test_items_status ON public.dev_test_items USING btree (status);
CREATE INDEX idx_dev_verificacoes_area ON public.dev_verificacoes_items USING btree (area);
CREATE INDEX idx_dev_verificacoes_ordem ON public.dev_verificacoes_items USING btree (area, ordem);
CREATE INDEX idx_dev_verificacoes_severidade ON public.dev_verificacoes_items USING btree (severidade);
CREATE INDEX idx_dev_verificacoes_status ON public.dev_verificacoes_items USING btree (status);
CREATE INDEX idx_documents_content_sha256 ON public.documents USING btree (content_sha256) WHERE (content_sha256 IS NOT NULL);
CREATE INDEX idx_email_templates_global_domain ON public.email_templates_global USING btree (domain) WHERE (is_active = true);
CREATE INDEX idx_email_templates_global_key ON public.email_templates_global USING btree (key) WHERE (is_active = true);
@@ -178,6 +294,8 @@ CREATE INDEX idx_email_templates_tenant_lookup ON public.email_templates_tenant
CREATE INDEX idx_email_templates_tenant_owner ON public.email_templates_tenant USING btree (owner_id, template_key) WHERE ((enabled = true) AND (owner_id IS NOT NULL));
CREATE INDEX idx_features_is_active ON public.features USING btree (is_active) WHERE (is_active = false);
CREATE INDEX idx_financial_categories_user_id ON public.financial_categories USING btree (user_id);
CREATE INDEX idx_financial_records_active ON public.financial_records USING btree (owner_id, paid_at DESC) WHERE (deleted_at IS NULL);
@@ -216,6 +334,8 @@ CREATE INDEX idx_intakes_owner_status_created ON public.patient_intake_requests
CREATE INDEX idx_intakes_status_created ON public.patient_intake_requests USING btree (status, created_at DESC);
CREATE INDEX idx_mc_expires ON public.math_challenges USING btree (expires_at);
CREATE INDEX idx_notice_dismissals_user ON public.notice_dismissals USING btree (user_id, notice_id);
CREATE INDEX idx_notif_channels_owner_active ON public.notification_channels USING btree (owner_id, channel) WHERE ((is_active = true) AND (deleted_at IS NULL));
@@ -270,12 +390,28 @@ CREATE INDEX idx_patient_groups_owner ON public.patient_groups USING btree (owne
CREATE INDEX idx_patient_groups_owner_system_nome ON public.patient_groups USING btree (owner_id, is_system, nome);
CREATE INDEX idx_patient_intake_requests_nome_trgm ON public.patient_intake_requests USING gin (nome_completo public.gin_trgm_ops) WHERE (status = 'new'::text);
CREATE INDEX idx_patient_invite_attempts_created ON public.patient_invite_attempts USING btree (created_at DESC);
CREATE INDEX idx_patient_invite_attempts_ok ON public.patient_invite_attempts USING btree (ok) WHERE (ok = false);
CREATE INDEX idx_patient_invite_attempts_owner ON public.patient_invite_attempts USING btree (owner_id);
CREATE INDEX idx_patient_invite_attempts_token ON public.patient_invite_attempts USING btree (token);
CREATE INDEX idx_patient_tags_owner ON public.patient_tags USING btree (owner_id);
CREATE INDEX idx_patients_cpf_trgm ON public.patients USING gin (cpf public.gin_trgm_ops) WHERE (cpf IS NOT NULL);
CREATE INDEX idx_patients_created_at ON public.patients USING btree (created_at DESC);
CREATE INDEX idx_patients_email_trgm ON public.patients USING gin (email_principal public.gin_trgm_ops) WHERE (email_principal IS NOT NULL);
CREATE INDEX idx_patients_last_attended ON public.patients USING btree (last_attended_at DESC);
CREATE INDEX idx_patients_nome_trgm ON public.patients USING gin (nome_completo public.gin_trgm_ops);
CREATE INDEX idx_patients_origem ON public.patients USING btree (tenant_id, origem) WHERE (origem IS NOT NULL);
CREATE INDEX idx_patients_owner_email_principal ON public.patients USING btree (owner_id, email_principal);
@@ -304,6 +440,12 @@ CREATE INDEX idx_ppt_patient ON public.patient_patient_tag USING btree (patient_
CREATE INDEX idx_ppt_tag ON public.patient_patient_tag USING btree (tag_id);
CREATE INDEX idx_psa_endpoint_created ON public.public_submission_attempts USING btree (endpoint, created_at DESC);
CREATE INDEX idx_psa_failed ON public.public_submission_attempts USING btree (created_at DESC) WHERE (success = false);
CREATE INDEX idx_psa_ip_hash_created ON public.public_submission_attempts USING btree (ip_hash, created_at DESC) WHERE (ip_hash IS NOT NULL);
CREATE INDEX idx_psh_patient ON public.patient_status_history USING btree (patient_id, alterado_em DESC);
CREATE INDEX idx_psh_tenant ON public.patient_status_history USING btree (tenant_id, alterado_em DESC);
@@ -314,8 +456,16 @@ CREATE INDEX idx_pt_patient_ocorrido ON public.patient_timeline USING btree (pat
CREATE INDEX idx_pt_tenant ON public.patient_timeline USING btree (tenant_id, ocorrido_em DESC);
CREATE INDEX idx_services_name_trgm ON public.services USING gin (name public.gin_trgm_ops);
CREATE INDEX idx_session_reminder_tenant_sent ON public.session_reminder_logs USING btree (tenant_id, sent_at DESC);
CREATE INDEX idx_slots_bloq_owner_dia ON public.agenda_slots_bloqueados_semanais USING btree (owner_id, dia_semana);
CREATE INDEX idx_srl_blocked_until ON public.submission_rate_limits USING btree (blocked_until) WHERE (blocked_until IS NOT NULL);
CREATE INDEX idx_srl_endpoint ON public.submission_rate_limits USING btree (endpoint, last_attempt_at DESC);
CREATE INDEX idx_subscription_intents_plan_interval ON public.subscription_intents_legacy USING btree (plan_key, "interval");
CREATE INDEX idx_subscription_intents_status ON public.subscription_intents_legacy USING btree (status);
@@ -344,6 +494,18 @@ CREATE INDEX idx_twilio_usage_tenant_period ON public.twilio_subaccount_usage US
CREATE UNIQUE INDEX idx_twilio_usage_unique_period ON public.twilio_subaccount_usage USING btree (channel_id, period_start, period_end);
CREATE INDEX idx_wa_credit_packages_active ON public.whatsapp_credit_packages USING btree (is_active, "position", price_brl) WHERE (is_active = true);
CREATE INDEX idx_wa_credit_purchases_asaas_payment ON public.whatsapp_credit_purchases USING btree (asaas_payment_id) WHERE (asaas_payment_id IS NOT NULL);
CREATE INDEX idx_wa_credit_purchases_status ON public.whatsapp_credit_purchases USING btree (status, created_at DESC);
CREATE INDEX idx_wa_credit_purchases_tenant ON public.whatsapp_credit_purchases USING btree (tenant_id, created_at DESC);
CREATE INDEX idx_wa_credits_tx_kind ON public.whatsapp_credits_transactions USING btree (tenant_id, kind, created_at DESC);
CREATE INDEX idx_wa_credits_tx_tenant_created ON public.whatsapp_credits_transactions USING btree (tenant_id, created_at DESC);
CREATE INDEX insurance_plans_owner_idx ON public.insurance_plans USING btree (owner_id);
CREATE INDEX insurance_plans_tenant_idx ON public.insurance_plans USING btree (tenant_id);
@@ -522,6 +684,24 @@ CREATE INDEX tenant_modules_owner_idx ON public.tenant_modules USING btree (owne
CREATE UNIQUE INDEX unique_member_per_tenant ON public.tenant_members USING btree (tenant_id, user_id);
CREATE UNIQUE INDEX uq_contact_email_types_system_slug ON public.contact_email_types USING btree (slug) WHERE (tenant_id IS NULL);
CREATE UNIQUE INDEX uq_contact_email_types_tenant_slug ON public.contact_email_types USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
CREATE UNIQUE INDEX uq_contact_emails_primary ON public.contact_emails USING btree (entity_type, entity_id) WHERE (is_primary = true);
CREATE UNIQUE INDEX uq_contact_phones_primary ON public.contact_phones USING btree (entity_type, entity_id) WHERE (is_primary = true);
CREATE UNIQUE INDEX uq_contact_types_system_slug ON public.contact_types USING btree (slug) WHERE (tenant_id IS NULL);
CREATE UNIQUE INDEX uq_contact_types_tenant_slug ON public.contact_types USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
CREATE UNIQUE INDEX uq_conv_optouts_active ON public.conversation_optouts USING btree (tenant_id, phone) WHERE (opted_back_in_at IS NULL);
CREATE UNIQUE INDEX uq_conv_tags_system_slug ON public.conversation_tags USING btree (slug) WHERE (tenant_id IS NULL);
CREATE UNIQUE INDEX uq_conv_tags_tenant_slug ON public.conversation_tags USING btree (tenant_id, slug) WHERE (tenant_id IS NOT NULL);
CREATE UNIQUE INDEX uq_patient_contacts_primario ON public.patient_contacts USING btree (patient_id) WHERE ((is_primario = true) AND (ativo = true));
CREATE UNIQUE INDEX uq_patients_tenant_user ON public.patients USING btree (tenant_id, user_id) WHERE (user_id IS NOT NULL);
@@ -530,6 +710,8 @@ CREATE UNIQUE INDEX uq_plan_price_active ON public.plan_prices USING btree (plan
CREATE UNIQUE INDEX uq_plan_prices_active ON public.plan_prices USING btree (plan_id, "interval") WHERE (is_active = true);
CREATE UNIQUE INDEX uq_session_reminder_event_type ON public.session_reminder_logs USING btree (event_id, reminder_type);
CREATE UNIQUE INDEX uq_subscriptions_active_by_tenant ON public.subscriptions USING btree (tenant_id) WHERE ((tenant_id IS NOT NULL) AND (status = 'active'::text));
CREATE UNIQUE INDEX uq_subscriptions_active_personal_by_user ON public.subscriptions USING btree (user_id) WHERE ((tenant_id IS NULL) AND (status = 'active'::text));
@@ -1,6 +1,6 @@
-- Constraints (PK, FK, UNIQUE, CHECK)
-- Gerado automaticamente em 2026-04-17T12:23:05.237Z
-- Total: 275
-- Gerado automaticamente em 2026-04-21T23:16:34.963Z
-- Total: 353
ALTER TABLE ONLY public._db_migrations
ADD CONSTRAINT _db_migrations_filename_key UNIQUE (filename);
@@ -65,6 +65,9 @@ ALTER TABLE ONLY public.agendador_configuracoes
ALTER TABLE ONLY public.agendador_solicitacoes
ADD CONSTRAINT agendador_solicitacoes_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.audit_logs
ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.billing_contracts
ADD CONSTRAINT billing_contracts_pkey PRIMARY KEY (id);
@@ -80,6 +83,42 @@ ALTER TABLE ONLY public.company_profiles
ALTER TABLE ONLY public.company_profiles
ADD CONSTRAINT company_profiles_tenant_id_key UNIQUE (tenant_id);
ALTER TABLE ONLY public.contact_email_types
ADD CONSTRAINT contact_email_types_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contact_emails
ADD CONSTRAINT contact_emails_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contact_phones
ADD CONSTRAINT contact_phones_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_optout_keywords
ADD CONSTRAINT conversation_optout_keywords_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_pkey PRIMARY KEY (tenant_id, thread_key, tag_id);
ALTER TABLE ONLY public.determined_commitment_fields
ADD CONSTRAINT determined_commitment_fields_pkey PRIMARY KEY (id);
@@ -89,12 +128,51 @@ ALTER TABLE ONLY public.determined_commitments
ALTER TABLE ONLY public.determined_commitments
ADD CONSTRAINT determined_commitments_tenant_native_key_uq UNIQUE (tenant_id, native_key);
ALTER TABLE ONLY public.dev_auditoria_items
ADD CONSTRAINT dev_auditoria_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_statu_comparison_id_competitor_id_key UNIQUE (comparison_id, competitor_id);
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_status_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_comparison_matrix
ADD CONSTRAINT dev_comparison_matrix_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_competitor_features
ADD CONSTRAINT dev_competitor_features_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_competitors
ADD CONSTRAINT dev_competitors_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_competitors
ADD CONSTRAINT dev_competitors_slug_key UNIQUE (slug);
ALTER TABLE ONLY public.dev_generation_log
ADD CONSTRAINT dev_generation_log_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_roadmap_items
ADD CONSTRAINT dev_roadmap_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_roadmap_phases
ADD CONSTRAINT dev_roadmap_phases_numero_key UNIQUE (numero);
ALTER TABLE ONLY public.dev_roadmap_phases
ADD CONSTRAINT dev_roadmap_phases_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_test_items
ADD CONSTRAINT dev_test_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_user_credentials
ADD CONSTRAINT dev_user_credentials_email_key UNIQUE (email);
ALTER TABLE ONLY public.dev_user_credentials
ADD CONSTRAINT dev_user_credentials_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.dev_verificacoes_items
ADD CONSTRAINT dev_verificacoes_items_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.document_access_logs
ADD CONSTRAINT document_access_logs_pkey PRIMARY KEY (id);
@@ -170,6 +248,9 @@ ALTER TABLE ONLY public.insurance_plans
ALTER TABLE ONLY public.login_carousel_slides
ADD CONSTRAINT login_carousel_slides_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.math_challenges
ADD CONSTRAINT math_challenges_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.medicos
ADD CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm);
@@ -230,6 +311,9 @@ ALTER TABLE ONLY public.patient_groups
ALTER TABLE ONLY public.patient_intake_requests
ADD CONSTRAINT patient_intake_requests_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.patient_invite_attempts
ADD CONSTRAINT patient_invite_attempts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.patient_invites
ADD CONSTRAINT patient_invites_pkey PRIMARY KEY (id);
@@ -290,6 +374,9 @@ ALTER TABLE ONLY public.professional_pricing
ALTER TABLE ONLY public.profiles
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.public_submission_attempts
ADD CONSTRAINT public_submission_attempts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.recurrence_exceptions
ADD CONSTRAINT recurrence_exceptions_pkey PRIMARY KEY (id);
@@ -320,9 +407,24 @@ ALTER TABLE ONLY public.saas_faq_itens
ALTER TABLE ONLY public.saas_faq
ADD CONSTRAINT saas_faq_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.saas_security_config
ADD CONSTRAINT saas_security_config_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.saas_twilio_config
ADD CONSTRAINT saas_twilio_config_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.services
ADD CONSTRAINT services_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.session_reminder_settings
ADD CONSTRAINT session_reminder_settings_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.submission_rate_limits
ADD CONSTRAINT submission_rate_limits_pkey PRIMARY KEY (ip_hash, endpoint);
ALTER TABLE ONLY public.subscription_events
ADD CONSTRAINT subscription_events_pkey PRIMARY KEY (id);
@@ -398,6 +500,18 @@ ALTER TABLE ONLY public.notification_templates
ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY public.whatsapp_credit_packages
ADD CONSTRAINT whatsapp_credit_packages_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.whatsapp_credits_balance
ADD CONSTRAINT whatsapp_credits_balance_pkey PRIMARY KEY (tenant_id);
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.addon_credits
ADD CONSTRAINT addon_credits_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id);
@@ -476,6 +590,12 @@ ALTER TABLE ONLY public.agendador_solicitacoes
ALTER TABLE ONLY public.agendador_solicitacoes
ADD CONSTRAINT agendador_sol_tenant_fk FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.audit_logs
ADD CONSTRAINT audit_logs_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.audit_logs
ADD CONSTRAINT audit_logs_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.billing_contracts
ADD CONSTRAINT billing_contracts_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
@@ -500,6 +620,69 @@ ALTER TABLE ONLY public.commitment_time_logs
ALTER TABLE ONLY public.company_profiles
ADD CONSTRAINT company_profiles_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_email_types
ADD CONSTRAINT contact_email_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_emails
ADD CONSTRAINT contact_emails_contact_email_type_id_fkey FOREIGN KEY (contact_email_type_id) REFERENCES public.contact_email_types(id) ON DELETE RESTRICT;
ALTER TABLE ONLY public.contact_emails
ADD CONSTRAINT contact_emails_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_phones
ADD CONSTRAINT contact_phones_contact_type_id_fkey FOREIGN KEY (contact_type_id) REFERENCES public.contact_types(id) ON DELETE RESTRICT;
ALTER TABLE ONLY public.contact_phones
ADD CONSTRAINT contact_phones_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contact_types
ADD CONSTRAINT contact_types_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_autoreply_log
ADD CONSTRAINT conversation_autoreply_log_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_autoreply_settings
ADD CONSTRAINT conversation_autoreply_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_messages
ADD CONSTRAINT conversation_messages_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_notes
ADD CONSTRAINT conversation_notes_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_optout_keywords
ADD CONSTRAINT conversation_optout_keywords_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_blocked_by_fkey FOREIGN KEY (blocked_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_patient_id_fkey FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_optouts
ADD CONSTRAINT conversation_optouts_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_tags
ADD CONSTRAINT conversation_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_tag_id_fkey FOREIGN KEY (tag_id) REFERENCES public.conversation_tags(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_tagged_by_fkey FOREIGN KEY (tagged_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.conversation_thread_tags
ADD CONSTRAINT conversation_thread_tags_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.determined_commitment_fields
ADD CONSTRAINT determined_commitment_fields_commitment_id_fkey FOREIGN KEY (commitment_id) REFERENCES public.determined_commitments(id) ON DELETE CASCADE;
@@ -509,6 +692,21 @@ ALTER TABLE ONLY public.determined_commitment_fields
ALTER TABLE ONLY public.determined_commitments
ADD CONSTRAINT determined_commitments_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_status_comparison_id_fkey FOREIGN KEY (comparison_id) REFERENCES public.dev_comparison_matrix(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_comparison_competitor_status
ADD CONSTRAINT dev_comparison_competitor_status_competitor_id_fkey FOREIGN KEY (competitor_id) REFERENCES public.dev_competitors(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_competitor_features
ADD CONSTRAINT dev_competitor_features_competitor_id_fkey FOREIGN KEY (competitor_id) REFERENCES public.dev_competitors(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_roadmap_items
ADD CONSTRAINT dev_roadmap_items_phase_id_fkey FOREIGN KEY (phase_id) REFERENCES public.dev_roadmap_phases(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.dev_verificacoes_items
ADD CONSTRAINT dev_verificacoes_items_auditoria_item_id_fkey FOREIGN KEY (auditoria_item_id) REFERENCES public.dev_auditoria_items(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.document_access_logs
ADD CONSTRAINT document_access_logs_documento_id_fkey FOREIGN KEY (documento_id) REFERENCES public.documents(id) ON DELETE CASCADE;
@@ -752,6 +950,18 @@ ALTER TABLE ONLY public.saas_faq_itens
ALTER TABLE ONLY public.services
ADD CONSTRAINT services_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_conversation_message_id_fkey FOREIGN KEY (conversation_message_id) REFERENCES public.conversation_messages(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_event_id_fkey FOREIGN KEY (event_id) REFERENCES public.agenda_eventos(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.session_reminder_logs
ADD CONSTRAINT session_reminder_logs_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.session_reminder_settings
ADD CONSTRAINT session_reminder_settings_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.subscription_intents_personal
ADD CONSTRAINT sint_personal_subscription_id_fkey FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id) ON DELETE SET NULL;
@@ -826,3 +1036,27 @@ ALTER TABLE ONLY public.twilio_subaccount_usage
ALTER TABLE ONLY public.user_settings
ADD CONSTRAINT user_settings_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_package_id_fkey FOREIGN KEY (package_id) REFERENCES public.whatsapp_credit_packages(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credit_purchases
ADD CONSTRAINT whatsapp_credit_purchases_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credits_balance
ADD CONSTRAINT whatsapp_credits_balance_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_admin_id_fkey FOREIGN KEY (admin_id) REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_conversation_message_id_fkey FOREIGN KEY (conversation_message_id) REFERENCES public.conversation_messages(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_purchase_id_fkey FOREIGN KEY (purchase_id) REFERENCES public.whatsapp_credit_purchases(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.whatsapp_credits_transactions
ADD CONSTRAINT whatsapp_credits_transactions_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE;
+64 -2
View File
@@ -1,6 +1,6 @@
-- Triggers
-- Gerado automaticamente em 2026-04-17T12:23:05.238Z
-- Total: 80
-- Gerado automaticamente em 2026-04-21T23:16:34.965Z
-- Total: 111
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
@@ -40,6 +40,16 @@ CREATE TRIGGER trg_agenda_eventos_busy_mirror_upd AFTER UPDATE ON public.agenda_
CREATE TRIGGER trg_agenda_regras_semanais_no_overlap BEFORE INSERT OR UPDATE ON public.agenda_regras_semanais FOR EACH ROW EXECUTE FUNCTION public.fn_agenda_regras_semanais_no_overlap();
CREATE TRIGGER trg_audit_agenda_eventos AFTER INSERT OR DELETE OR UPDATE ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_documents AFTER INSERT OR DELETE OR UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_financial_records AFTER INSERT OR DELETE OR UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_patients AFTER INSERT OR DELETE OR UPDATE ON public.patients FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_audit_tenant_members AFTER INSERT OR DELETE OR UPDATE ON public.tenant_members FOR EACH ROW EXECUTE FUNCTION public.log_audit_change();
CREATE TRIGGER trg_auto_financial_from_session AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.auto_create_financial_record_from_session();
CREATE TRIGGER trg_cancel_notifs_on_opt_out AFTER UPDATE ON public.notification_preferences FOR EACH ROW EXECUTE FUNCTION public.cancel_notifications_on_opt_out();
@@ -48,10 +58,50 @@ CREATE TRIGGER trg_cancel_notifs_on_session_cancel AFTER UPDATE ON public.agenda
CREATE TRIGGER trg_company_profiles_updated_at BEFORE UPDATE ON public.company_profiles FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_email_types_updated_at BEFORE UPDATE ON public.contact_email_types FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_emails_sync_legacy AFTER INSERT OR DELETE OR UPDATE ON public.contact_emails FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_email_fields();
CREATE TRIGGER trg_contact_emails_updated_at BEFORE UPDATE ON public.contact_emails FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_phones_sync_legacy AFTER INSERT OR DELETE OR UPDATE ON public.contact_phones FOR EACH ROW EXECUTE FUNCTION public.sync_legacy_phone_fields();
CREATE TRIGGER trg_contact_phones_updated_at BEFORE UPDATE ON public.contact_phones FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_contact_types_updated_at BEFORE UPDATE ON public.contact_types FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_autoreply_settings_updated_at BEFORE UPDATE ON public.conversation_autoreply_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_messages_updated_at BEFORE UPDATE ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_notes_updated_at BEFORE UPDATE ON public.conversation_notes FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_optouts_updated_at BEFORE UPDATE ON public.conversation_optouts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_conv_tags_updated_at BEFORE UPDATE ON public.conversation_tags FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_determined_commitment_fields_updated_at BEFORE UPDATE ON public.determined_commitment_fields FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_determined_commitments_updated_at BEFORE UPDATE ON public.determined_commitments FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_dev_auditoria_items_updated_at BEFORE UPDATE ON public.dev_auditoria_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_ccs_updated_at BEFORE UPDATE ON public.dev_comparison_competitor_status FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_comparison_matrix_updated_at BEFORE UPDATE ON public.dev_comparison_matrix FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_competitor_features_updated_at BEFORE UPDATE ON public.dev_competitor_features FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_competitors_updated_at BEFORE UPDATE ON public.dev_competitors FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_roadmap_items_updated_at BEFORE UPDATE ON public.dev_roadmap_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_roadmap_phases_updated_at BEFORE UPDATE ON public.dev_roadmap_phases FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_test_items_updated_at BEFORE UPDATE ON public.dev_test_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_dev_verificacoes_updated_at BEFORE UPDATE ON public.dev_verificacoes_items FOR EACH ROW EXECUTE FUNCTION public.dev_set_updated_at();
CREATE TRIGGER trg_documents_timeline_insert AFTER INSERT ON public.documents FOR EACH ROW EXECUTE FUNCTION public.fn_documents_timeline_insert();
CREATE TRIGGER trg_documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
@@ -68,10 +118,14 @@ CREATE TRIGGER trg_email_templates_global_updated_at BEFORE UPDATE ON public.ema
CREATE TRIGGER trg_email_templates_tenant_updated_at BEFORE UPDATE ON public.email_templates_tenant FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_fanout_inbound_to_notifications AFTER INSERT ON public.conversation_messages FOR EACH ROW EXECUTE FUNCTION public.fanout_inbound_message_to_notifications();
CREATE TRIGGER trg_financial_exceptions_updated_at BEFORE UPDATE ON public.financial_exceptions FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_financial_records_auto_overdue BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.trg_fn_financial_records_auto_overdue();
CREATE TRIGGER trg_financial_records_inject_tenant BEFORE INSERT ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.financial_records_inject_tenant();
CREATE TRIGGER trg_financial_records_updated_at BEFORE UPDATE ON public.financial_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_global_notices_updated_at BEFORE UPDATE ON public.global_notices FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
@@ -138,6 +192,8 @@ CREATE TRIGGER trg_psc_updated_at BEFORE UPDATE ON public.patient_support_contac
CREATE TRIGGER trg_services_updated_at BEFORE UPDATE ON public.services FOR EACH ROW EXECUTE FUNCTION public.set_services_updated_at();
CREATE TRIGGER trg_session_reminder_settings_updated_at BEFORE UPDATE ON public.session_reminder_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_subscription_intents_view_insert INSTEAD OF INSERT ON public.subscription_intents FOR EACH ROW EXECUTE FUNCTION public.subscription_intents_view_insert();
CREATE TRIGGER trg_subscriptions_validate_scope BEFORE INSERT OR UPDATE ON public.subscriptions FOR EACH ROW EXECUTE FUNCTION public.subscriptions_validate_scope();
@@ -152,6 +208,12 @@ CREATE TRIGGER trg_therapist_payouts_updated_at BEFORE UPDATE ON public.therapis
CREATE TRIGGER trg_user_settings_updated_at BEFORE UPDATE ON public.user_settings FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_wa_credit_packages_updated_at BEFORE UPDATE ON public.whatsapp_credit_packages FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_wa_credit_purchases_updated_at BEFORE UPDATE ON public.whatsapp_credit_purchases FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER trg_wa_credits_balance_updated_at BEFORE UPDATE ON public.whatsapp_credits_balance FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
CREATE TRIGGER tr_check_filters BEFORE INSERT OR UPDATE ON realtime.subscription FOR EACH ROW EXECUTE FUNCTION realtime.subscription_check_filters();
CREATE TRIGGER enforce_bucket_name_length_trigger BEFORE INSERT OR UPDATE OF name ON storage.buckets FOR EACH ROW EXECUTE FUNCTION storage.enforce_bucket_name_length();
+472 -77
View File
@@ -1,7 +1,7 @@
-- RLS Policies
-- Gerado automaticamente em 2026-04-17T12:23:05.240Z
-- Enable RLS: 88 tabelas
-- Policies: 252
-- Gerado automaticamente em 2026-04-21T23:16:34.967Z
-- Enable RLS: 131 tabelas
-- Policies: 344
-- Enable RLS
ALTER TABLE public.addon_credits ENABLE ROW LEVEL SECURITY;
@@ -17,13 +17,36 @@ ALTER TABLE public.agenda_slots_bloqueados_semanais ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.agenda_slots_regras ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.agendador_configuracoes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.agendador_solicitacoes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.billing_contracts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.commitment_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.commitment_time_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.company_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_email_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_emails ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_phones ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contact_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_autoreply_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optout_keywords ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_optouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.conversation_thread_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.determined_commitment_fields ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.determined_commitments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_auditoria_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_comparison_competitor_status ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_comparison_matrix ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_competitor_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_competitors ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_generation_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_roadmap_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_roadmap_phases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_test_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_user_credentials ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.dev_verificacoes_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
@@ -43,6 +66,7 @@ ALTER TABLE public.global_notices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.insurance_plan_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.insurance_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.login_carousel_slides ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.math_challenges ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.module_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.modules ENABLE ROW LEVEL SECURITY;
@@ -60,6 +84,7 @@ ALTER TABLE public.patient_discounts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_group_patient ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_intake_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_invite_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_patient_tag ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patient_status_history ENABLE ROW LEVEL SECURITY;
@@ -69,9 +94,13 @@ ALTER TABLE public.patient_timeline ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.patients ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plan_public_bullets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.professional_pricing ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.public_submission_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recurrence_exceptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recurrence_rule_services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recurrence_rules ENABLE ROW LEVEL SECURITY;
@@ -80,11 +109,21 @@ ALTER TABLE public.saas_doc_votos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_faq ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_faq_itens ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_security_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.saas_twilio_config ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.services ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.session_reminder_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.submission_rate_limits ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_legacy ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_personal ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscription_intents_tenant ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.support_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_feature_exceptions_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_invites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenant_modules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tenants ENABLE ROW LEVEL SECURITY;
@@ -92,6 +131,10 @@ ALTER TABLE public.therapist_payout_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.therapist_payouts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_packages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credit_purchases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_balance ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.whatsapp_credits_transactions ENABLE ROW LEVEL SECURITY;
-- Policies
CREATE POLICY addon_credits_admin_select ON public.addon_credits FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
@@ -115,8 +158,8 @@ CREATE POLICY addon_products_admin_all ON public.addon_products TO authenticated
CREATE POLICY addon_products_select_authenticated ON public.addon_products FOR SELECT TO authenticated USING (((deleted_at IS NULL) AND (is_active = true) AND (is_visible = true)));
CREATE POLICY addon_transactions_admin_insert ON public.addon_transactions FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid()))));
FROM public.saas_admins sa
WHERE (sa.user_id = auth.uid()))));
CREATE POLICY addon_transactions_admin_select ON public.addon_transactions FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.saas_admins
@@ -180,7 +223,43 @@ CREATE POLICY agendador_sol_patient_read ON public.agendador_solicitacoes FOR SE
CREATE POLICY agendador_sol_public_insert ON public.agendador_solicitacoes FOR INSERT TO anon WITH CHECK (true);
CREATE POLICY "billing_contracts: owner full access" ON public.billing_contracts USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "audit_logs: no direct delete" ON public.audit_logs FOR DELETE TO authenticated USING (false);
CREATE POLICY "audit_logs: no direct insert" ON public.audit_logs FOR INSERT TO authenticated WITH CHECK (false);
CREATE POLICY "audit_logs: no direct update" ON public.audit_logs FOR UPDATE TO authenticated USING (false) WITH CHECK (false);
CREATE POLICY "audit_logs: select tenant" ON public.audit_logs FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_log: select" ON public.conversation_autoreply_log FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_log.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_settings: insert" ON public.conversation_autoreply_settings FOR INSERT TO authenticated WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_settings: select" ON public.conversation_autoreply_settings FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "autoreply_settings: update" ON public.conversation_autoreply_settings FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_autoreply_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "billing_contracts: delete" ON public.billing_contracts FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "billing_contracts: insert" ON public.billing_contracts FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "billing_contracts: select" ON public.billing_contracts FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "billing_contracts: update" ON public.billing_contracts FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY bloqueios_delete ON public.agenda_bloqueios FOR DELETE TO authenticated USING ((owner_id = auth.uid()));
@@ -198,11 +277,13 @@ CREATE POLICY clinic_admin_read_all_docs ON public.saas_docs FOR SELECT TO authe
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['clinic_admin'::text, 'tenant_admin'::text])))))));
CREATE POLICY "commitment_services: owner full access" ON public.commitment_services USING ((EXISTS ( SELECT 1
CREATE POLICY "commitment_services: tenant_member" ON public.commitment_services TO authenticated USING ((EXISTS ( SELECT 1
FROM public.services s
WHERE ((s.id = commitment_services.service_id) AND (s.owner_id = auth.uid()))))) WITH CHECK ((EXISTS ( SELECT 1
WHERE ((s.id = commitment_services.service_id) AND ((s.owner_id = auth.uid()) OR public.is_saas_admin() OR (s.tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))))))) WITH CHECK ((EXISTS ( SELECT 1
FROM public.services s
WHERE ((s.id = commitment_services.service_id) AND (s.owner_id = auth.uid())))));
WHERE ((s.id = commitment_services.service_id) AND ((s.owner_id = auth.uid()) OR public.is_saas_admin())))));
CREATE POLICY company_profiles_delete ON public.company_profiles FOR DELETE USING ((EXISTS ( SELECT 1
FROM public.tenant_members
@@ -222,13 +303,99 @@ CREATE POLICY company_profiles_update ON public.company_profiles FOR UPDATE USIN
FROM public.tenant_members
WHERE ((tenant_members.tenant_id = company_profiles.tenant_id) AND (tenant_members.user_id = auth.uid())))));
CREATE POLICY "contact_email_types: manage custom" ON public.contact_email_types TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_email_types.tenant_id) AND (tm.status = 'active'::text))))))) WITH CHECK (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_email_types.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "contact_email_types: select" ON public.contact_email_types FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_email_types.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "contact_emails: all tenant" ON public.contact_emails TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_emails.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_emails.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "contact_phones: all tenant" ON public.contact_phones TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_phones.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_phones.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "contact_types: manage custom" ON public.contact_types TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text))))))) WITH CHECK (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "contact_types: select" ON public.contact_types FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = contact_types.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_msg: no direct delete" ON public.conversation_messages FOR DELETE TO authenticated USING (false);
CREATE POLICY "conv_msg: no direct insert" ON public.conversation_messages FOR INSERT TO authenticated WITH CHECK (false);
CREATE POLICY "conv_msg: select tenant" ON public.conversation_messages FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_msg: update kanban" ON public.conversation_messages FOR UPDATE TO authenticated USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))) WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "conv_notes: insert tenant members" ON public.conversation_notes FOR INSERT TO authenticated WITH CHECK (((created_by = auth.uid()) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_notes.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_notes: select tenant members" ON public.conversation_notes FOR SELECT TO authenticated USING (((deleted_at IS NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_notes.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_notes: update creator or saas" ON public.conversation_notes FOR UPDATE TO authenticated USING (((deleted_at IS NULL) AND ((created_by = auth.uid()) OR public.is_saas_admin()))) WITH CHECK ((created_by = ( SELECT conversation_notes_1.created_by
FROM public.conversation_notes conversation_notes_1
WHERE (conversation_notes_1.id = conversation_notes_1.id))));
CREATE POLICY "conv_tags: delete custom" ON public.conversation_tags FOR DELETE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_tags: insert custom" ON public.conversation_tags FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NOT NULL) AND (is_system = false) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_tags: select" ON public.conversation_tags FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_tags: update custom" ON public.conversation_tags FOR UPDATE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_tags.tenant_id) AND (tm.status = 'active'::text))))))) WITH CHECK ((is_system = false));
CREATE POLICY "conv_thread_tags: delete" ON public.conversation_thread_tags FOR DELETE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_thread_tags.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "conv_thread_tags: insert" ON public.conversation_thread_tags FOR INSERT TO authenticated WITH CHECK (((tagged_by = auth.uid()) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_thread_tags.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "conv_thread_tags: select" ON public.conversation_thread_tags FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_thread_tags.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY ctl_delete_for_active_member ON public.commitment_time_logs FOR DELETE TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = commitment_time_logs.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
CREATE POLICY ctl_insert_for_active_member ON public.commitment_time_logs FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.tenant_id = commitment_time_logs.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY ctl_select_for_active_member ON public.commitment_time_logs FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
@@ -240,7 +407,9 @@ CREATE POLICY ctl_update_for_active_member ON public.commitment_time_logs FOR UP
FROM public.tenant_members tm
WHERE ((tm.tenant_id = commitment_time_logs.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs FOR INSERT WITH CHECK (true);
CREATE POLICY "dal: tenant members can insert" ON public.document_access_logs FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "dal: tenant members can select" ON public.document_access_logs FOR SELECT USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
@@ -250,9 +419,9 @@ CREATE POLICY dc_delete_custom_for_active_member ON public.determined_commitment
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitments.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
CREATE POLICY dc_insert_for_active_member ON public.determined_commitments FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitments.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY dc_select_for_active_member ON public.determined_commitments FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
@@ -268,9 +437,9 @@ CREATE POLICY dcf_delete_for_active_member ON public.determined_commitment_field
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitment_fields.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields FOR INSERT TO authenticated WITH CHECK ((EXISTS ( SELECT 1
CREATE POLICY dcf_insert_for_active_member ON public.determined_commitment_fields FOR INSERT TO authenticated WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.tenant_id = determined_commitment_fields.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY dcf_select_for_active_member ON public.determined_commitment_fields FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.tenant_members tm
@@ -284,6 +453,16 @@ CREATE POLICY dcf_update_for_active_member ON public.determined_commitment_field
CREATE POLICY "delete own" ON public.agenda_bloqueios FOR DELETE USING ((owner_id = auth.uid()));
CREATE POLICY dev_auditoria_items_saas_admin_all ON public.dev_auditoria_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_comparison_competitor_status_saas_admin_all ON public.dev_comparison_competitor_status TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_comparison_matrix_saas_admin_all ON public.dev_comparison_matrix TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_competitor_features_saas_admin_all ON public.dev_competitor_features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_competitors_saas_admin_all ON public.dev_competitors TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_creds_select_saas_admin ON public.dev_user_credentials FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.profiles p
WHERE ((p.id = auth.uid()) AND (p.role = 'saas_admin'::text)))));
@@ -294,35 +473,67 @@ CREATE POLICY dev_creds_write_saas_admin ON public.dev_user_credentials TO authe
FROM public.profiles p
WHERE ((p.id = auth.uid()) AND (p.role = 'saas_admin'::text)))));
CREATE POLICY dev_generation_log_saas_admin_all ON public.dev_generation_log TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_roadmap_items_saas_admin_all ON public.dev_roadmap_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_roadmap_phases_saas_admin_all ON public.dev_roadmap_phases TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_test_items_saas_admin_all ON public.dev_test_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY dev_verificacoes_items_saas_admin_all ON public.dev_verificacoes_items TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY "dg: generator full access" ON public.document_generated USING ((gerado_por = auth.uid())) WITH CHECK ((gerado_por = auth.uid()));
CREATE POLICY "dg: tenant members can select" ON public.document_generated FOR SELECT USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
CREATE POLICY "documents: owner full access" ON public.documents USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "documents: delete" ON public.documents FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "ds: tenant members access" ON public.document_signatures USING ((tenant_id IN ( SELECT tm.tenant_id
CREATE POLICY "documents: insert" ON public.documents FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))) WITH CHECK ((tenant_id IN ( SELECT tm.tenant_id
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "documents: portal patient read" ON public.documents FOR SELECT TO authenticated USING (((compartilhado_portal = true) AND (patient_id IN ( SELECT p.id
FROM public.patients p
WHERE (p.user_id = auth.uid()))) AND ((expira_compartilhamento IS NULL) OR (expira_compartilhamento > now()))));
CREATE POLICY "documents: select" ON public.documents FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))));
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "dsl: creator full access" ON public.document_share_links USING ((criado_por = auth.uid())) WITH CHECK ((criado_por = auth.uid()));
CREATE POLICY "documents: update" ON public.documents FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "dsl: public read by token" ON public.document_share_links FOR SELECT USING (((ativo = true) AND (expira_em > now()) AND (usos < usos_max)));
CREATE POLICY "ds: delete" ON public.document_signatures FOR DELETE TO authenticated USING (((signatario_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY "ds: insert" ON public.document_signatures FOR INSERT TO authenticated WITH CHECK (((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))) AND ((signatario_id IS NULL) OR (signatario_id = auth.uid()))));
CREATE POLICY "ds: select" ON public.document_signatures FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "ds: update" ON public.document_signatures FOR UPDATE TO authenticated USING (((signatario_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((signatario_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "dsl: creator full access" ON public.document_share_links TO authenticated USING (((criado_por = auth.uid()) OR public.is_saas_admin())) WITH CHECK ((criado_por = auth.uid()));
CREATE POLICY "dt: global templates readable by all" ON public.document_templates FOR SELECT USING ((is_global = true));
CREATE POLICY "dt: owner can delete" ON public.document_templates FOR DELETE USING (((owner_id = auth.uid()) AND (is_global = false)));
CREATE POLICY "dt: owner can insert" ON public.document_templates FOR INSERT WITH CHECK (((owner_id = auth.uid()) AND (is_global = false)));
CREATE POLICY "dt: owner can insert" ON public.document_templates FOR INSERT TO authenticated WITH CHECK (((is_global = false) AND (owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "dt: owner can update" ON public.document_templates FOR UPDATE USING (((owner_id = auth.uid()) AND (is_global = false))) WITH CHECK (((owner_id = auth.uid()) AND (is_global = false)));
CREATE POLICY "dt: saas admin can delete global" ON public.document_templates FOR DELETE USING (((is_global = true) AND public.is_saas_admin()));
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates FOR INSERT WITH CHECK (((is_global = true) AND public.is_saas_admin()));
CREATE POLICY "dt: saas admin can insert global" ON public.document_templates FOR INSERT TO authenticated WITH CHECK (((is_global = true) AND public.is_saas_admin()));
CREATE POLICY "dt: saas admin can update global" ON public.document_templates FOR UPDATE USING (((is_global = true) AND public.is_saas_admin())) WITH CHECK (((is_global = true) AND public.is_saas_admin()));
@@ -330,47 +541,57 @@ CREATE POLICY "dt: tenant members can select" ON public.document_templates FOR S
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "email_layout_config: tenant_admin all" ON public.email_layout_config TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY "email_templates_tenant: tenant_admin all" ON public.email_templates_tenant TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY ent_inv_select_own ON public.entitlements_invalidation FOR SELECT USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY ent_inv_update_saas ON public.entitlements_invalidation FOR UPDATE USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY ent_inv_write_saas ON public.entitlements_invalidation FOR INSERT WITH CHECK (public.is_saas_admin());
CREATE POLICY faq_admin_write ON public.saas_faq TO authenticated USING ((EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['saas_admin'::text, 'tenant_admin'::text, 'clinic_admin'::text]))))));
CREATE POLICY faq_auth_read ON public.saas_faq FOR SELECT TO authenticated USING ((ativo = true));
CREATE POLICY faq_itens_admin_write ON public.saas_faq_itens TO authenticated USING ((EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = ANY (ARRAY['saas_admin'::text, 'tenant_admin'::text, 'clinic_admin'::text]))))));
CREATE POLICY faq_itens_auth_read ON public.saas_faq_itens FOR SELECT TO authenticated USING (((ativo = true) AND (EXISTS ( SELECT 1
FROM public.saas_docs d
WHERE ((d.id = saas_faq_itens.doc_id) AND (d.ativo = true))))));
CREATE POLICY faq_itens_saas_admin_write ON public.saas_faq_itens TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY faq_public_read ON public.saas_faq FOR SELECT USING (((publico = true) AND (ativo = true)));
CREATE POLICY faq_saas_admin_write ON public.saas_faq TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY features_read_authenticated ON public.features FOR SELECT TO authenticated USING (true);
CREATE POLICY features_write_saas_admin ON public.features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY feriados_delete ON public.feriados FOR DELETE USING ((owner_id = auth.uid()));
CREATE POLICY feriados_delete ON public.feriados FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR ((tenant_id IS NOT NULL) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))));
CREATE POLICY feriados_global_select ON public.feriados FOR SELECT USING ((tenant_id IS NULL));
CREATE POLICY feriados_insert ON public.feriados FOR INSERT WITH CHECK ((tenant_id IN ( SELECT tenant_members.tenant_id
FROM public.tenant_members
WHERE (tenant_members.user_id = auth.uid()))));
CREATE POLICY feriados_insert ON public.feriados FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NOT NULL) AND (owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY feriados_saas_delete ON public.feriados FOR DELETE USING ((EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid()))));
CREATE POLICY feriados_saas_insert ON public.feriados FOR INSERT WITH CHECK ((EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid()))));
CREATE POLICY feriados_saas_insert ON public.feriados FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NULL) AND (EXISTS ( SELECT 1
FROM public.saas_admins sa
WHERE (sa.user_id = auth.uid())))));
CREATE POLICY feriados_saas_select ON public.feriados FOR SELECT USING ((EXISTS ( SELECT 1
FROM public.saas_admins
@@ -406,15 +627,37 @@ CREATE POLICY global_notices_select ON public.global_notices FOR SELECT TO authe
CREATE POLICY "insert own" ON public.agenda_bloqueios FOR INSERT WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY insurance_plan_services_owner ON public.insurance_plan_services USING ((EXISTS ( SELECT 1
CREATE POLICY "insurance_plan_services: tenant_member" ON public.insurance_plan_services TO authenticated USING ((EXISTS ( SELECT 1
FROM public.insurance_plans ip
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND (ip.owner_id = auth.uid()))))) WITH CHECK ((EXISTS ( SELECT 1
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND ((ip.owner_id = auth.uid()) OR public.is_saas_admin() OR (ip.tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))))))) WITH CHECK ((EXISTS ( SELECT 1
FROM public.insurance_plans ip
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND (ip.owner_id = auth.uid())))));
WHERE ((ip.id = insurance_plan_services.insurance_plan_id) AND ((ip.owner_id = auth.uid()) OR public.is_saas_admin())))));
CREATE POLICY "insurance_plans: owner full access" ON public.insurance_plans USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "insurance_plans: delete" ON public.insurance_plans FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "medicos: owner full access" ON public.medicos USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "insurance_plans: insert" ON public.insurance_plans FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "insurance_plans: select" ON public.insurance_plans FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "insurance_plans: update" ON public.insurance_plans FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "medicos: delete" ON public.medicos FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "medicos: insert" ON public.medicos FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "medicos: select" ON public.medicos FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "medicos: update" ON public.medicos FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY module_features_read_authenticated ON public.module_features FOR SELECT TO authenticated USING (true);
@@ -426,12 +669,32 @@ CREATE POLICY modules_write_saas_admin ON public.modules TO authenticated USING
CREATE POLICY notice_dismissals_own ON public.notice_dismissals TO authenticated USING ((user_id = auth.uid())) WITH CHECK ((user_id = auth.uid()));
CREATE POLICY notif_channels_delete ON public.notification_channels FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY notif_channels_insert ON public.notification_channels FOR INSERT TO authenticated WITH CHECK ((public.is_saas_admin() OR ((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY notif_channels_modify ON public.notification_channels FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY notif_channels_select ON public.notification_channels FOR SELECT TO authenticated USING (((deleted_at IS NULL) AND (public.is_saas_admin() OR (owner_id = auth.uid()) OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY notif_logs_owner ON public.notification_logs FOR SELECT USING ((owner_id = auth.uid()));
CREATE POLICY notif_logs_tenant_member ON public.notification_logs FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY notif_prefs_owner ON public.notification_preferences USING (((owner_id = auth.uid()) AND (deleted_at IS NULL))) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY notif_queue_owner ON public.notification_queue FOR SELECT USING ((owner_id = auth.uid()));
CREATE POLICY notif_queue_tenant_member ON public.notification_queue FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY notif_schedules_owner ON public.notification_schedules USING (((owner_id = auth.uid()) AND (deleted_at IS NULL))) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY notif_templates_admin_all ON public.notification_templates TO authenticated USING ((EXISTS ( SELECT 1
@@ -444,7 +707,33 @@ CREATE POLICY notif_templates_read_global ON public.notification_templates FOR S
CREATE POLICY notif_templates_write_owner ON public.notification_templates TO authenticated USING (((owner_id = auth.uid()) OR public.is_tenant_member(tenant_id))) WITH CHECK (((owner_id = auth.uid()) OR public.is_tenant_member(tenant_id)));
CREATE POLICY notification_channels_owner ON public.notification_channels USING (((owner_id = auth.uid()) AND (deleted_at IS NULL))) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "optout_kw: delete custom" ON public.conversation_optout_keywords FOR DELETE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "optout_kw: insert custom" ON public.conversation_optout_keywords FOR INSERT TO authenticated WITH CHECK (((tenant_id IS NOT NULL) AND (is_system = false) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "optout_kw: select" ON public.conversation_optout_keywords FOR SELECT TO authenticated USING (((tenant_id IS NULL) OR public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "optout_kw: update/delete custom" ON public.conversation_optout_keywords FOR UPDATE TO authenticated USING (((is_system = false) AND (tenant_id IS NOT NULL) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optout_keywords.tenant_id) AND (tm.status = 'active'::text)))))));
CREATE POLICY "optouts: insert" ON public.conversation_optouts FOR INSERT TO authenticated WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optouts.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "optouts: select" ON public.conversation_optouts FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optouts.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "optouts: update" ON public.conversation_optouts FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = conversation_optouts.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "owner only" ON public.notifications USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
@@ -478,6 +767,8 @@ CREATE POLICY patient_intake_requests_select ON public.patient_intake_requests F
CREATE POLICY patient_intake_requests_write ON public.patient_intake_requests USING ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit'::text))) WITH CHECK ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit'::text)));
CREATE POLICY patient_invite_attempts_owner_read ON public.patient_invite_attempts FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY patient_invites_owner_all ON public.patient_invites TO authenticated USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY patient_invites_select ON public.patient_invites FOR SELECT USING ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.view'::text)));
@@ -508,17 +799,37 @@ CREATE POLICY patients_update ON public.patients FOR UPDATE USING ((public.is_cl
CREATE POLICY "payment_settings: owner full access" ON public.payment_settings USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "payment_settings: tenant_admin read" ON public.payment_settings FOR SELECT TO authenticated USING (((tenant_id IS NOT NULL) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY plan_features_read_authenticated ON public.plan_features FOR SELECT TO authenticated USING (true);
CREATE POLICY plan_features_write_saas_admin ON public.plan_features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plan_prices_read ON public.plan_prices FOR SELECT TO authenticated USING (true);
CREATE POLICY plan_prices_write ON public.plan_prices TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plan_public_bullets_read_anon ON public.plan_public_bullets FOR SELECT TO authenticated, anon USING (true);
CREATE POLICY plan_public_bullets_write ON public.plan_public_bullets TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plan_public_read_anon ON public.plan_public FOR SELECT TO authenticated, anon USING (true);
CREATE POLICY plan_public_write ON public.plan_public TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY plans_read_authenticated ON public.plans FOR SELECT TO authenticated USING (true);
CREATE POLICY plans_write_saas_admin ON public.plans TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY "professional_pricing: owner full access" ON public.professional_pricing USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY profiles_insert_own ON public.profiles FOR INSERT WITH CHECK ((id = auth.uid()));
CREATE POLICY "professional_pricing: tenant_admin read" ON public.professional_pricing FOR SELECT TO authenticated USING ((tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))));
CREATE POLICY profiles_insert_own ON public.profiles FOR INSERT TO authenticated WITH CHECK ((id = auth.uid()));
CREATE POLICY profiles_read_saas_admin ON public.profiles FOR SELECT USING (public.is_saas_admin());
@@ -526,6 +837,8 @@ CREATE POLICY profiles_select_own ON public.profiles FOR SELECT USING ((id = aut
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE USING ((id = auth.uid())) WITH CHECK ((id = auth.uid()));
CREATE POLICY psa_read_saas_admin ON public.public_submission_attempts FOR SELECT TO authenticated USING (public.is_saas_admin());
CREATE POLICY "psc: owner full access" ON public.patient_support_contacts USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY psh_insert ON public.patient_status_history FOR INSERT WITH CHECK ((public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit'::text)));
@@ -538,12 +851,6 @@ CREATE POLICY pt_select ON public.patient_timeline FOR SELECT USING ((public.is_
CREATE POLICY public_read ON public.login_carousel_slides FOR SELECT USING ((ativo = true));
CREATE POLICY "read features (auth)" ON public.features FOR SELECT TO authenticated USING (true);
CREATE POLICY "read plan_features (auth)" ON public.plan_features FOR SELECT TO authenticated USING (true);
CREATE POLICY "read plans (auth)" ON public.plans FOR SELECT TO authenticated USING (true);
CREATE POLICY recurrence_exceptions_tenant ON public.recurrence_exceptions TO authenticated USING ((tenant_id IN ( SELECT tenant_members.tenant_id
FROM public.tenant_members
WHERE (tenant_members.user_id = auth.uid())))) WITH CHECK ((tenant_id IN ( SELECT tenant_members.tenant_id
@@ -572,6 +879,16 @@ CREATE POLICY recurrence_rules_clinic_write ON public.recurrence_rules USING ((p
CREATE POLICY recurrence_rules_owner ON public.recurrence_rules TO authenticated USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "reminder_logs: tenant members select" ON public.session_reminder_logs FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = session_reminder_logs.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "reminder_settings: tenant members all" ON public.session_reminder_settings TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = session_reminder_settings.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = session_reminder_settings.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "saas_admin can read subscription_intents" ON public.subscription_intents_legacy FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.saas_admins a
WHERE (a.user_id = auth.uid()))));
@@ -594,11 +911,29 @@ CREATE POLICY saas_admin_full_access ON public.saas_docs TO authenticated USING
CREATE POLICY saas_admins_select_self ON public.saas_admins FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY saas_security_config_read ON public.saas_security_config FOR SELECT TO authenticated USING (true);
CREATE POLICY saas_security_config_write ON public.saas_security_config FOR UPDATE TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY saas_twilio_config_read ON public.saas_twilio_config FOR SELECT TO authenticated USING (public.is_saas_admin());
CREATE POLICY "select own" ON public.agenda_bloqueios FOR SELECT USING ((owner_id = auth.uid()));
CREATE POLICY service_role_manage_usage ON public.twilio_subaccount_usage USING ((auth.role() = 'service_role'::text));
CREATE POLICY "services: owner full access" ON public.services USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
CREATE POLICY "services: delete" ON public.services FOR DELETE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY "services: insert" ON public.services FOR INSERT TO authenticated WITH CHECK (((owner_id = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "services: select" ON public.services FOR SELECT TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "services: update" ON public.services FOR UPDATE TO authenticated USING (((owner_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((owner_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY srl_read_saas_admin ON public.submission_rate_limits FOR SELECT TO authenticated USING (public.is_saas_admin());
CREATE POLICY subscription_events_read_saas ON public.subscription_events FOR SELECT USING (public.is_saas_admin());
@@ -606,9 +941,15 @@ CREATE POLICY subscription_events_write_saas ON public.subscription_events FOR I
CREATE POLICY subscription_intents_insert_own ON public.subscription_intents_legacy FOR INSERT TO authenticated WITH CHECK ((user_id = auth.uid()));
CREATE POLICY subscription_intents_personal_owner ON public.subscription_intents_personal TO authenticated USING (((user_id = auth.uid()) OR public.is_saas_admin())) WITH CHECK (((user_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY subscription_intents_select_own ON public.subscription_intents_legacy FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY "subscriptions read own" ON public.subscriptions FOR SELECT TO authenticated USING ((user_id = auth.uid()));
CREATE POLICY subscription_intents_tenant_member ON public.subscription_intents_tenant TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text])))))));
CREATE POLICY "subscriptions: read if linked owner_users" ON public.subscriptions FOR SELECT TO authenticated USING ((EXISTS ( SELECT 1
FROM public.owner_users ou
@@ -616,33 +957,53 @@ CREATE POLICY "subscriptions: read if linked owner_users" ON public.subscription
CREATE POLICY subscriptions_insert_own_personal ON public.subscriptions FOR INSERT TO authenticated WITH CHECK (((user_id = auth.uid()) AND (tenant_id IS NULL)));
CREATE POLICY subscriptions_no_direct_update ON public.subscriptions FOR UPDATE TO authenticated USING (false) WITH CHECK (false);
CREATE POLICY subscriptions_read_own ON public.subscriptions FOR SELECT TO authenticated USING (((user_id = auth.uid()) OR public.is_saas_admin()));
CREATE POLICY subscriptions_select_for_tenant_members ON public.subscriptions FOR SELECT TO authenticated USING (((tenant_id IS NOT NULL) AND (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.tenant_id = subscriptions.tenant_id) AND (tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY subscriptions_select_own_personal ON public.subscriptions FOR SELECT TO authenticated USING (((user_id = auth.uid()) AND (tenant_id IS NULL)));
CREATE POLICY subscriptions_update_only_saas_admin ON public.subscriptions FOR UPDATE TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY support_sessions_saas_delete ON public.support_sessions FOR DELETE USING (((auth.uid() = admin_id) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text))))));
CREATE POLICY support_sessions_saas_insert ON public.support_sessions FOR INSERT WITH CHECK (((auth.uid() = admin_id) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text))))));
CREATE POLICY support_sessions_saas_insert ON public.support_sessions FOR INSERT TO authenticated WITH CHECK (((admin_id = auth.uid()) AND (EXISTS ( SELECT 1
FROM public.saas_admins sa
WHERE (sa.user_id = auth.uid())))));
CREATE POLICY support_sessions_saas_select ON public.support_sessions FOR SELECT USING (((auth.uid() = admin_id) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text))))));
CREATE POLICY "tenant manages own overrides" ON public.email_templates_tenant USING ((tenant_id = auth.uid())) WITH CHECK ((tenant_id = auth.uid()));
CREATE POLICY tenant_feature_exceptions_log_read ON public.tenant_feature_exceptions_log FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "tenant owns email layout config" ON public.email_layout_config USING ((tenant_id = auth.uid())) WITH CHECK ((tenant_id = auth.uid()));
CREATE POLICY tenant_features_select ON public.tenant_features FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY tenant_features_write_saas_only ON public.tenant_features TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY tenant_invites_delete ON public.tenant_invites FOR DELETE TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_invites_insert ON public.tenant_invites FOR INSERT TO authenticated WITH CHECK (((invited_by = auth.uid()) AND (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_invites_select ON public.tenant_invites FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_invites_update ON public.tenant_invites FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text]))))))) WITH CHECK ((public.is_saas_admin() OR (tenant_id IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND (tm.role = ANY (ARRAY['tenant_admin'::text, 'admin'::text, 'owner'::text])))))));
CREATE POLICY tenant_members_write_saas ON public.tenant_members TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
@@ -680,7 +1041,7 @@ CREATE POLICY tm_select_own_membership ON public.tenant_members FOR SELECT TO au
CREATE POLICY "update own" ON public.agenda_bloqueios FOR UPDATE USING ((owner_id = auth.uid()));
CREATE POLICY user_settings_insert_own ON public.user_settings FOR INSERT WITH CHECK ((user_id = auth.uid()));
CREATE POLICY user_settings_insert_own ON public.user_settings FOR INSERT TO authenticated WITH CHECK ((user_id = auth.uid()));
CREATE POLICY user_settings_select_own ON public.user_settings FOR SELECT USING ((user_id = auth.uid()));
@@ -692,6 +1053,28 @@ CREATE POLICY votos_select_own ON public.saas_doc_votos FOR SELECT TO authentica
CREATE POLICY votos_upsert_own ON public.saas_doc_votos TO authenticated USING ((user_id = auth.uid())) WITH CHECK ((user_id = auth.uid()));
CREATE POLICY "wa_credits_balance: select tenant" ON public.whatsapp_credits_balance FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_balance.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_credits_balance: update tenant" ON public.whatsapp_credits_balance FOR UPDATE TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_balance.tenant_id) AND (tm.status = 'active'::text)))))) WITH CHECK ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_balance.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_credits_tx: select tenant" ON public.whatsapp_credits_transactions FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credits_transactions.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "wa_packages: manage saas admin" ON public.whatsapp_credit_packages TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
CREATE POLICY "wa_packages: select active" ON public.whatsapp_credit_packages FOR SELECT TO authenticated USING (((is_active = true) OR public.is_saas_admin()));
CREATE POLICY "wa_purchases: select tenant" ON public.whatsapp_credit_purchases FOR SELECT TO authenticated USING ((public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.tenant_id = whatsapp_credit_purchases.tenant_id) AND (tm.status = 'active'::text))))));
CREATE POLICY "Allow authenticated updates" ON storage.objects FOR UPDATE TO authenticated USING ((bucket_id = ANY (ARRAY['avatars'::text, 'logos'::text])));
CREATE POLICY "Allow authenticated uploads" ON storage.objects FOR INSERT TO authenticated WITH CHECK ((bucket_id = ANY (ARRAY['avatars'::text, 'logos'::text])));
@@ -730,25 +1113,31 @@ CREATE POLICY avatars_update_own ON storage.objects FOR UPDATE TO authenticated
CREATE POLICY avatars_update_own_folder ON storage.objects FOR UPDATE USING (((bucket_id = 'avatars'::text) AND (auth.role() = 'authenticated'::text) AND (name ~~ (('owners/'::text || auth.uid()) || '/%'::text)))) WITH CHECK (((bucket_id = 'avatars'::text) AND (auth.role() = 'authenticated'::text) AND (name ~~ (('owners/'::text || auth.uid()) || '/%'::text))));
CREATE POLICY "documents: authenticated delete" ON storage.objects FOR DELETE TO authenticated USING ((bucket_id = 'documents'::text));
CREATE POLICY "documents: tenant member delete" ON storage.objects FOR DELETE TO authenticated USING (((bucket_id = 'documents'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "documents: authenticated read" ON storage.objects FOR SELECT TO authenticated USING ((bucket_id = 'documents'::text));
CREATE POLICY "documents: tenant member read" ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'documents'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "documents: authenticated upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK ((bucket_id = 'documents'::text));
CREATE POLICY "documents: tenant member upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (((bucket_id = 'documents'::text) AND (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY "generated-docs: authenticated delete" ON storage.objects FOR DELETE TO authenticated USING ((bucket_id = 'generated-docs'::text));
CREATE POLICY "generated-docs: tenant member delete" ON storage.objects FOR DELETE TO authenticated USING (((bucket_id = 'generated-docs'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "generated-docs: authenticated read" ON storage.objects FOR SELECT TO authenticated USING ((bucket_id = 'generated-docs'::text));
CREATE POLICY "generated-docs: tenant member read" ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'generated-docs'::text) AND (public.is_saas_admin() OR (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text)))))));
CREATE POLICY "generated-docs: authenticated upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK ((bucket_id = 'generated-docs'::text));
CREATE POLICY "generated-docs: tenant member upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (((bucket_id = 'generated-docs'::text) AND (((storage.foldername(name))[1])::uuid IN ( SELECT tm.tenant_id
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text))))));
CREATE POLICY intake_read_anon ON storage.objects FOR SELECT TO anon USING (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_read_public ON storage.objects FOR SELECT USING (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_upload_anon ON storage.objects FOR INSERT TO anon WITH CHECK (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_upload_public ON storage.objects FOR INSERT WITH CHECK (((bucket_id = 'avatars'::text) AND (name ~~ 'intakes/%'::text)));
CREATE POLICY intake_read_owner_only ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'avatars'::text) AND ((storage.foldername(name))[1] = 'intakes'::text)));
CREATE POLICY public_read ON storage.objects FOR SELECT USING ((bucket_id = 'saas-docs'::text));
@@ -759,3 +1148,9 @@ CREATE POLICY saas_admin_delete ON storage.objects FOR DELETE TO authenticated U
CREATE POLICY saas_admin_upload ON storage.objects FOR INSERT TO authenticated WITH CHECK (((bucket_id = 'saas-docs'::text) AND (EXISTS ( SELECT 1
FROM public.saas_admins
WHERE (saas_admins.user_id = auth.uid())))));
CREATE POLICY "whatsapp-media: delete saas admin" ON storage.objects FOR DELETE TO authenticated USING (((bucket_id = 'whatsapp-media'::text) AND public.is_saas_admin()));
CREATE POLICY "whatsapp-media: read tenant members" ON storage.objects FOR SELECT TO authenticated USING (((bucket_id = 'whatsapp-media'::text) AND (public.is_saas_admin() OR (EXISTS ( SELECT 1
FROM public.tenant_members tm
WHERE ((tm.user_id = auth.uid()) AND (tm.status = 'active'::text) AND ((storage.foldername(objects.name))[1] = (tm.tenant_id)::text)))))));