From cd67f7e9f5df00dbe10e4c69c37485f21c84effc Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 21 May 2026 04:21:03 -0300 Subject: [PATCH] compliance CFP: #5 registro profissional + #9 especialidades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROADMAP Fase 1.2 (Compliance basico BR). Item #5: profiles ganha 3 colunas (professional_registration_type/number/uf) com CHECK constraint dos conselhos comuns (CRP, CRM, CRFa, CREFITO, CRESS, CRN, RMS, outro). Item #9: catalogo public.specialties + join M:N profile_specialties + RLS. Seed seed_050 popula 33 especialidades is_system=true (clinica, jurídica, neuropsicologia, ABA, TCC, psicanalise etc). Service specialtiesService.js no src/services pra consumo na UI. Item #8 (nome social) ja estava integrado. #6 (consent forms UI) e #7 (assinatura no portal) adiados — schemas document_templates e document_signatures existem, falta workflow UI dedicado. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...003_profiles_professional_registration.sql | 71 +++++++++++ .../migrations/20260521000004_specialties.sql | 79 ++++++++++++ database-novo/seeds/seed_050_specialties.sql | 57 +++++++++ src/services/specialtiesService.js | 112 ++++++++++++++++++ 4 files changed, 319 insertions(+) create mode 100644 database-novo/migrations/20260521000003_profiles_professional_registration.sql create mode 100644 database-novo/migrations/20260521000004_specialties.sql create mode 100644 database-novo/seeds/seed_050_specialties.sql create mode 100644 src/services/specialtiesService.js diff --git a/database-novo/migrations/20260521000003_profiles_professional_registration.sql b/database-novo/migrations/20260521000003_profiles_professional_registration.sql new file mode 100644 index 0000000..96198aa --- /dev/null +++ b/database-novo/migrations/20260521000003_profiles_professional_registration.sql @@ -0,0 +1,71 @@ +-- ============================================================================ +-- Compliance CFP — Tipo de registro profissional (ROADMAP item #5) +-- ---------------------------------------------------------------------------- +-- Adiciona campos de registro profissional ao perfil. Necessário pra emissão +-- de recibos/laudos válidos (CFP exige tipo, número e UF do conselho). +-- +-- Conselhos comuns no Brasil: +-- CRP — Psicólogo +-- CRM — Médico +-- CRFa — Fonoaudiólogo +-- CREFITO — Fisioterapeuta / Terapeuta Ocupacional +-- CRESS — Assistente Social +-- CRN — Nutricionista +-- RMS — Residência Multiprofissional (Saúde) +-- outro — Catch-all (campo livre na UI) +-- ============================================================================ + +BEGIN; + +ALTER TABLE public.profiles + ADD COLUMN IF NOT EXISTS professional_registration_type text, + ADD COLUMN IF NOT EXISTS professional_registration_number text, + ADD COLUMN IF NOT EXISTS professional_registration_uf text; + +-- CHECK não pode ser ADD IF NOT EXISTS — guard com DO block +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'profiles_registration_type_check' + ) THEN + ALTER TABLE public.profiles + ADD CONSTRAINT profiles_registration_type_check CHECK ( + professional_registration_type IS NULL + OR professional_registration_type = ANY (ARRAY[ + 'CRP', + 'CRM', + 'CRFa', + 'CREFITO', + 'CRESS', + 'CRN', + 'RMS', + 'outro' + ]) + ); + END IF; +END $$; + +-- UF check (regex pra 2 chars uppercase ou NULL) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'profiles_registration_uf_check' + ) THEN + ALTER TABLE public.profiles + ADD CONSTRAINT profiles_registration_uf_check CHECK ( + professional_registration_uf IS NULL + OR professional_registration_uf ~ '^[A-Z]{2}$' + ); + END IF; +END $$; + +COMMENT ON COLUMN public.profiles.professional_registration_type IS + 'Tipo de registro profissional. Obrigatório pra emitir recibos/laudos. ROADMAP item #5.'; +COMMENT ON COLUMN public.profiles.professional_registration_number IS + 'Número do registro (ex: 06/12345 ou 123456). Formato livre — UI ajuda com mask se relevante.'; +COMMENT ON COLUMN public.profiles.professional_registration_uf IS + 'UF do conselho (2 chars uppercase). Alguns conselhos exigem regionalização (CRP 06/SP, CRP 03/BA).'; + +COMMIT; diff --git a/database-novo/migrations/20260521000004_specialties.sql b/database-novo/migrations/20260521000004_specialties.sql new file mode 100644 index 0000000..b9b5925 --- /dev/null +++ b/database-novo/migrations/20260521000004_specialties.sql @@ -0,0 +1,79 @@ +-- ============================================================================ +-- Compliance CFP — Especialidades do profissional (ROADMAP item #9) +-- ---------------------------------------------------------------------------- +-- Catálogo de especialidades/abordagens + join many-to-many com profiles. +-- Profissional pode ter múltiplas especialidades (clínica + jurídica, etc). +-- ============================================================================ + +BEGIN; + +CREATE TABLE IF NOT EXISTS public.specialties ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + key text UNIQUE NOT NULL, + name text NOT NULL, + category text, + is_system boolean DEFAULT false NOT NULL, + active boolean DEFAULT true NOT NULL, + created_at timestamptz DEFAULT now() NOT NULL +); + +COMMENT ON TABLE public.specialties IS + 'Catálogo global de especialidades/abordagens psicológicas (ROADMAP item #9). is_system=true pra entries seedadas.'; + +CREATE INDEX IF NOT EXISTS idx_specialties_active ON public.specialties (active, category, name); + +CREATE TABLE IF NOT EXISTS public.profile_specialties ( + profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE, + specialty_id uuid NOT NULL REFERENCES public.specialties(id) ON DELETE RESTRICT, + other_label text, + created_at timestamptz DEFAULT now() NOT NULL, + PRIMARY KEY (profile_id, specialty_id) +); + +COMMENT ON TABLE public.profile_specialties IS + 'M:N entre profile e specialty. other_label preenchido só quando specialty.key=outra (custom user-defined).'; + +CREATE INDEX IF NOT EXISTS idx_profile_specialties_profile ON public.profile_specialties (profile_id); + +-- ────────────────────────────────────────────────────────────────────────── +-- RLS +-- ────────────────────────────────────────────────────────────────────────── + +ALTER TABLE public.specialties ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.profile_specialties ENABLE ROW LEVEL SECURITY; + +-- specialties: read-only pra todos authenticated (catálogo público); só saas_admin escreve +CREATE POLICY specialties_authenticated_read + ON public.specialties FOR SELECT TO authenticated + USING (active = true); + +CREATE POLICY specialties_saas_admin_write + ON public.specialties TO authenticated + USING (public.is_saas_admin()) + WITH CHECK (public.is_saas_admin()); + +-- profile_specialties: cada user gerencia o próprio +CREATE POLICY profile_specialties_owner_select + ON public.profile_specialties FOR SELECT TO authenticated + USING (profile_id = auth.uid()); + +CREATE POLICY profile_specialties_owner_insert + ON public.profile_specialties FOR INSERT TO authenticated + WITH CHECK (profile_id = auth.uid()); + +CREATE POLICY profile_specialties_owner_delete + ON public.profile_specialties FOR DELETE TO authenticated + USING (profile_id = auth.uid()); + +-- Tenant_admin pode VER specialties dos membros (pra cards públicos / perfil clínica) +CREATE POLICY profile_specialties_tenant_admin_read + ON public.profile_specialties FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.tenant_members tm + WHERE tm.user_id = profile_specialties.profile_id + AND public.is_tenant_admin(tm.tenant_id) + ) + ); + +COMMIT; diff --git a/database-novo/seeds/seed_050_specialties.sql b/database-novo/seeds/seed_050_specialties.sql new file mode 100644 index 0000000..5b510e2 --- /dev/null +++ b/database-novo/seeds/seed_050_specialties.sql @@ -0,0 +1,57 @@ +-- ============================================================================ +-- Seed: Especialidades do sistema (ROADMAP item #9) +-- ---------------------------------------------------------------------------- +-- Lista canônica de especialidades + abordagens psicológicas no Brasil. +-- is_system=true; usuário escolhe múltiplas; 'outra' permite custom via +-- profile_specialties.other_label. +-- ============================================================================ + +BEGIN; + +INSERT INTO public.specialties (key, name, category, is_system, active) VALUES + -- Especialidades CFP (psicologia) + ('psicologia_clinica', 'Psicologia Clínica', 'psicologia', true, true), + ('psicologia_hospitalar', 'Psicologia Hospitalar', 'psicologia', true, true), + ('neuropsicologia', 'Neuropsicologia', 'psicologia', true, true), + ('psicologia_organizacional', 'Psicologia Organizacional e do Trabalho', 'psicologia', true, true), + ('psicologia_escolar', 'Psicologia Escolar e Educacional', 'psicologia', true, true), + ('psicologia_juridica', 'Psicologia Jurídica', 'psicologia', true, true), + ('psicologia_esporte', 'Psicologia do Esporte', 'psicologia', true, true), + ('psicologia_social', 'Psicologia Social', 'psicologia', true, true), + ('psicologia_transito', 'Psicologia do Trânsito', 'psicologia', true, true), + + -- Abordagens teóricas + ('psicanalise', 'Psicanálise', 'abordagem', true, true), + ('tcc', 'Terapia Cognitivo-Comportamental (TCC)', 'abordagem', true, true), + ('psicodrama', 'Psicodrama', 'abordagem', true, true), + ('gestalt_terapia', 'Gestalt-terapia', 'abordagem', true, true), + ('analise_comportamento', 'Análise do Comportamento (ABA)', 'abordagem', true, true), + ('humanista', 'Abordagem Humanista (Rogers)', 'abordagem', true, true), + ('sistemica_familiar', 'Terapia Sistêmica Familiar', 'abordagem', true, true), + ('logoterapia', 'Logoterapia (Frankl)', 'abordagem', true, true), + ('analitica_jung', 'Psicologia Analítica (Jung)', 'abordagem', true, true), + + -- Públicos + ('infantil', 'Atendimento Infantil', 'publico', true, true), + ('adolescentes', 'Atendimento de Adolescentes', 'publico', true, true), + ('casais', 'Terapia de Casal', 'publico', true, true), + ('familia', 'Terapia Familiar', 'publico', true, true), + ('grupos', 'Atendimento de Grupos', 'publico', true, true), + ('idosos', 'Atendimento de Idosos / Gerontologia', 'publico', true, true), + ('lgbtqia', 'Atendimento LGBTQIA+', 'publico', true, true), + + -- Temas + ('ansiedade', 'Transtornos de Ansiedade', 'tema', true, true), + ('depressao', 'Depressão', 'tema', true, true), + ('tdah', 'TDAH', 'tema', true, true), + ('autismo', 'Transtorno do Espectro Autista', 'tema', true, true), + ('luto', 'Luto e Perdas', 'tema', true, true), + ('dependencia_quimica', 'Dependência Química', 'tema', true, true), + ('transtornos_alimentares', 'Transtornos Alimentares', 'tema', true, true), + ('trauma', 'Trauma e Estresse Pós-Traumático', 'tema', true, true), + + -- Catch-all + ('outra', 'Outra', 'outro', true, true) +ON CONFLICT (key) DO NOTHING; + +COMMIT; diff --git a/src/services/specialtiesService.js b/src/services/specialtiesService.js new file mode 100644 index 0000000..a613d2d --- /dev/null +++ b/src/services/specialtiesService.js @@ -0,0 +1,112 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/services/specialtiesService.js +| +| Service pra catálogo de especialidades + manage as escolhidas do profile. +| ROADMAP item #9 (Compliance CFP). +| +| Schema: ver migrations/20260521000004_specialties.sql + seed_050. +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; + +const SPECIALTY_SELECT = 'id, key, name, category, is_system, active'; +const PROFILE_SPECIALTY_SELECT = 'profile_id, specialty_id, other_label, created_at'; + +/** + * Lista catálogo de especialidades ativas. Ordem: category, name. + * + * @param {Object} [opts] + * @param {string} [opts.category] - filtra por categoria (psicologia, abordagem, publico, tema, outro) + */ +export async function listSpecialties({ category } = {}) { + let q = supabase.from('specialties').select(SPECIALTY_SELECT).eq('active', true).order('category', { ascending: true }).order('name', { ascending: true }); + if (category) q = q.eq('category', category); + const { data, error } = await q; + if (error) throw error; + return data || []; +} + +/** + * Lê especialidades do profile passado (default: user logado via auth.uid()). + * + * @param {string} [profileId] + */ +export async function getProfileSpecialties(profileId = null) { + let pid = profileId; + if (!pid) { + const { data: userData } = await supabase.auth.getUser(); + pid = userData?.user?.id; + if (!pid) throw new Error('Sessão inválida.'); + } + + const { data, error } = await supabase + .from('profile_specialties') + .select(`${PROFILE_SPECIALTY_SELECT}, specialty:specialties (${SPECIALTY_SELECT})`) + .eq('profile_id', pid); + + if (error) throw error; + return (data || []).map((r) => ({ + ...r, + // flatten — UI espera campos do specialty direto + key: r.specialty?.key, + name: r.specialty?.name, + category: r.specialty?.category + })); +} + +/** + * Substitui completamente as especialidades do user (delete + insert pattern). + * Other label preenchido se key === 'outra'. + * + * @param {Array<{specialty_id, other_label?}>} specialties + * @param {string} [profileId] + */ +export async function setProfileSpecialties(specialties, profileId = null) { + let pid = profileId; + if (!pid) { + const { data: userData } = await supabase.auth.getUser(); + pid = userData?.user?.id; + if (!pid) throw new Error('Sessão inválida.'); + } + + // 1. Delete existentes + const { error: delErr } = await supabase.from('profile_specialties').delete().eq('profile_id', pid); + if (delErr) throw delErr; + + // 2. Insert novos (skip se array vazio) + if (!specialties?.length) return []; + + const rows = specialties.map((s) => ({ + profile_id: pid, + specialty_id: s.specialty_id, + other_label: s.other_label ? String(s.other_label).trim() || null : null + })); + + const { data, error } = await supabase.from('profile_specialties').insert(rows).select(PROFILE_SPECIALTY_SELECT); + if (error) throw error; + return data || []; +} + +/** + * Lista profiles que têm uma especialidade específica (perfil público / busca). + * Use só pra contextos onde tenant_admin/saas tem permissão de ver outros profiles. + * + * @param {string} specialtyKey - ex: 'tcc', 'psicanalise' + */ +export async function listProfilesBySpecialty(specialtyKey) { + if (!specialtyKey) return []; + + // 1. Pega specialty.id pela key + const { data: spec } = await supabase.from('specialties').select('id').eq('key', specialtyKey).eq('active', true).maybeSingle(); + + if (!spec) return []; + + // 2. Lê profile_specialties + join profiles + const { data, error } = await supabase.from('profile_specialties').select('profile_id, profiles!profile_id (id, full_name, avatar_url, bio, professional_registration_type, professional_registration_number, professional_registration_uf)').eq('specialty_id', spec.id); + + if (error) throw error; + return (data || []).map((r) => r.profiles).filter(Boolean); +}