compliance CFP: #5 registro profissional + #9 especialidades

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) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:21:03 -03:00
parent de3898878a
commit cd67f7e9f5
4 changed files with 319 additions and 0 deletions
@@ -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;
@@ -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;
@@ -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;
+112
View File
@@ -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);
}