-- ============================================================================= -- MIGRATION: patients — melhorias completas -- Gerado em: 2025-03 -- Estratégia: cirúrgico — só adiciona, nunca destrói o que existe -- ============================================================================= -- ----------------------------------------------------------------------------- -- 1. ALTERAÇÕES NA TABELA patients -- Novos campos adicionados sem tocar nos existentes -- ----------------------------------------------------------------------------- ALTER TABLE public.patients -- Identidade & pronomes ADD COLUMN IF NOT EXISTS nome_social text, ADD COLUMN IF NOT EXISTS pronomes text, -- Dados socioeconômicos (opcionais, clínicamente relevantes) ADD COLUMN IF NOT EXISTS etnia text, ADD COLUMN IF NOT EXISTS religiao text, ADD COLUMN IF NOT EXISTS faixa_renda text, -- Preferências de comunicação (alimenta lembretes automáticos) ADD COLUMN IF NOT EXISTS canal_preferido text DEFAULT 'whatsapp', ADD COLUMN IF NOT EXISTS horario_contato_inicio time DEFAULT '08:00', ADD COLUMN IF NOT EXISTS horario_contato_fim time DEFAULT '20:00', ADD COLUMN IF NOT EXISTS idioma text DEFAULT 'pt-BR', -- Origem estruturada (permite filtros e relatórios) ADD COLUMN IF NOT EXISTS origem text, -- Financeiro ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text, -- Ciclo de vida ADD COLUMN IF NOT EXISTS motivo_saida text, ADD COLUMN IF NOT EXISTS data_saida date, ADD COLUMN IF NOT EXISTS encaminhado_para text, -- Risco clínico (flag de atenção visível no topo do cadastro) ADD COLUMN IF NOT EXISTS risco_elevado boolean DEFAULT false NOT NULL, ADD COLUMN IF NOT EXISTS risco_nota text, ADD COLUMN IF NOT EXISTS risco_sinalizado_em timestamp with time zone, ADD COLUMN IF NOT EXISTS risco_sinalizado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL; -- Constraints de validação para novos campos enum-like ALTER TABLE public.patients DROP CONSTRAINT IF EXISTS patients_canal_preferido_check, ADD CONSTRAINT patients_canal_preferido_check CHECK (canal_preferido IS NULL OR canal_preferido = ANY ( ARRAY['whatsapp','email','sms','telefone'] )); ALTER TABLE public.patients DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_check, ADD CONSTRAINT patients_metodo_pagamento_check CHECK (metodo_pagamento_preferido IS NULL OR metodo_pagamento_preferido = ANY ( ARRAY['pix','cartao','dinheiro','deposito','convenio'] )); ALTER TABLE public.patients DROP CONSTRAINT IF EXISTS patients_faixa_renda_check, ADD CONSTRAINT patients_faixa_renda_check CHECK (faixa_renda IS NULL OR faixa_renda = ANY ( ARRAY['ate_1sm','1_3sm','3_6sm','6_10sm','acima_10sm','nao_informado'] )); -- Constraint: risco_elevado = true exige nota e sinalizante ALTER TABLE public.patients DROP CONSTRAINT IF EXISTS patients_risco_consistency_check, ADD CONSTRAINT patients_risco_consistency_check CHECK ( (risco_elevado = false) OR (risco_elevado = true AND risco_nota IS NOT NULL AND risco_sinalizado_por IS NOT NULL) ); -- Comments COMMENT ON COLUMN public.patients.nome_social IS 'Nome social preferido — exibido no lugar do nome completo quando preenchido'; COMMENT ON COLUMN public.patients.pronomes IS 'Pronomes preferidos: ele/dele, ela/dela, eles/deles, etc.'; COMMENT ON COLUMN public.patients.etnia IS 'Autodeclaração étnico-racial (opcional)'; COMMENT ON COLUMN public.patients.religiao IS 'Religião ou espiritualidade (opcional, relevante clinicamente)'; COMMENT ON COLUMN public.patients.faixa_renda IS 'Faixa de renda em salários mínimos — usado para precificação solidária'; COMMENT ON COLUMN public.patients.canal_preferido IS 'Canal de comunicação preferido para lembretes e notificações'; COMMENT ON COLUMN public.patients.horario_contato_inicio IS 'Início da janela de horário preferida para contato'; COMMENT ON COLUMN public.patients.horario_contato_fim IS 'Fim da janela de horário preferida para contato'; COMMENT ON COLUMN public.patients.origem IS 'Como o paciente chegou: indicacao, agendador, redes_sociais, encaminhamento, outro'; COMMENT ON COLUMN public.patients.metodo_pagamento_preferido IS 'Método de pagamento habitual — sugerido ao criar cobrança'; COMMENT ON COLUMN public.patients.motivo_saida IS 'Motivo da alta, inativação ou encaminhamento'; COMMENT ON COLUMN public.patients.data_saida IS 'Data em que o paciente foi desligado/encaminhado'; COMMENT ON COLUMN public.patients.encaminhado_para IS 'Nome ou serviço para onde o paciente foi encaminhado'; COMMENT ON COLUMN public.patients.risco_elevado IS 'Flag de atenção clínica — exibe alerta no topo do cadastro e prontuário'; COMMENT ON COLUMN public.patients.risco_nota IS 'Descrição do risco (obrigatória quando risco_elevado = true)'; COMMENT ON COLUMN public.patients.risco_sinalizado_em IS 'Timestamp em que o risco foi sinalizado'; COMMENT ON COLUMN public.patients.risco_sinalizado_por IS 'Usuário que sinalizou o risco'; -- Índices úteis para filtros frequentes CREATE INDEX IF NOT EXISTS idx_patients_risco_elevado ON public.patients (tenant_id, risco_elevado) WHERE risco_elevado = true; CREATE INDEX IF NOT EXISTS idx_patients_status_tenant ON public.patients (tenant_id, status); CREATE INDEX IF NOT EXISTS idx_patients_origem ON public.patients (tenant_id, origem) WHERE origem IS NOT NULL; -- ----------------------------------------------------------------------------- -- 2. TABELA patient_contacts -- Substitui os campos soltos nome_parente/telefone_parente na tabela principal -- Os campos antigos ficam intactos (retrocompatibilidade) -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.patient_contacts ( id uuid DEFAULT gen_random_uuid() NOT NULL, patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE, tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, -- Identificação nome text NOT NULL, tipo text NOT NULL, -- emergencia | responsavel_legal | profissional_saude | outro relacao text, -- mãe, pai, psiquiatra, médico, cônjuge... -- Contato telefone text, email text, cpf text, -- Profissional de saúde especialidade text, -- preenchido quando tipo = profissional_saude registro_profissional text, -- CRM, CRP, etc. -- Flags is_primario boolean DEFAULT false NOT NULL, -- contato principal de emergência ativo boolean DEFAULT true NOT NULL, -- Auditoria created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT patient_contacts_pkey PRIMARY KEY (id), CONSTRAINT patient_contacts_tipo_check CHECK (tipo = ANY ( ARRAY['emergencia','responsavel_legal','profissional_saude','outro'] )) ); COMMENT ON TABLE public.patient_contacts IS 'Contatos vinculados ao paciente: emergência, responsável legal, outros profissionais de saúde'; COMMENT ON COLUMN public.patient_contacts.tipo IS 'Categoria do contato: emergencia | responsavel_legal | profissional_saude | outro'; COMMENT ON COLUMN public.patient_contacts.is_primario IS 'Contato de emergência principal — exibido em destaque no cadastro'; -- Garante no máximo 1 contato primário por paciente CREATE UNIQUE INDEX IF NOT EXISTS uq_patient_contacts_primario ON public.patient_contacts (patient_id) WHERE is_primario = true AND ativo = true; CREATE INDEX IF NOT EXISTS idx_patient_contacts_patient ON public.patient_contacts (patient_id); CREATE INDEX IF NOT EXISTS idx_patient_contacts_tenant ON public.patient_contacts (tenant_id); -- updated_at automático CREATE TRIGGER trg_patient_contacts_updated_at BEFORE UPDATE ON public.patient_contacts FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); -- RLS — mesmas regras de patients ALTER TABLE public.patient_contacts ENABLE ROW LEVEL SECURITY; CREATE POLICY patient_contacts_select ON public.patient_contacts FOR SELECT USING ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.view') ); CREATE POLICY patient_contacts_write ON public.patient_contacts USING ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit') ) WITH CHECK ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit') ); -- ----------------------------------------------------------------------------- -- 3. TABELA patient_status_history -- Trilha de auditoria de todas as mudanças de status do paciente -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.patient_status_history ( id uuid DEFAULT gen_random_uuid() NOT NULL, patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE, tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, status_anterior text, -- NULL na primeira inserção status_novo text NOT NULL, motivo text, encaminhado_para text, -- preenchido quando status = Encaminhado data_saida date, -- preenchido quando Alta/Encaminhado/Arquivado alterado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL, alterado_em timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT patient_status_history_pkey PRIMARY KEY (id), CONSTRAINT psh_status_novo_check CHECK (status_novo = ANY ( ARRAY['Ativo','Inativo','Alta','Encaminhado','Arquivado'] )) ); COMMENT ON TABLE public.patient_status_history IS 'Histórico imutável de todas as mudanças de status do paciente — não editar, apenas inserir'; CREATE INDEX IF NOT EXISTS idx_psh_patient ON public.patient_status_history (patient_id, alterado_em DESC); CREATE INDEX IF NOT EXISTS idx_psh_tenant ON public.patient_status_history (tenant_id, alterado_em DESC); -- RLS ALTER TABLE public.patient_status_history ENABLE ROW LEVEL SECURITY; CREATE POLICY psh_select ON public.patient_status_history FOR SELECT USING ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.view') ); CREATE POLICY psh_insert ON public.patient_status_history FOR INSERT WITH CHECK ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit') ); -- Trigger: registra automaticamente no histórico quando status muda em patients CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_history() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN INSERT INTO public.patient_status_history ( patient_id, tenant_id, status_anterior, status_novo, motivo, encaminhado_para, data_saida, alterado_por, alterado_em ) VALUES ( NEW.id, NEW.tenant_id, CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END, NEW.status, NEW.motivo_saida, NEW.encaminhado_para, NEW.data_saida, auth.uid(), now() ); END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_patient_status_history ON public.patients; CREATE TRIGGER trg_patient_status_history AFTER INSERT OR UPDATE OF status ON public.patients FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_history(); -- ----------------------------------------------------------------------------- -- 4. TABELA patient_timeline -- Feed cronológico automático de eventos relevantes do paciente -- ----------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.patient_timeline ( id uuid DEFAULT gen_random_uuid() NOT NULL, patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE, tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE, -- Tipo do evento evento_tipo text NOT NULL, -- Exemplos: primeira_sessao | sessao_realizada | sessao_cancelada | falta | -- status_alterado | risco_sinalizado | documento_assinado | -- escala_respondida | pagamento_vencido | pagamento_recebido | -- tarefa_combinada | contato_adicionado | prontuario_editado titulo text NOT NULL, -- Ex: "Sessão realizada" descricao text, -- Ex: "Sessão 47 · presencial · 50min" icone_cor text DEFAULT 'gray', -- green | blue | amber | red | gray link_ref_tipo text, -- agenda_evento | financial_record | documento | escala link_ref_id uuid, -- FK genérico — sem constraint formal (polimórfico) gerado_por uuid REFERENCES auth.users(id) ON DELETE SET NULL, ocorrido_em timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT patient_timeline_pkey PRIMARY KEY (id), CONSTRAINT pt_evento_tipo_check CHECK (evento_tipo = ANY (ARRAY[ 'primeira_sessao','sessao_realizada','sessao_cancelada','falta', 'status_alterado','risco_sinalizado','risco_removido', 'documento_assinado','documento_adicionado', 'escala_respondida','escala_enviada', 'pagamento_vencido','pagamento_recebido', 'tarefa_combinada','contato_adicionado', 'prontuario_editado','nota_adicionada','manual' ])), CONSTRAINT pt_icone_cor_check CHECK (icone_cor = ANY ( ARRAY['green','blue','amber','red','gray','purple'] )) ); COMMENT ON TABLE public.patient_timeline IS 'Feed cronológico de eventos do paciente — alimentado por triggers e inserções manuais'; COMMENT ON COLUMN public.patient_timeline.link_ref_tipo IS 'Tipo da entidade referenciada (polimórfico): agenda_evento | financial_record | documento | escala'; COMMENT ON COLUMN public.patient_timeline.link_ref_id IS 'ID da entidade referenciada — sem FK formal para suportar múltiplos tipos'; CREATE INDEX IF NOT EXISTS idx_pt_patient_ocorrido ON public.patient_timeline (patient_id, ocorrido_em DESC); CREATE INDEX IF NOT EXISTS idx_pt_tenant ON public.patient_timeline (tenant_id, ocorrido_em DESC); CREATE INDEX IF NOT EXISTS idx_pt_evento_tipo ON public.patient_timeline (patient_id, evento_tipo); -- RLS ALTER TABLE public.patient_timeline ENABLE ROW LEVEL SECURITY; CREATE POLICY pt_select ON public.patient_timeline FOR SELECT USING ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.view') ); CREATE POLICY pt_insert ON public.patient_timeline FOR INSERT WITH CHECK ( public.is_clinic_tenant(tenant_id) AND public.is_tenant_member(tenant_id) AND public.tenant_has_feature(tenant_id, 'patients.edit') ); -- Trigger: registra na timeline quando risco é sinalizado/removido CREATE OR REPLACE FUNCTION public.trg_fn_patient_risco_timeline() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN INSERT INTO public.patient_timeline ( patient_id, tenant_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em ) VALUES ( NEW.id, NEW.tenant_id, CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END, CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END, NEW.risco_nota, CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END, auth.uid(), now() ); END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_patient_risco_timeline ON public.patients; CREATE TRIGGER trg_patient_risco_timeline AFTER UPDATE OF risco_elevado ON public.patients FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_risco_timeline(); -- Trigger: registra na timeline quando status muda CREATE OR REPLACE FUNCTION public.trg_fn_patient_status_timeline() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN INSERT INTO public.patient_timeline ( patient_id, tenant_id, evento_tipo, titulo, descricao, icone_cor, gerado_por, ocorrido_em ) VALUES ( NEW.id, NEW.tenant_id, 'status_alterado', 'Status alterado para ' || NEW.status, CASE WHEN TG_OP = 'INSERT' THEN 'Paciente cadastrado' ELSE 'De ' || OLD.status || ' → ' || NEW.status || CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END END, CASE NEW.status WHEN 'Ativo' THEN 'green' WHEN 'Alta' THEN 'blue' WHEN 'Inativo' THEN 'gray' WHEN 'Encaminhado' THEN 'amber' WHEN 'Arquivado' THEN 'gray' ELSE 'gray' END, auth.uid(), now() ); END IF; RETURN NEW; END; $$; DROP TRIGGER IF EXISTS trg_patient_status_timeline ON public.patients; CREATE TRIGGER trg_patient_status_timeline AFTER INSERT OR UPDATE OF status ON public.patients FOR EACH ROW EXECUTE FUNCTION public.trg_fn_patient_status_timeline(); -- ----------------------------------------------------------------------------- -- 5. VIEW v_patient_engajamento -- Score calculado em tempo real — sem armazenar, sem inconsistência -- ----------------------------------------------------------------------------- CREATE OR REPLACE VIEW public.v_patient_engajamento WITH (security_invoker = on) AS WITH sessoes AS ( SELECT ae.patient_id, ae.tenant_id, COUNT(*) FILTER (WHERE ae.status = 'realizado') AS total_realizadas, COUNT(*) FILTER (WHERE ae.status IN ('realizado','cancelado','faltou')) AS total_marcadas, COUNT(*) FILTER (WHERE ae.status = 'faltou') AS total_faltas, MAX(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS ultima_sessao_em, MIN(ae.inicio_em) FILTER (WHERE ae.status = 'realizado') AS primeira_sessao_em, COUNT(*) FILTER (WHERE ae.status = 'realizado' AND ae.inicio_em >= now() - interval '30 days') AS sessoes_ultimo_mes FROM public.agenda_eventos ae WHERE ae.patient_id IS NOT NULL GROUP BY ae.patient_id, ae.tenant_id ), financeiro AS ( SELECT fr.patient_id, fr.tenant_id, COALESCE(SUM(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS total_pago, COALESCE(AVG(fr.final_amount) FILTER (WHERE fr.status = 'paid'), 0) AS ticket_medio, COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue') AND fr.due_date < now()) AS cobr_vencidas, COUNT(*) FILTER (WHERE fr.status IN ('pending','overdue')) AS cobr_pendentes, COUNT(*) FILTER (WHERE fr.type = 'receita' AND fr.status = 'paid') AS cobr_pagas FROM public.financial_records fr WHERE fr.patient_id IS NOT NULL AND fr.deleted_at IS NULL GROUP BY fr.patient_id, fr.tenant_id ) SELECT p.id AS patient_id, p.tenant_id, p.nome_completo, p.status, p.risco_elevado, -- Sessões COALESCE(s.total_realizadas, 0) AS total_sessoes, COALESCE(s.sessoes_ultimo_mes, 0) AS sessoes_ultimo_mes, s.primeira_sessao_em, s.ultima_sessao_em, EXTRACT(DAY FROM now() - s.ultima_sessao_em)::int AS dias_sem_sessao, -- Taxa de comparecimento (%) CASE WHEN COALESCE(s.total_marcadas, 0) = 0 THEN NULL ELSE ROUND((s.total_realizadas::numeric / s.total_marcadas) * 100, 1) END AS taxa_comparecimento, -- Financeiro COALESCE(f.total_pago, 0) AS ltv_total, ROUND(COALESCE(f.ticket_medio, 0), 2) AS ticket_medio, COALESCE(f.cobr_vencidas, 0) AS cobr_vencidas, COALESCE(f.cobr_pagas, 0) AS cobr_pagas, -- Taxa de pagamentos em dia (%) CASE WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN NULL ELSE ROUND( f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 100, 1 ) END AS taxa_pagamentos_dia, -- Score de engajamento composto (0-100) -- Pesos: comparecimento 50%, pagamentos 30%, recência 20% ROUND( LEAST(100, COALESCE( ( -- Comparecimento (50 pts) CASE WHEN COALESCE(s.total_marcadas, 0) = 0 THEN 50 ELSE LEAST(50, (s.total_realizadas::numeric / s.total_marcadas) * 50) END + -- Pagamentos em dia (30 pts) CASE WHEN COALESCE(f.cobr_pagas + f.cobr_vencidas, 0) = 0 THEN 30 ELSE LEAST(30, f.cobr_pagas::numeric / (f.cobr_pagas + f.cobr_vencidas) * 30) END + -- Recência (20 pts — penaliza quem está há muito tempo sem sessão) CASE WHEN s.ultima_sessao_em IS NULL THEN 0 WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 14 THEN 20 WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 30 THEN 15 WHEN EXTRACT(DAY FROM now() - s.ultima_sessao_em) <= 60 THEN 8 ELSE 0 END ), 0 ) ) , 0) AS engajamento_score, -- Duração do tratamento CASE WHEN s.primeira_sessao_em IS NULL THEN NULL ELSE EXTRACT(DAY FROM now() - s.primeira_sessao_em)::int END AS duracao_tratamento_dias FROM public.patients p LEFT JOIN sessoes s ON s.patient_id = p.id AND s.tenant_id = p.tenant_id LEFT JOIN financeiro f ON f.patient_id = p.id AND f.tenant_id = p.tenant_id; COMMENT ON VIEW public.v_patient_engajamento IS 'Score de engajamento e métricas consolidadas por paciente. Calculado em tempo real via RLS (security_invoker=on).'; -- ----------------------------------------------------------------------------- -- 6. VIEW v_patients_risco -- Lista rápida de pacientes que precisam de atenção imediata -- ----------------------------------------------------------------------------- CREATE OR REPLACE VIEW public.v_patients_risco WITH (security_invoker = on) AS SELECT p.id, p.tenant_id, p.nome_completo, p.status, p.risco_elevado, p.risco_nota, p.risco_sinalizado_em, e.dias_sem_sessao, e.engajamento_score, e.taxa_comparecimento, -- Motivo do alerta CASE WHEN p.risco_elevado THEN 'risco_sinalizado' WHEN COALESCE(e.dias_sem_sessao, 999) > 30 AND p.status = 'Ativo' THEN 'sem_sessao_30d' WHEN COALESCE(e.taxa_comparecimento, 100) < 60 THEN 'baixo_comparecimento' WHEN COALESCE(e.cobr_vencidas, 0) > 0 THEN 'cobranca_vencida' ELSE 'ok' END AS alerta_tipo FROM public.patients p JOIN public.v_patient_engajamento e ON e.patient_id = p.id WHERE p.status = 'Ativo' AND ( p.risco_elevado = true OR COALESCE(e.dias_sem_sessao, 999) > 30 OR COALESCE(e.taxa_comparecimento, 100) < 60 OR COALESCE(e.cobr_vencidas, 0) > 0 ); COMMENT ON VIEW public.v_patients_risco IS 'Pacientes ativos que precisam de atenção: risco clínico, sem sessão há 30+ dias, baixo comparecimento ou cobrança vencida'; -- ----------------------------------------------------------------------------- -- 7. Migração de dados: popular patient_contacts com os dados já existentes -- Roda só uma vez — protegido por WHERE NOT EXISTS -- ----------------------------------------------------------------------------- INSERT INTO public.patient_contacts ( patient_id, tenant_id, nome, tipo, relacao, telefone, is_primario, ativo ) SELECT p.id, p.tenant_id, p.nome_parente, 'emergencia', p.grau_parentesco, p.telefone_parente, true, true FROM public.patients p WHERE p.nome_parente IS NOT NULL AND p.telefone_parente IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM public.patient_contacts pc WHERE pc.patient_id = p.id AND pc.tipo = 'emergencia' ); -- Migra responsável legal quando diferente do parente de emergência INSERT INTO public.patient_contacts ( patient_id, tenant_id, nome, tipo, relacao, telefone, cpf, is_primario, ativo ) SELECT p.id, p.tenant_id, p.nome_responsavel, 'responsavel_legal', 'Responsável legal', p.telefone_responsavel, p.cpf_responsavel, false, true FROM public.patients p WHERE p.nome_responsavel IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM public.patient_contacts pc WHERE pc.patient_id = p.id AND pc.tipo = 'responsavel_legal' ); -- ----------------------------------------------------------------------------- -- 8. Seed do histórico de status para pacientes já existentes -- Cria a primeira entrada de histórico com o status atual -- ----------------------------------------------------------------------------- INSERT INTO public.patient_status_history ( patient_id, tenant_id, status_anterior, status_novo, motivo, alterado_em ) SELECT p.id, p.tenant_id, NULL, p.status, 'Status inicial — migração de dados', COALESCE(p.created_at, now()) FROM public.patients p WHERE NOT EXISTS ( SELECT 1 FROM public.patient_status_history psh WHERE psh.patient_id = p.id ); -- ============================================================================= -- FIM DO MIGRATION -- Resumo do que foi feito: -- 1. ALTER TABLE patients — 16 novos campos (pronomes, risco, origem, etc.) -- 2. CREATE TABLE patient_contacts — múltiplos contatos por paciente -- 3. CREATE TABLE patient_status_history — trilha imutável de mudanças de status -- 4. CREATE TABLE patient_timeline — feed cronológico de eventos -- 5. Triggers automáticos — status history, timeline de risco e status -- 6. VIEW v_patient_engajamento — score 0-100 + métricas calculadas em tempo real -- 7. VIEW v_patients_risco — lista de pacientes que precisam de atenção -- 8. Migração de dados — popula patient_contacts e status_history com dados existentes -- =============================================================================