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:
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user