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
@@ -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
-- ==========================================================================