-- ============================================================================= -- 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).';