padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes

Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create
overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia
PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical
notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent
Dialog.vue.bak deletado (lixo de refator anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:19:45 -03:00
parent 5b345c5598
commit f94a4ae97f
12 changed files with 2857 additions and 3522 deletions
@@ -0,0 +1,165 @@
-- ============================================================================
-- Cria tabelas do prontuário clínico
-- ----------------------------------------------------------------------------
-- Núcleo do prontuário: notas clínicas (anamnese, evolução, plano), com
-- versionamento (audit trail) e templates (SOAP/DAP/BIRP/livre).
--
-- Decisões (sessão de modelagem 2026-05-20):
-- • Tabela única `clinical_notes` discriminada por `note_type` (não 1 tabela
-- por tipo). Templates customizáveis exigem flexibilidade.
-- • `content_text` (livre) + `content_structured` (jsonb) coexistem na mesma
-- row — UI prioriza conforme template; busca/edit rápido sempre tem text.
-- • Versionamento via snapshot completo (não diff) em `clinical_note_versions`
-- — restore trivial e audit visualization friendly. Trigger de versionamento
-- criado em migration separada.
-- • Instrumentos de avaliação (GAD-7, PHQ-9, etc) ficam pra Fase 2.
-- • RLS: owner-only (terapeuta responsável). Sem clinic-wide read — CFP exige
-- sigilo entre profissionais. Policies em migration separada.
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. clinical_notes — núcleo do prontuário
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL, -- terapeuta responsável
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE RESTRICT,
session_event_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
note_type text NOT NULL,
template_id uuid, -- FK adicionada após criar templates
title text,
content_text text,
content_structured jsonb,
pinned boolean DEFAULT false NOT NULL,
is_draft boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid NOT NULL,
updated_by uuid,
deleted_at timestamp with time zone,
deleted_by uuid,
CONSTRAINT clinical_notes_note_type_check CHECK (note_type IN (
'anamnese',
'evolucao_sessao',
'plano_terapeutico',
'observacao_livre',
'resumo_caso'
)),
CONSTRAINT clinical_notes_content_present_check CHECK (
content_text IS NOT NULL OR content_structured IS NOT NULL
)
);
COMMENT ON TABLE public.clinical_notes IS
'Notas clínicas do prontuário (anamnese, evolução de sessão, plano, observações). Owner-only via RLS — CFP exige sigilo.';
COMMENT ON COLUMN public.clinical_notes.session_event_id IS
'Sessão associada (quando aplicável). Anamnese/plano/resumo podem ter NULL.';
COMMENT ON COLUMN public.clinical_notes.content_text IS
'Conteúdo em texto livre (sempre disponível pra busca/edit rápido).';
COMMENT ON COLUMN public.clinical_notes.content_structured IS
'Conteúdo em formato estruturado quando há template ativo (jsonb dos campos preenchidos).';
CREATE INDEX IF NOT EXISTS idx_clinical_notes_patient_recent
ON public.clinical_notes (tenant_id, patient_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_owner
ON public.clinical_notes (owner_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_session
ON public.clinical_notes (session_event_id)
WHERE session_event_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_type
ON public.clinical_notes (tenant_id, patient_id, note_type)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_pinned
ON public.clinical_notes (tenant_id, patient_id)
WHERE pinned = true AND deleted_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. clinical_note_versions — audit trail (snapshot completo)
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_note_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
note_id uuid NOT NULL REFERENCES public.clinical_notes(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
version_number integer NOT NULL,
title text,
content_text text,
content_structured jsonb,
change_reason text, -- 'criacao' | 'edicao' | livre
created_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid NOT NULL,
CONSTRAINT clinical_note_versions_unique UNIQUE (note_id, version_number)
);
COMMENT ON TABLE public.clinical_note_versions IS
'Snapshot completo de cada versão de clinical_notes. Criado via trigger AFTER INSERT OR UPDATE.';
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_recent
ON public.clinical_note_versions (note_id, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_audit
ON public.clinical_note_versions (created_by, created_at DESC);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. clinical_note_templates — templates SOAP/DAP/BIRP/anamnese padrão
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_note_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid, -- NULL = template global do sistema
owner_id uuid, -- NULL = template do tenant inteiro
key text NOT NULL, -- 'soap', 'dap', 'birp', 'anamnese_padrao', ...
name text NOT NULL,
note_type text NOT NULL,
description text,
structure jsonb NOT NULL, -- [{key, label, type, required, hint}]
is_system boolean DEFAULT false NOT NULL,
is_global boolean DEFAULT false NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT clinical_note_templates_note_type_check CHECK (note_type IN (
'anamnese',
'evolucao_sessao',
'plano_terapeutico',
'observacao_livre',
'resumo_caso'
)),
CONSTRAINT clinical_note_templates_scope_check CHECK (
-- Sistema: ambos NULL e is_system=true
-- Tenant-wide: tenant_id presente, owner_id NULL
-- Owner: ambos presentes
(is_system = true AND tenant_id IS NULL AND owner_id IS NULL)
OR (is_system = false AND tenant_id IS NOT NULL)
)
);
COMMENT ON TABLE public.clinical_note_templates IS
'Templates de notas clínicas. Escopo: sistema (is_system, sem tenant), tenant-wide (tenant_id sem owner), owner (ambos).';
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_active
ON public.clinical_note_templates (note_type)
WHERE active = true;
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_tenant
ON public.clinical_note_templates (tenant_id, note_type)
WHERE tenant_id IS NOT NULL AND active = true;
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_owner
ON public.clinical_note_templates (owner_id, note_type)
WHERE owner_id IS NOT NULL AND active = true;
-- ──────────────────────────────────────────────────────────────────────────
-- 4. FK de clinical_notes.template_id (criada agora que templates existe)
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_notes
ADD CONSTRAINT clinical_notes_template_fkey
FOREIGN KEY (template_id)
REFERENCES public.clinical_note_templates(id)
ON DELETE SET NULL;
COMMIT;
@@ -0,0 +1,111 @@
-- ============================================================================
-- RLS policies do prontuário clínico
-- ----------------------------------------------------------------------------
-- Padrão MAIS RESTRITIVO que agenda — CFP exige sigilo profissional entre
-- terapeutas do mesmo tenant. Default: APENAS o owner (terapeuta responsável)
-- lê e escreve. Sem clinic-wide read.
--
-- Compartilhamento com supervisor / outro terapeuta vai requerer policy
-- específica baseada em tabela `clinical_note_shares` (Fase 2).
--
-- Templates seguem regra mais aberta:
-- • Sistema (is_system): todos authenticated leem
-- • Tenant-wide (tenant_id): membros do tenant leem; tenant_admin edita
-- • Owner: só o owner lê/edita
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_notes — owner only
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY clinical_notes_owner_select
ON public.clinical_notes FOR SELECT TO authenticated
USING (owner_id = auth.uid() AND deleted_at IS NULL);
CREATE POLICY clinical_notes_owner_insert
ON public.clinical_notes FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND public.is_tenant_member(tenant_id)
);
CREATE POLICY clinical_notes_owner_update
ON public.clinical_notes FOR UPDATE TO authenticated
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- DELETE só por soft-delete (UPDATE deleted_at). Hard delete bloqueado em RLS.
-- Backup/admin pode dropar via psql -U supabase_admin se preciso.
CREATE POLICY clinical_notes_no_hard_delete
ON public.clinical_notes FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_note_versions — read-only pelo owner da nota
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_note_versions ENABLE ROW LEVEL SECURITY;
CREATE POLICY clinical_note_versions_owner_select
ON public.clinical_note_versions FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.clinical_notes cn
WHERE cn.id = clinical_note_versions.note_id
AND cn.owner_id = auth.uid()
)
);
-- INSERT só via trigger (SECURITY DEFINER). Sem policy de UPDATE/DELETE —
-- versões são imutáveis. Trigger usa role bypass.
CREATE POLICY clinical_note_versions_no_write
ON public.clinical_note_versions FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY clinical_note_versions_no_update
ON public.clinical_note_versions FOR UPDATE TO authenticated
USING (false);
CREATE POLICY clinical_note_versions_no_delete
ON public.clinical_note_versions FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_note_templates — escopo escalonado
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_note_templates ENABLE ROW LEVEL SECURITY;
-- SELECT: sistema (qualquer authenticated) + tenant-wide (membros) + owner (próprio)
CREATE POLICY clinical_note_templates_select
ON public.clinical_note_templates FOR SELECT TO authenticated
USING (
active = true
AND (
is_system = true
OR (tenant_id IS NOT NULL AND public.is_tenant_member(tenant_id))
)
);
-- INSERT/UPDATE/DELETE: só owner ou tenant_admin do tenant
-- Templates do sistema (is_system) nunca alteráveis via UI — só via seed/migration.
CREATE POLICY clinical_note_templates_owner_write
ON public.clinical_note_templates TO authenticated
USING (
is_system = false
AND (
owner_id = auth.uid()
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
)
)
WITH CHECK (
is_system = false
AND (
owner_id = auth.uid()
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
)
);
COMMIT;
@@ -0,0 +1,117 @@
-- ============================================================================
-- Trigger de versionamento automático de clinical_notes
-- ----------------------------------------------------------------------------
-- A cada INSERT ou UPDATE relevante em clinical_notes, cria snapshot completo
-- em clinical_note_versions. Função é SECURITY DEFINER pra bypassar a RLS
-- (que bloqueia INSERT direto em clinical_note_versions).
--
-- Versionamento dispara em:
-- • INSERT — registra criação (version_number = 1)
-- • UPDATE em content_text, content_structured ou title — registra edição
--
-- Mudanças em pinned/is_draft NÃO disparam versionamento (mudança de UI/state,
-- não de conteúdo).
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
next_version integer;
reason text;
BEGIN
SELECT COALESCE(MAX(version_number), 0) + 1
INTO next_version
FROM public.clinical_note_versions
WHERE note_id = NEW.id;
IF TG_OP = 'INSERT' THEN
reason := 'criacao';
ELSIF TG_OP = 'UPDATE' THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
reason := 'soft_delete';
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN
reason := 'restore';
ELSE
reason := 'edicao';
END IF;
ELSE
reason := 'desconhecido';
END IF;
INSERT INTO public.clinical_note_versions (
note_id,
tenant_id,
version_number,
title,
content_text,
content_structured,
change_reason,
created_at,
created_by
) VALUES (
NEW.id,
NEW.tenant_id,
next_version,
NEW.title,
NEW.content_text,
NEW.content_structured,
reason,
now(),
COALESCE(NEW.updated_by, NEW.created_by)
);
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION public.fn_clinical_note_version() IS
'Snapshot completo de clinical_notes a cada INSERT/UPDATE relevante. SECURITY DEFINER bypassa RLS pra escrever em clinical_note_versions (que bloqueia INSERT direto).';
CREATE TRIGGER trg_clinical_notes_version_insert
AFTER INSERT ON public.clinical_notes
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_note_version();
CREATE TRIGGER trg_clinical_notes_version_update
AFTER UPDATE OF content_text, content_structured, title, deleted_at
ON public.clinical_notes
FOR EACH ROW
WHEN (
OLD.content_text IS DISTINCT FROM NEW.content_text
OR OLD.content_structured IS DISTINCT FROM NEW.content_structured
OR OLD.title IS DISTINCT FROM NEW.title
OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at
)
EXECUTE FUNCTION public.fn_clinical_note_version();
-- ──────────────────────────────────────────────────────────────────────────
-- Trigger para updated_at automático
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.fn_clinical_notes_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_clinical_notes_updated_at
BEFORE UPDATE ON public.clinical_notes
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
CREATE TRIGGER trg_clinical_note_templates_updated_at
BEFORE UPDATE ON public.clinical_note_templates
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
COMMIT;
@@ -0,0 +1,46 @@
-- ============================================================================
-- Liga documents a clinical_notes (preenche FK órfã)
-- ----------------------------------------------------------------------------
-- A coluna `documents.session_note_id` existia desde antes apontando pra uma
-- tabela `session_notes` que nunca foi criada. Agora que `clinical_notes`
-- existe e abrange anamnese/evolução/plano (não só sessão), renomeia pra
-- `clinical_note_id` e adiciona FK constraint.
--
-- PRÉ-CHECK: a query abaixo deve retornar 0 antes de rodar esta migration.
-- SELECT count(*) FROM public.documents WHERE session_note_id IS NOT NULL;
-- Se houver dados, eles são órfãos (referenciam tabela inexistente) — limpar
-- antes de adicionar a FK constraint, ou ela falha.
-- ============================================================================
BEGIN;
-- 1. Limpa eventuais órfãos (FK nunca foi enforced, mas valor pode ter sido
-- setado por código no front antes da migration). Defesa em profundidade.
UPDATE public.documents
SET session_note_id = NULL
WHERE session_note_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.clinical_notes cn
WHERE cn.id = documents.session_note_id
);
-- 2. Rename
ALTER TABLE public.documents
RENAME COLUMN session_note_id TO clinical_note_id;
-- 3. FK constraint
ALTER TABLE public.documents
ADD CONSTRAINT documents_clinical_note_fkey
FOREIGN KEY (clinical_note_id)
REFERENCES public.clinical_notes(id)
ON DELETE SET NULL;
-- 4. Index pra reverse lookup (documentos de uma nota)
CREATE INDEX IF NOT EXISTS idx_documents_clinical_note
ON public.documents (clinical_note_id)
WHERE clinical_note_id IS NOT NULL AND deleted_at IS NULL;
COMMENT ON COLUMN public.documents.clinical_note_id IS
'Vínculo opcional a uma nota clínica (anexar PDF a anamnese/evolução). Renomeado de session_note_id em 2026-05-20.';
COMMIT;