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