Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions

View File

@@ -0,0 +1,57 @@
-- ============================================================
-- Migration 002 — SetupWizard1: campos Negócio e Atendimento
-- ============================================================
-- Tabela: tenants (Step 2 — Negócio)
-- Tabela: agenda_configuracoes (Step 3 — Atendimento)
-- ============================================================
-- ----------------------------------------------------------
-- tenants: dados do negócio
-- ----------------------------------------------------------
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS business_type text,
ADD COLUMN IF NOT EXISTS logo_url text,
ADD COLUMN IF NOT EXISTS address text,
ADD COLUMN IF NOT EXISTS phone text,
ADD COLUMN IF NOT EXISTS contact_email text,
ADD COLUMN IF NOT EXISTS site_url text,
ADD COLUMN IF NOT EXISTS social_instagram text;
-- Valores aceitos: consultorio | clinica | instituto | grupo
ALTER TABLE public.tenants
ADD CONSTRAINT tenants_business_type_check
CHECK (business_type IS NULL OR business_type = ANY (ARRAY[
'consultorio'::text,
'clinica'::text,
'instituto'::text,
'grupo'::text
]));
-- ----------------------------------------------------------
-- agenda_configuracoes: modo de atendimento
-- ----------------------------------------------------------
ALTER TABLE public.agenda_configuracoes
ADD COLUMN IF NOT EXISTS atendimento_mode text DEFAULT 'particular'::text;
ALTER TABLE public.agenda_configuracoes
ADD CONSTRAINT agenda_configuracoes_atendimento_mode_check
CHECK (atendimento_mode IS NULL OR atendimento_mode = ANY (ARRAY[
'particular'::text,
'convenio'::text,
'ambos'::text
]));
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.business_type IS 'Tipo de negócio: consultorio, clinica, instituto, grupo';
COMMENT ON COLUMN public.tenants.logo_url IS 'URL da logo do negócio (Storage bucket)';
COMMENT ON COLUMN public.tenants.address IS 'Endereço do negócio (texto livre)';
COMMENT ON COLUMN public.tenants.phone IS 'Telefone/WhatsApp do negócio';
COMMENT ON COLUMN public.tenants.contact_email IS 'E-mail público de contato do negócio';
COMMENT ON COLUMN public.tenants.site_url IS 'Site do negócio';
COMMENT ON COLUMN public.tenants.social_instagram IS 'Instagram do negócio (sem @)';
COMMENT ON COLUMN public.agenda_configuracoes.atendimento_mode IS 'Modo de atendimento: particular | convenio | ambos';

View File

@@ -0,0 +1,33 @@
-- ============================================================
-- Migration 003 — Tenants: campos de endereço detalhado
-- ============================================================
-- Substitui o campo address (texto livre) por campos estruturados
-- preenchidos via consulta de CEP (ViaCEP)
-- ============================================================
ALTER TABLE public.tenants
ADD COLUMN IF NOT EXISTS cep text,
ADD COLUMN IF NOT EXISTS logradouro text,
ADD COLUMN IF NOT EXISTS numero text,
ADD COLUMN IF NOT EXISTS complemento text,
ADD COLUMN IF NOT EXISTS bairro text,
ADD COLUMN IF NOT EXISTS cidade text,
ADD COLUMN IF NOT EXISTS estado text;
-- Migra dados existentes do campo address para logradouro
UPDATE public.tenants
SET logradouro = address
WHERE address IS NOT NULL
AND logradouro IS NULL;
-- ----------------------------------------------------------
-- Comments
-- ----------------------------------------------------------
COMMENT ON COLUMN public.tenants.cep IS 'CEP do endereço do negócio';
COMMENT ON COLUMN public.tenants.logradouro IS 'Logradouro (rua, avenida, etc.)';
COMMENT ON COLUMN public.tenants.numero IS 'Número do endereço';
COMMENT ON COLUMN public.tenants.complemento IS 'Complemento (sala, andar, etc.)';
COMMENT ON COLUMN public.tenants.bairro IS 'Bairro';
COMMENT ON COLUMN public.tenants.cidade IS 'Cidade';
COMMENT ON COLUMN public.tenants.estado IS 'UF (2 letras)';

View File

@@ -0,0 +1,147 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `medicos`
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026 · São Carlos/SP — Brasil
--
-- Propósito:
-- Armazena médicos e profissionais de referência (psiquiatras, neurologistas,
-- clínicos gerais, etc.) que encaminham pacientes ou fazem parte da rede de
-- suporte clínico do terapeuta.
--
-- Usado em:
-- - PatientsCadastroPage: campo "Encaminhado por" (FK medico_id)
-- - CadastroRapidoMedico.vue: cadastro rápido dentro do formulário
-- - MedicosCadastroPage.vue: página completa de gestão de médicos
--
-- Relacionamentos:
-- medicos.owner_id → auth.users(id)
-- medicos.tenant_id → tenants(id)
-- patients.medico_encaminhador_id → medicos(id) (opcional, ver abaixo)
--
-- RLS: owner_id = auth.uid() — cada profissional vê apenas seus médicos.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.medicos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Identidade profissional
nome text NOT NULL,
crm text, -- Ex: "123456/SP"
especialidade text, -- Ex: "Psiquiatria"
-- Contatos — telefone_pessoal é sensível (exibido com ícone de olho)
telefone_profissional text, -- Consultório / clínica
telefone_pessoal text, -- WhatsApp / pessoal
email text,
-- Local de atuação
clinica text, -- Nome da clínica/hospital
cidade text,
estado text DEFAULT 'SP',
-- Notas internas do terapeuta
observacoes text,
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT medicos_pkey PRIMARY KEY (id),
-- CRM único por owner (mesmo terapeuta não cadastra o mesmo CRM duas vezes)
CONSTRAINT medicos_crm_owner_unique UNIQUE NULLS NOT DISTINCT (owner_id, crm)
);
-- --------------------------------------------------------------------------
-- 2. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS medicos_owner_idx
ON public.medicos USING btree (owner_id);
CREATE INDEX IF NOT EXISTS medicos_tenant_idx
ON public.medicos USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS medicos_nome_idx
ON public.medicos USING btree (nome);
CREATE INDEX IF NOT EXISTS medicos_especialidade_idx
ON public.medicos USING btree (especialidade);
-- Busca textual por nome e especialidade
CREATE INDEX IF NOT EXISTS medicos_nome_trgm_idx
ON public.medicos USING gin (nome gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger de updated_at
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.set_medicos_updated_at()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_medicos_updated_at
BEFORE UPDATE ON public.medicos
FOR EACH ROW
EXECUTE FUNCTION public.set_medicos_updated_at();
-- --------------------------------------------------------------------------
-- 4. Row Level Security
-- --------------------------------------------------------------------------
ALTER TABLE public.medicos ENABLE ROW LEVEL SECURITY;
-- Owner tem acesso total aos seus próprios médicos
CREATE POLICY "medicos: owner full access"
ON public.medicos
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 5. Comentários de documentação
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.medicos IS 'Médicos e profissionais de referência cadastrados pelo terapeuta.';
COMMENT ON COLUMN public.medicos.owner_id IS 'Terapeuta dono do cadastro (auth.uid()).';
COMMENT ON COLUMN public.medicos.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.medicos.nome IS 'Nome completo do médico/profissional.';
COMMENT ON COLUMN public.medicos.crm IS 'CRM com UF. Ex: 123456/SP. Único por owner_id.';
COMMENT ON COLUMN public.medicos.especialidade IS 'Especialidade médica. Ex: Psiquiatria, Neurologia.';
COMMENT ON COLUMN public.medicos.telefone_profissional IS 'Telefone do consultório ou clínica.';
COMMENT ON COLUMN public.medicos.telefone_pessoal IS 'Telefone pessoal / WhatsApp. Campo sensível.';
COMMENT ON COLUMN public.medicos.email IS 'E-mail profissional.';
COMMENT ON COLUMN public.medicos.clinica IS 'Nome da clínica ou hospital onde atua.';
COMMENT ON COLUMN public.medicos.cidade IS 'Cidade de atuação.';
COMMENT ON COLUMN public.medicos.estado IS 'UF de atuação. Default SP.';
COMMENT ON COLUMN public.medicos.observacoes IS 'Notas internas do terapeuta sobre o médico.';
COMMENT ON COLUMN public.medicos.ativo IS 'Soft delete: false oculta da listagem.';
-- --------------------------------------------------------------------------
-- 6. Coluna FK opcional em patients
-- (Conecta "Encaminhado por" ao cadastro de médico)
-- Execute apenas se quiser a FK estruturada; caso contrário,
-- o campo encaminhado_por (text) no PatientsCadastroPage já funciona.
-- --------------------------------------------------------------------------
-- ALTER TABLE public.patients
-- ADD COLUMN IF NOT EXISTS medico_encaminhador_id uuid
-- REFERENCES public.medicos(id) ON DELETE SET NULL;
-- CREATE INDEX IF NOT EXISTS patients_medico_encaminhador_idx
-- ON public.patients USING btree (medico_encaminhador_id);
-- COMMENT ON COLUMN public.patients.medico_encaminhador_id
-- IS 'FK para medicos.id — quem encaminhou o paciente.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,119 @@
-- ==========================================================================
-- Agência PSI — Migração: novos campos em `patients`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000002_patients_new_columns.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Adiciona as colunas identificadas na engenharia reversa da tela de detalhe
-- (PatientsDetailPage) que ainda não existiam na tabela `patients`.
--
-- Também ajusta os CHECK constraints de `status` e `patient_scope` para
-- aceitar os valores usados no novo formulário de cadastro.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Colunas novas
-- --------------------------------------------------------------------------
-- Identidade
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS pronomes text,
ADD COLUMN IF NOT EXISTS nome_social text,
ADD COLUMN IF NOT EXISTS etnia text;
-- Contato
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS canal_preferido text,
ADD COLUMN IF NOT EXISTS horario_contato text;
-- Clínico / convênio
-- convenio: nome de exibição (badge azul no header)
-- convenio_id: FK para insurance_plans (opcional — permite vincular ao cadastro)
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS convenio text,
ADD COLUMN IF NOT EXISTS convenio_id uuid REFERENCES public.insurance_plans(id) ON DELETE SET NULL;
-- Origem
ALTER TABLE public.patients
ADD COLUMN IF NOT EXISTS metodo_pagamento_preferido text,
ADD COLUMN IF NOT EXISTS motivo_saida text;
-- --------------------------------------------------------------------------
-- 2. Ajuste do CHECK constraint de `status`
-- Valores originais: Ativo | Inativo | Alta | Encaminhado | Arquivado
-- Valores novos: + Em espera
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- --------------------------------------------------------------------------
-- 3. Ajuste do CHECK constraint de `patient_scope`
-- Valores originais: clinic | therapist (valores técnicos internos)
-- Valores novos: + Clínica | Particular | Online | Híbrido
-- Estratégia: remover o constraint restritivo e deixar livre (text),
-- pois o controle já é feito no frontend via Select com opções fixas.
-- --------------------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
-- Também remove a constraint de consistência que dependia do scope antigo
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- --------------------------------------------------------------------------
-- 4. Índices de performance
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS patients_convenio_id_idx
ON public.patients USING btree (convenio_id);
CREATE INDEX IF NOT EXISTS patients_pronomes_idx
ON public.patients USING btree (pronomes);
CREATE INDEX IF NOT EXISTS patients_etnia_idx
ON public.patients USING btree (etnia);
-- --------------------------------------------------------------------------
-- 5. Comentários
-- --------------------------------------------------------------------------
COMMENT ON COLUMN public.patients.pronomes
IS 'Pronomes de tratamento. Ex: ela/dela, ele/dele. Exibido no header do perfil.';
COMMENT ON COLUMN public.patients.nome_social
IS 'Nome social / como prefere ser chamado(a) no atendimento.';
COMMENT ON COLUMN public.patients.etnia
IS 'Etnia / raça autodeclarada. Exibida no card "Dados pessoais".';
COMMENT ON COLUMN public.patients.canal_preferido
IS 'Canal preferido de contato. Ex: WhatsApp, Telefone, E-mail.';
COMMENT ON COLUMN public.patients.horario_contato
IS 'Horário preferido para contato. Ex: 08h18h.';
COMMENT ON COLUMN public.patients.convenio
IS 'Nome do convênio para exibição (badge azul no header). Derivado de convenio_id.';
COMMENT ON COLUMN public.patients.convenio_id
IS 'FK para insurance_plans.id. Vincula o paciente ao convênio cadastrado.';
COMMENT ON COLUMN public.patients.metodo_pagamento_preferido
IS 'Método de pagamento preferido. Ex: PIX, Cartão crédito. Exibido no card Origem.';
COMMENT ON COLUMN public.patients.motivo_saida
IS 'Motivo de encerramento do acompanhamento. Exibido no card Origem quando preenchido.';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,70 @@
-- ==========================================================================
-- Agência PSI — Migração: remove check constraints dos novos campos
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000003_patients_drop_check_constraints.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- O banco tinha CHECK constraints nos novos campos que foram adicionados
-- pela migration anterior (ou que já existiam no schema ao vivo).
-- O frontend já controla os valores via Select com opções fixas,
-- então os constraints são desnecessários e serão removidos.
-- ==========================================================================
-- canal_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_canal_preferido_check;
-- horario_contato
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_horario_contato_check;
-- pronomes
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_pronomes_check;
-- nome_social
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_nome_social_check;
-- etnia
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_etnia_check;
-- convenio
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_convenio_check;
-- metodo_pagamento_preferido
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_metodo_pagamento_preferido_check;
-- motivo_saida
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_motivo_saida_check;
-- status (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check CHECK (
status = ANY (ARRAY[
'Ativo'::text,
'Em espera'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
])
);
-- patient_scope (já ajustado na migration anterior, mas garante)
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_patient_scope_check;
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_therapist_scope_consistency;
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,56 @@
-- ==========================================================================
-- Agência PSI — Migração: tabela `patient_support_contacts`
-- ==========================================================================
-- Arquivo: supabase/migrations/20260328000004_create_patient_support_contacts.sql
-- Criado por: Leonardo Nohama · 2026 · São Carlos/SP
--
-- Contatos da rede de suporte do paciente.
-- Alimenta o card "Contatos & rede de suporte" na tela de detalhe.
-- is_primario = true → badge vermelho "emergência" no perfil.
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.patient_support_contacts (
id uuid DEFAULT gen_random_uuid() NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
nome text,
relacao text, -- Ex: mãe, psiquiatra, cônjuge
tipo text, -- emergencia | familiar | profissional_saude | amigo | outro
telefone text,
email text,
is_primario boolean DEFAULT false NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT patient_support_contacts_pkey PRIMARY KEY (id)
);
-- Índices
CREATE INDEX IF NOT EXISTS psc_patient_idx ON public.patient_support_contacts USING btree (patient_id);
CREATE INDEX IF NOT EXISTS psc_owner_idx ON public.patient_support_contacts USING btree (owner_id);
-- Trigger updated_at
CREATE TRIGGER trg_psc_updated_at
BEFORE UPDATE ON public.patient_support_contacts
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
-- RLS
ALTER TABLE public.patient_support_contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "psc: owner full access"
ON public.patient_support_contacts
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- Comentários
COMMENT ON TABLE public.patient_support_contacts IS 'Rede de suporte do paciente. Exibida no card "Contatos & rede de suporte" do perfil.';
COMMENT ON COLUMN public.patient_support_contacts.is_primario IS 'true = badge vermelho "emergência" no perfil do paciente.';
COMMENT ON COLUMN public.patient_support_contacts.tipo IS 'emergencia | familiar | profissional_saude | amigo | outro';
-- ==========================================================================
-- FIM DA MIGRAÇÃO
-- ==========================================================================

View File

@@ -0,0 +1,454 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Documentos & Arquivos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Modulo completo de documentos do paciente.
-- Tabelas: documents, document_access_logs, document_signatures,
-- document_share_links.
--
-- Relacionamentos:
-- documents.patient_id → patients(id)
-- documents.owner_id → auth.users(id)
-- documents.tenant_id → tenants(id)
-- documents.agenda_evento_id → agenda_eventos(id) (opcional)
-- document_access_logs.documento_id → documents(id)
-- document_signatures.documento_id → documents(id)
-- document_share_links.documento_id → documents(id)
--
-- RLS: owner_id = auth.uid() para documents, signatures e share_links.
-- access_logs: somente INSERT (imutavel) + SELECT por tenant.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela principal: documents
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.documents (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto de acesso
owner_id uuid NOT NULL,
tenant_id uuid NOT NULL,
-- Vinculo com paciente
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
-- Arquivo no Storage
bucket_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'documents',
nome_original text NOT NULL,
mime_type text,
tamanho_bytes bigint,
-- Classificacao
tipo_documento text NOT NULL DEFAULT 'outro',
-- laudo | receita | exame | termo_assinado | relatorio_externo
-- identidade | convenio | declaracao | atestado | recibo | outro
categoria text,
descricao text,
tags text[] DEFAULT '{}',
-- Vinculo opcional com sessao/nota
agenda_evento_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
session_note_id uuid,
-- Visibilidade & controle de acesso
visibilidade text NOT NULL DEFAULT 'privado',
-- privado | compartilhado_supervisor | compartilhado_portal
compartilhado_portal boolean DEFAULT false NOT NULL,
compartilhado_supervisor boolean DEFAULT false NOT NULL,
compartilhado_em timestamptz,
expira_compartilhamento timestamptz,
-- Upload pelo paciente (portal)
enviado_pelo_paciente boolean DEFAULT false NOT NULL,
status_revisao text DEFAULT 'aprovado',
-- pendente | aprovado | rejeitado
revisado_por uuid,
revisado_em timestamptz,
-- Quem fez upload
uploaded_by uuid NOT NULL,
uploaded_at timestamptz DEFAULT now() NOT NULL,
-- Soft delete com retencao (LGPD / CFP)
deleted_at timestamptz,
deleted_by uuid,
retencao_ate timestamptz,
-- Controle
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT documents_pkey PRIMARY KEY (id),
-- Validacoes
CONSTRAINT documents_tipo_check CHECK (
tipo_documento = ANY (ARRAY[
'laudo', 'receita', 'exame', 'termo_assinado', 'relatorio_externo',
'identidade', 'convenio', 'declaracao', 'atestado', 'recibo', 'outro'
])
),
CONSTRAINT documents_visibilidade_check CHECK (
visibilidade = ANY (ARRAY['privado', 'compartilhado_supervisor', 'compartilhado_portal'])
),
CONSTRAINT documents_status_revisao_check CHECK (
status_revisao = ANY (ARRAY['pendente', 'aprovado', 'rejeitado'])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — documents
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS docs_patient_idx
ON public.documents USING btree (patient_id);
CREATE INDEX IF NOT EXISTS docs_owner_idx
ON public.documents USING btree (owner_id);
CREATE INDEX IF NOT EXISTS docs_tenant_idx
ON public.documents USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS docs_tipo_idx
ON public.documents USING btree (patient_id, tipo_documento);
CREATE INDEX IF NOT EXISTS docs_tags_idx
ON public.documents USING gin (tags);
CREATE INDEX IF NOT EXISTS docs_uploaded_at_idx
ON public.documents USING btree (patient_id, uploaded_at DESC);
-- Excluir soft-deleted da listagem padrao
CREATE INDEX IF NOT EXISTS docs_active_idx
ON public.documents USING btree (patient_id, uploaded_at DESC)
WHERE deleted_at IS NULL;
-- Busca textual no nome do arquivo
CREATE INDEX IF NOT EXISTS docs_nome_trgm_idx
ON public.documents USING gin (nome_original gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at — documents
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_documents_updated_at
BEFORE UPDATE ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. Trigger: registrar na patient_timeline ao adicionar documento
-- --------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.fn_documents_timeline_insert()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
NEW.patient_id,
NEW.tenant_id,
'documento_adicionado',
'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
'blue',
'documento',
NEW.id,
NEW.uploaded_by,
NEW.uploaded_at
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_documents_timeline_insert
AFTER INSERT ON public.documents
FOR EACH ROW
EXECUTE FUNCTION public.fn_documents_timeline_insert();
-- --------------------------------------------------------------------------
-- 5. RLS — documents
-- --------------------------------------------------------------------------
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "documents: owner full access"
ON public.documents
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- --------------------------------------------------------------------------
-- 6. Comentarios — documents
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.documents IS 'Documentos e arquivos vinculados a pacientes. Armazenados no Supabase Storage.';
COMMENT ON COLUMN public.documents.owner_id IS 'Terapeuta dono do documento (auth.uid()).';
COMMENT ON COLUMN public.documents.tenant_id IS 'Tenant do terapeuta.';
COMMENT ON COLUMN public.documents.patient_id IS 'Paciente ao qual o documento pertence.';
COMMENT ON COLUMN public.documents.bucket_path IS 'Caminho do arquivo no Supabase Storage bucket.';
COMMENT ON COLUMN public.documents.storage_bucket IS 'Nome do bucket no Storage. Default: documents.';
COMMENT ON COLUMN public.documents.nome_original IS 'Nome original do arquivo enviado.';
COMMENT ON COLUMN public.documents.mime_type IS 'MIME type do arquivo. Ex: application/pdf, image/jpeg.';
COMMENT ON COLUMN public.documents.tamanho_bytes IS 'Tamanho do arquivo em bytes.';
COMMENT ON COLUMN public.documents.tipo_documento IS 'Tipo: laudo|receita|exame|termo_assinado|relatorio_externo|identidade|convenio|declaracao|atestado|recibo|outro.';
COMMENT ON COLUMN public.documents.categoria IS 'Categoria livre para organizacao adicional.';
COMMENT ON COLUMN public.documents.tags IS 'Tags livres para busca e filtro. Array de text.';
COMMENT ON COLUMN public.documents.visibilidade IS 'privado|compartilhado_supervisor|compartilhado_portal.';
COMMENT ON COLUMN public.documents.compartilhado_portal IS 'true = visivel para o paciente no portal.';
COMMENT ON COLUMN public.documents.compartilhado_supervisor IS 'true = visivel para o supervisor.';
COMMENT ON COLUMN public.documents.enviado_pelo_paciente IS 'true = upload feito pelo paciente via portal.';
COMMENT ON COLUMN public.documents.status_revisao IS 'pendente|aprovado|rejeitado — para uploads do paciente.';
COMMENT ON COLUMN public.documents.deleted_at IS 'Soft delete: data da exclusao. NULL = ativo.';
COMMENT ON COLUMN public.documents.retencao_ate IS 'LGPD/CFP: arquivo retido ate esta data mesmo apos soft delete.';
-- ==========================================================================
-- 7. Tabela: document_access_logs (imutavel — auditoria)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_access_logs (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Acao realizada
acao text NOT NULL,
-- visualizou | baixou | imprimiu | compartilhou | assinou
user_id uuid,
ip inet,
user_agent text,
acessado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_access_logs_pkey PRIMARY KEY (id),
CONSTRAINT dal_acao_check CHECK (
acao = ANY (ARRAY['visualizou', 'baixou', 'imprimiu', 'compartilhou', 'assinou'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS dal_documento_idx
ON public.document_access_logs USING btree (documento_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_tenant_idx
ON public.document_access_logs USING btree (tenant_id, acessado_em DESC);
CREATE INDEX IF NOT EXISTS dal_user_idx
ON public.document_access_logs USING btree (user_id, acessado_em DESC);
-- RLS — somente INSERT (imutavel) + SELECT
ALTER TABLE public.document_access_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dal: tenant members can insert"
ON public.document_access_logs
FOR INSERT
WITH CHECK (true);
CREATE POLICY "dal: tenant members can select"
ON public.document_access_logs
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_access_logs IS 'Log imutavel de acessos a documentos. Conformidade CFP e LGPD. Sem UPDATE/DELETE.';
COMMENT ON COLUMN public.document_access_logs.acao IS 'visualizou|baixou|imprimiu|compartilhou|assinou.';
-- ==========================================================================
-- 8. Tabela: document_signatures (assinatura eletronica)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_signatures (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Signatario
signatario_tipo text NOT NULL,
-- paciente | responsavel_legal | terapeuta
signatario_id uuid,
signatario_nome text,
signatario_email text,
-- Ordem e status
ordem smallint DEFAULT 1 NOT NULL,
status text NOT NULL DEFAULT 'pendente',
-- pendente | enviado | assinado | recusado | expirado
-- Dados da assinatura (preenchidos ao assinar)
ip inet,
user_agent text,
assinado_em timestamptz,
hash_documento text,
-- Controle
criado_em timestamptz DEFAULT now(),
atualizado_em timestamptz DEFAULT now(),
CONSTRAINT document_signatures_pkey PRIMARY KEY (id),
CONSTRAINT ds_signatario_tipo_check CHECK (
signatario_tipo = ANY (ARRAY['paciente', 'responsavel_legal', 'terapeuta'])
),
CONSTRAINT ds_status_check CHECK (
status = ANY (ARRAY['pendente', 'enviado', 'assinado', 'recusado', 'expirado'])
)
);
-- Indices
CREATE INDEX IF NOT EXISTS ds_documento_idx
ON public.document_signatures USING btree (documento_id, ordem);
CREATE INDEX IF NOT EXISTS ds_tenant_idx
ON public.document_signatures USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS ds_status_idx
ON public.document_signatures USING btree (documento_id, status);
-- Trigger updated_at
CREATE TRIGGER trg_ds_updated_at
BEFORE UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- Trigger: ao assinar, registrar na patient_timeline
CREATE OR REPLACE FUNCTION public.fn_document_signature_timeline()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_patient_id uuid;
v_tenant_id uuid;
v_doc_nome text;
BEGIN
IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
SELECT d.patient_id, d.tenant_id, d.nome_original
INTO v_patient_id, v_tenant_id, v_doc_nome
FROM public.documents d
WHERE d.id = NEW.documento_id;
IF v_patient_id IS NOT NULL THEN
INSERT INTO public.patient_timeline (
patient_id, tenant_id, evento_tipo,
titulo, descricao, icone_cor,
link_ref_tipo, link_ref_id,
gerado_por, ocorrido_em
) VALUES (
v_patient_id,
v_tenant_id,
'documento_assinado',
'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
'green',
'documento',
NEW.documento_id,
NEW.signatario_id,
NEW.assinado_em
);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_ds_timeline
AFTER UPDATE ON public.document_signatures
FOR EACH ROW
EXECUTE FUNCTION public.fn_document_signature_timeline();
-- RLS
ALTER TABLE public.document_signatures ENABLE ROW LEVEL SECURITY;
CREATE POLICY "ds: tenant members access"
ON public.document_signatures
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
))
WITH CHECK (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- Comentarios
COMMENT ON TABLE public.document_signatures IS 'Assinaturas eletronicas de documentos. Cada signatario tem seu registro.';
COMMENT ON COLUMN public.document_signatures.signatario_tipo IS 'paciente|responsavel_legal|terapeuta.';
COMMENT ON COLUMN public.document_signatures.status IS 'pendente|enviado|assinado|recusado|expirado.';
COMMENT ON COLUMN public.document_signatures.hash_documento IS 'Hash SHA-256 do documento no momento da assinatura. Garante integridade.';
COMMENT ON COLUMN public.document_signatures.ip IS 'IP do signatario no momento da assinatura.';
-- ==========================================================================
-- 9. Tabela: document_share_links (links temporarios)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_share_links (
id uuid DEFAULT gen_random_uuid() NOT NULL,
documento_id uuid NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Token unico para o link
token text NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
-- Limites
expira_em timestamptz NOT NULL,
usos_max smallint DEFAULT 5 NOT NULL,
usos smallint DEFAULT 0 NOT NULL,
-- Quem criou
criado_por uuid NOT NULL,
criado_em timestamptz DEFAULT now(),
-- Controle
ativo boolean DEFAULT true NOT NULL,
CONSTRAINT document_share_links_pkey PRIMARY KEY (id),
CONSTRAINT dsl_token_unique UNIQUE (token)
);
-- Indices
CREATE INDEX IF NOT EXISTS dsl_documento_idx
ON public.document_share_links USING btree (documento_id);
CREATE INDEX IF NOT EXISTS dsl_token_idx
ON public.document_share_links USING btree (token)
WHERE ativo = true;
CREATE INDEX IF NOT EXISTS dsl_expira_idx
ON public.document_share_links USING btree (expira_em)
WHERE ativo = true;
-- RLS
ALTER TABLE public.document_share_links ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dsl: creator full access"
ON public.document_share_links
USING (criado_por = auth.uid())
WITH CHECK (criado_por = auth.uid());
-- Politica publica de leitura por token (para acesso externo sem login)
CREATE POLICY "dsl: public read by token"
ON public.document_share_links
FOR SELECT
USING (ativo = true AND expira_em > now() AND usos < usos_max);
-- Comentarios
COMMENT ON TABLE public.document_share_links IS 'Links temporarios assinados para compartilhar documento com profissional externo.';
COMMENT ON COLUMN public.document_share_links.token IS 'Token unico gerado automaticamente (32 bytes hex).';
COMMENT ON COLUMN public.document_share_links.expira_em IS 'Data/hora de expiracao do link.';
COMMENT ON COLUMN public.document_share_links.usos_max IS 'Numero maximo de acessos permitidos.';
COMMENT ON COLUMN public.document_share_links.usos IS 'Numero de vezes que o link ja foi acessado.';
-- ==========================================================================
-- FIM DA MIGRACAO 005
-- ==========================================================================

View File

@@ -0,0 +1,260 @@
-- ==========================================================================
-- Agencia PSI — Migracao: tabelas de Templates de Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Proposito:
-- Templates de documentos (declaracao, atestado, recibo, relatorio etc.)
-- e registro de cada documento gerado (instancia PDF).
--
-- Tabelas: document_templates, document_generated.
--
-- Relacionamentos:
-- document_templates.tenant_id → tenants(id)
-- document_templates.owner_id → auth.users(id)
-- document_generated.template_id → document_templates(id)
-- document_generated.patient_id → patients(id)
-- document_generated.tenant_id → tenants(id)
--
-- Templates globais: is_global = true, tenant_id = NULL.
-- Templates do tenant: is_global = false, tenant_id preenchido.
-- ==========================================================================
-- --------------------------------------------------------------------------
-- 1. Tabela: document_templates
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.document_templates (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Contexto
tenant_id uuid,
owner_id uuid,
-- Identificacao
nome_template text NOT NULL,
tipo text NOT NULL DEFAULT 'outro',
-- declaracao_comparecimento | atestado_psicologico
-- relatorio_acompanhamento | recibo_pagamento
-- termo_consentimento | encaminhamento | outro
descricao text,
-- Corpo do template
corpo_html text NOT NULL DEFAULT '',
cabecalho_html text,
rodape_html text,
-- Variaveis que o template utiliza
variaveis text[] DEFAULT '{}',
-- Ex: {paciente_nome, paciente_cpf, data_sessao, terapeuta_nome, ...}
-- Personalizacao visual
logo_url text,
-- Escopo
is_global boolean DEFAULT false NOT NULL,
-- true = template padrao do sistema (visivel para todos)
-- false = template criado pelo tenant/terapeuta
-- Controle
ativo boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
CONSTRAINT document_templates_pkey PRIMARY KEY (id),
CONSTRAINT dt_tipo_check CHECK (
tipo = ANY (ARRAY[
'declaracao_comparecimento', 'atestado_psicologico',
'relatorio_acompanhamento', 'recibo_pagamento',
'termo_consentimento', 'encaminhamento',
'contrato_servicos', 'tcle', 'autorizacao_menor',
'laudo_psicologico', 'parecer_psicologico',
'termo_sigilo', 'declaracao_inicio_tratamento',
'termo_alta', 'tcle_online', 'outro'
])
)
);
-- --------------------------------------------------------------------------
-- 2. Indices — document_templates
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dt_tenant_idx
ON public.document_templates USING btree (tenant_id);
CREATE INDEX IF NOT EXISTS dt_owner_idx
ON public.document_templates USING btree (owner_id);
CREATE INDEX IF NOT EXISTS dt_global_idx
ON public.document_templates USING btree (is_global)
WHERE is_global = true;
CREATE INDEX IF NOT EXISTS dt_tipo_idx
ON public.document_templates USING btree (tipo);
CREATE INDEX IF NOT EXISTS dt_nome_trgm_idx
ON public.document_templates USING gin (nome_template gin_trgm_ops);
-- --------------------------------------------------------------------------
-- 3. Trigger updated_at
-- --------------------------------------------------------------------------
CREATE TRIGGER trg_dt_updated_at
BEFORE UPDATE ON public.document_templates
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- --------------------------------------------------------------------------
-- 4. RLS — document_templates
-- --------------------------------------------------------------------------
ALTER TABLE public.document_templates ENABLE ROW LEVEL SECURITY;
-- Templates globais: todos podem ler
CREATE POLICY "dt: global templates readable by all"
ON public.document_templates
FOR SELECT
USING (is_global = true);
-- Templates do tenant: membros do tenant podem ler
CREATE POLICY "dt: tenant members can select"
ON public.document_templates
FOR SELECT
USING (
is_global = false
AND tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
)
);
-- Owner pode inserir/atualizar/deletar seus templates
CREATE POLICY "dt: owner can insert"
ON public.document_templates
FOR INSERT
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can update"
ON public.document_templates
FOR UPDATE
USING (owner_id = auth.uid() AND is_global = false)
WITH CHECK (owner_id = auth.uid() AND is_global = false);
CREATE POLICY "dt: owner can delete"
ON public.document_templates
FOR DELETE
USING (owner_id = auth.uid() AND is_global = false);
-- SaaS admin pode gerenciar templates globais (usa funcao public.is_saas_admin())
CREATE POLICY "dt: saas admin can insert global"
ON public.document_templates
FOR INSERT
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can update global"
ON public.document_templates
FOR UPDATE
USING (is_global = true AND public.is_saas_admin())
WITH CHECK (is_global = true AND public.is_saas_admin());
CREATE POLICY "dt: saas admin can delete global"
ON public.document_templates
FOR DELETE
USING (is_global = true AND public.is_saas_admin());
-- --------------------------------------------------------------------------
-- 5. Comentarios — document_templates
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_templates IS 'Templates de documentos para geracao automatica (declaracao, atestado, recibo etc.).';
COMMENT ON COLUMN public.document_templates.nome_template IS 'Nome do template. Ex: Declaracao de Comparecimento.';
COMMENT ON COLUMN public.document_templates.tipo IS 'declaracao_comparecimento|atestado_psicologico|relatorio_acompanhamento|recibo_pagamento|termo_consentimento|encaminhamento|outro.';
COMMENT ON COLUMN public.document_templates.corpo_html IS 'Corpo do template em HTML com variaveis {{nome_variavel}}.';
COMMENT ON COLUMN public.document_templates.cabecalho_html IS 'HTML do cabecalho (logo, nome da clinica etc.).';
COMMENT ON COLUMN public.document_templates.rodape_html IS 'HTML do rodape (CRP, endereco, contato etc.).';
COMMENT ON COLUMN public.document_templates.variaveis IS 'Array com nomes das variaveis usadas no template. Ex: {paciente_nome, data_sessao}.';
COMMENT ON COLUMN public.document_templates.is_global IS 'true = template padrao do sistema visivel para todos. false = template do tenant.';
COMMENT ON COLUMN public.document_templates.logo_url IS 'URL do logo personalizado para o cabecalho do documento.';
-- ==========================================================================
-- 6. Tabela: document_generated (cada PDF gerado)
-- ==========================================================================
CREATE TABLE IF NOT EXISTS public.document_generated (
id uuid DEFAULT gen_random_uuid() NOT NULL,
-- Origem
template_id uuid NOT NULL REFERENCES public.document_templates(id) ON DELETE RESTRICT,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
-- Dados usados no preenchimento (snapshot — permite auditoria futura)
dados_preenchidos jsonb NOT NULL DEFAULT '{}',
-- PDF gerado
pdf_path text NOT NULL,
storage_bucket text NOT NULL DEFAULT 'generated-docs',
-- Vinculo opcional com documento pai (se o PDF gerado tambem for registrado em documents)
documento_id uuid REFERENCES public.documents(id) ON DELETE SET NULL,
-- Quem gerou
gerado_por uuid NOT NULL,
gerado_em timestamptz DEFAULT now() NOT NULL,
CONSTRAINT document_generated_pkey PRIMARY KEY (id)
);
-- --------------------------------------------------------------------------
-- 7. Indices — document_generated
-- --------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS dg_template_idx
ON public.document_generated USING btree (template_id);
CREATE INDEX IF NOT EXISTS dg_patient_idx
ON public.document_generated USING btree (patient_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_tenant_idx
ON public.document_generated USING btree (tenant_id, gerado_em DESC);
CREATE INDEX IF NOT EXISTS dg_gerado_por_idx
ON public.document_generated USING btree (gerado_por, gerado_em DESC);
-- --------------------------------------------------------------------------
-- 8. RLS — document_generated
-- --------------------------------------------------------------------------
ALTER TABLE public.document_generated ENABLE ROW LEVEL SECURITY;
CREATE POLICY "dg: generator full access"
ON public.document_generated
USING (gerado_por = auth.uid())
WITH CHECK (gerado_por = auth.uid());
-- Membros do tenant podem visualizar
CREATE POLICY "dg: tenant members can select"
ON public.document_generated
FOR SELECT
USING (tenant_id IN (
SELECT tm.tenant_id FROM public.tenant_members tm
WHERE tm.user_id = auth.uid() AND tm.status = 'active'
));
-- --------------------------------------------------------------------------
-- 9. Comentarios — document_generated
-- --------------------------------------------------------------------------
COMMENT ON TABLE public.document_generated IS 'Registro de cada documento PDF gerado a partir de um template.';
COMMENT ON COLUMN public.document_generated.template_id IS 'Template usado para gerar o documento.';
COMMENT ON COLUMN public.document_generated.dados_preenchidos IS 'Snapshot JSON dos dados usados no preenchimento. Permite auditoria futura.';
COMMENT ON COLUMN public.document_generated.pdf_path IS 'Caminho do PDF gerado no Supabase Storage bucket.';
COMMENT ON COLUMN public.document_generated.documento_id IS 'FK opcional para documents — se o PDF gerado tambem foi registrado como documento do paciente.';
COMMENT ON COLUMN public.document_generated.gerado_por IS 'Usuario que gerou o documento (auth.uid()).';
-- ==========================================================================
-- FIM DA MIGRACAO 006
-- ==========================================================================

View File

@@ -0,0 +1,93 @@
-- ==========================================================================
-- Agencia PSI — Migracao: Storage Buckets para Documentos
-- ==========================================================================
-- Criado por: Leonardo Nohama
-- Data: 2026-03-29 · Sao Carlos/SP — Brasil
--
-- Cria os buckets no Supabase Storage para documentos de pacientes
-- e PDFs gerados pelo sistema.
-- ==========================================================================
-- Bucket: documents (uploads de terapeuta/paciente)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'documents',
'documents',
false,
52428800, -- 50 MB
ARRAY[
'application/pdf',
'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain'
]
)
ON CONFLICT (id) DO NOTHING;
-- Bucket: generated-docs (PDFs gerados pelo sistema)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'generated-docs',
'generated-docs',
false,
20971520, -- 20 MB
ARRAY['application/pdf']
)
ON CONFLICT (id) DO NOTHING;
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: documents
-- --------------------------------------------------------------------------
-- Upload: usuario autenticado pode fazer upload no path do seu tenant
CREATE POLICY "documents: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'documents');
-- Download: usuario autenticado pode ler arquivos do seu tenant
CREATE POLICY "documents: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'documents');
-- Delete: usuario autenticado pode deletar seus arquivos
CREATE POLICY "documents: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'documents');
-- --------------------------------------------------------------------------
-- Storage RLS Policies — bucket: generated-docs
-- --------------------------------------------------------------------------
CREATE POLICY "generated-docs: authenticated upload"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated read"
ON storage.objects
FOR SELECT
TO authenticated
USING (bucket_id = 'generated-docs');
CREATE POLICY "generated-docs: authenticated delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'generated-docs');
-- ==========================================================================
-- FIM DA MIGRACAO
-- ==========================================================================

View File

@@ -0,0 +1,661 @@
-- =============================================================================
-- 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
-- =============================================================================