From 388e9a4186506b324c6683bcceb5d96fa21ba80b Mon Sep 17 00:00:00 2001 From: Leonardo Date: Thu, 21 May 2026 04:20:15 -0300 Subject: [PATCH] =?UTF-8?q?M3:=20prontuario=20foundation=20=E2=80=94=20rep?= =?UTF-8?q?ositories=20+=20composables=20clinical=5Fnotes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modulo 3 da Fase 1. 6 arquivos novos em features/patients/prontuario/: services (_tenantGuards, clinicalNotesSelects, clinicalNotesRepository, clinicalNoteTemplatesRepository) + composables (useClinicalNotes, useClinicalNoteTemplates). Ativa quando migrations 0.5.B (clinical_notes tables/rls/versioning + documents link) forem aplicadas no banco. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useClinicalNoteTemplates.js | 130 +++++++++ .../composables/useClinicalNotes.js | 190 +++++++++++++ .../prontuario/services/_tenantGuards.js | 25 ++ .../clinicalNoteTemplatesRepository.js | 140 ++++++++++ .../services/clinicalNotesRepository.js | 251 ++++++++++++++++++ .../services/clinicalNotesSelects.js | 63 +++++ 6 files changed, 799 insertions(+) create mode 100644 src/features/patients/prontuario/composables/useClinicalNoteTemplates.js create mode 100644 src/features/patients/prontuario/composables/useClinicalNotes.js create mode 100644 src/features/patients/prontuario/services/_tenantGuards.js create mode 100644 src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js create mode 100644 src/features/patients/prontuario/services/clinicalNotesRepository.js create mode 100644 src/features/patients/prontuario/services/clinicalNotesSelects.js diff --git a/src/features/patients/prontuario/composables/useClinicalNoteTemplates.js b/src/features/patients/prontuario/composables/useClinicalNoteTemplates.js new file mode 100644 index 0000000..8317df8 --- /dev/null +++ b/src/features/patients/prontuario/composables/useClinicalNoteTemplates.js @@ -0,0 +1,130 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/prontuario/composables/useClinicalNoteTemplates.js +| +| Thin wrapper sobre clinicalNoteTemplatesRepository. +| Carrega templates visíveis (sistema + tenant + owner), filtra por noteType. +| +| ⚠️ Depende das migrations 0.5.B + seed_040_clinical_note_templates.sql. +|-------------------------------------------------------------------------- +*/ +import { ref, computed } from 'vue'; +import { + listAvailable, + getById, + getByKey, + create as repoCreate, + update as repoUpdate, + softDelete as repoSoftDelete +} from '@/features/patients/prontuario/services/clinicalNoteTemplatesRepository'; + +export function useClinicalNoteTemplates() { + const rows = ref([]); + const loading = ref(false); + const error = ref(''); + + async function load(opts = {}) { + loading.value = true; + error.value = ''; + try { + rows.value = await listAvailable(opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar templates.'; + rows.value = []; + } finally { + loading.value = false; + } + } + + async function fetchById(id) { + try { + return await getById(id); + } catch (e) { + error.value = e?.message || 'Falha ao carregar template.'; + return null; + } + } + + async function fetchByKey(key, opts = {}) { + try { + return await getByKey(key, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar template.'; + return null; + } + } + + async function create(payload) { + loading.value = true; + error.value = ''; + try { + const created = await repoCreate(payload); + rows.value = [...rows.value, created]; + return created; + } catch (e) { + error.value = e?.message || 'Falha ao criar template.'; + throw e; + } finally { + loading.value = false; + } + } + + async function update(id, patch) { + loading.value = true; + error.value = ''; + try { + const updated = await repoUpdate(id, patch); + const idx = rows.value.findIndex((r) => r.id === id); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao atualizar template.'; + throw e; + } finally { + loading.value = false; + } + } + + async function softDelete(id) { + loading.value = true; + error.value = ''; + try { + await repoSoftDelete(id); + rows.value = rows.value.filter((r) => r.id !== id); + return true; + } catch (e) { + error.value = e?.message || 'Falha ao remover template.'; + throw e; + } finally { + loading.value = false; + } + } + + // ─── Computeds derivados ──────────────────────────────────────────── + + const systemTemplates = computed(() => rows.value.filter((t) => t.is_system)); + const tenantTemplates = computed(() => rows.value.filter((t) => !t.is_system && t.tenant_id && !t.owner_id)); + const ownerTemplates = computed(() => rows.value.filter((t) => !t.is_system && t.owner_id)); + + function byType(noteType) { + return rows.value.filter((t) => t.note_type === noteType); + } + + return { + rows, + loading, + error, + load, + fetchById, + fetchByKey, + create, + update, + softDelete, + systemTemplates, + tenantTemplates, + ownerTemplates, + byType + }; +} diff --git a/src/features/patients/prontuario/composables/useClinicalNotes.js b/src/features/patients/prontuario/composables/useClinicalNotes.js new file mode 100644 index 0000000..c74fc4d --- /dev/null +++ b/src/features/patients/prontuario/composables/useClinicalNotes.js @@ -0,0 +1,190 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/prontuario/composables/useClinicalNotes.js +| +| Thin wrapper sobre clinicalNotesRepository (composable-blueprint Tipo A). +| State reativo: rows, loading, error. +| +| ⚠️ Depende das migrations 0.5.B aplicadas no banco. Sem isso, qualquer +| operação retorna erro de "tabela clinical_notes não existe". +|-------------------------------------------------------------------------- +*/ +import { ref } from 'vue'; +import { + listForPatient, + listForSession, + getById, + create as repoCreate, + update as repoUpdate, + softDelete as repoSoftDelete, + restore as repoRestore, + setPinned as repoSetPinned, + listVersions, + getVersion +} from '@/features/patients/prontuario/services/clinicalNotesRepository'; + +export function useClinicalNotes() { + const rows = ref([]); + const loading = ref(false); + const error = ref(''); + + async function loadForPatient(patientId, opts = {}) { + if (!patientId) { + rows.value = []; + return; + } + loading.value = true; + error.value = ''; + try { + rows.value = await listForPatient(patientId, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar notas clínicas.'; + rows.value = []; + } finally { + loading.value = false; + } + } + + async function loadForSession(sessionEventId, opts = {}) { + if (!sessionEventId) { + rows.value = []; + return; + } + loading.value = true; + error.value = ''; + try { + rows.value = await listForSession(sessionEventId, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar notas da sessão.'; + rows.value = []; + } finally { + loading.value = false; + } + } + + async function fetchById(noteId, opts = {}) { + loading.value = true; + error.value = ''; + try { + return await getById(noteId, opts); + } catch (e) { + error.value = e?.message || 'Falha ao carregar nota.'; + return null; + } finally { + loading.value = false; + } + } + + async function create(payload) { + loading.value = true; + error.value = ''; + try { + const created = await repoCreate(payload); + // Insere no topo se pinned, senão por ordem natural (já vem com created_at = now) + rows.value = [created, ...rows.value]; + return created; + } catch (e) { + error.value = e?.message || 'Falha ao criar nota.'; + throw e; + } finally { + loading.value = false; + } + } + + async function update(noteId, patch, opts = {}) { + loading.value = true; + error.value = ''; + try { + const updated = await repoUpdate(noteId, patch, opts); + const idx = rows.value.findIndex((r) => r.id === noteId); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao atualizar nota.'; + throw e; + } finally { + loading.value = false; + } + } + + async function softDelete(noteId, opts = {}) { + loading.value = true; + error.value = ''; + try { + await repoSoftDelete(noteId, opts); + rows.value = rows.value.filter((r) => r.id !== noteId); + return true; + } catch (e) { + error.value = e?.message || 'Falha ao remover nota.'; + throw e; + } finally { + loading.value = false; + } + } + + async function restore(noteId, opts = {}) { + loading.value = true; + error.value = ''; + try { + await repoRestore(noteId, opts); + return true; + } catch (e) { + error.value = e?.message || 'Falha ao restaurar nota.'; + throw e; + } finally { + loading.value = false; + } + } + + async function togglePinned(noteId, pinned, opts = {}) { + loading.value = true; + error.value = ''; + try { + const updated = await repoSetPinned(noteId, pinned, opts); + const idx = rows.value.findIndex((r) => r.id === noteId); + if (idx >= 0) rows.value[idx] = updated; + return updated; + } catch (e) { + error.value = e?.message || 'Falha ao fixar nota.'; + throw e; + } finally { + loading.value = false; + } + } + + async function fetchVersions(noteId) { + try { + return await listVersions(noteId); + } catch (e) { + error.value = e?.message || 'Falha ao carregar versões.'; + return []; + } + } + + async function fetchVersion(noteId, versionNumber) { + try { + return await getVersion(noteId, versionNumber); + } catch (e) { + error.value = e?.message || 'Falha ao carregar versão.'; + return null; + } + } + + return { + rows, + loading, + error, + loadForPatient, + loadForSession, + fetchById, + create, + update, + softDelete, + restore, + togglePinned, + fetchVersions, + fetchVersion + }; +} diff --git a/src/features/patients/prontuario/services/_tenantGuards.js b/src/features/patients/prontuario/services/_tenantGuards.js new file mode 100644 index 0000000..46d729a --- /dev/null +++ b/src/features/patients/prontuario/services/_tenantGuards.js @@ -0,0 +1,25 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/prontuario/services/_tenantGuards.js +| +| Guards compartilhados pelos repositories do prontuário clínico. +| Pattern canônico — ver blueprints/repository-blueprint.md seção 3. +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; + +export function assertTenantId(tenantId) { + if (!tenantId || tenantId === 'null' || tenantId === 'undefined') { + throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar no prontuário.'); + } +} + +export async function getUid() { + const { data, error } = await supabase.auth.getUser(); + if (error) throw error; + const uid = data?.user?.id; + if (!uid) throw new Error('Usuário não autenticado.'); + return uid; +} diff --git a/src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js b/src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js new file mode 100644 index 0000000..21a32eb --- /dev/null +++ b/src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js @@ -0,0 +1,140 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js +| +| Repository de clinical_note_templates. Escopo escalonado: +| - Sistema (is_system=true, tenant_id NULL) — todos authenticated leem +| - Tenant-wide (tenant_id, owner_id NULL) — membros do tenant +| - Owner (tenant_id + owner_id) — só o owner +| +| RLS bloqueia INSERT/UPDATE/DELETE de templates is_system — só via seed. +| Templates do tenant podem ser criados/editados pelo tenant_admin. +| +| Schema: ver migrations/20260520000001_clinical_notes_tables.sql +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid } from './_tenantGuards'; +import { CLINICAL_NOTE_TEMPLATE_SELECT } from './clinicalNotesSelects'; + +const VALID_NOTE_TYPES = ['anamnese', 'evolucao_sessao', 'plano_terapeutico', 'observacao_livre', 'resumo_caso']; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; + assertTenantId(tenantId); + return tenantId; +} + +/** + * Lista templates visíveis pelo usuário ativo (sistema + tenant + owner). + * RLS no banco filtra automaticamente; aqui só ordenamos. + * + * @param {Object} [opts] + * @param {string} [opts.noteType] - filtra por tipo de nota + * @param {string} [opts.tenantId] + * @param {boolean} [opts.includeInactive=false] + */ +export async function listAvailable({ noteType, tenantId, includeInactive = false } = {}) { + resolveTenantId(tenantId); // garante tenant ativo (RLS depende) + + let q = supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).order('is_system', { ascending: false }).order('name', { ascending: true }); + + if (!includeInactive) q = q.eq('active', true); + if (noteType) q = q.eq('note_type', noteType); + + const { data, error } = await q; + if (error) throw error; + return data || []; +} + +/** + * Lê template por id. + */ +export async function getById(templateId) { + if (!templateId) throw new Error('ID inválido.'); + + const { data, error } = await supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('id', templateId).maybeSingle(); + + if (error) throw error; + return data || null; +} + +/** + * Lê template por key (útil pra defaults — 'soap', 'dap', 'birp', 'anamnese_padrao'). + * Prioriza is_system se houver conflito de key (cobre seed primeiro). + */ +export async function getByKey(key, { noteType } = {}) { + if (!key) throw new Error('Key inválida.'); + + let q = supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('key', key).eq('active', true); + if (noteType) q = q.eq('note_type', noteType); + + const { data, error } = await q.order('is_system', { ascending: false }).limit(1).maybeSingle(); + if (error) throw error; + return data || null; +} + +/** + * Cria template tenant-wide ou owner-scoped. is_system bloqueado em RLS. + */ +export async function create(payload) { + if (!payload?.key) throw new Error('key obrigatória.'); + if (!payload?.name) throw new Error('name obrigatório.'); + if (!payload?.note_type) throw new Error('note_type obrigatório.'); + if (!VALID_NOTE_TYPES.includes(payload.note_type)) { + throw new Error(`note_type inválido. Aceitos: ${VALID_NOTE_TYPES.join(', ')}.`); + } + if (!payload?.structure) throw new Error('structure (jsonb) obrigatória.'); + + const uid = await getUid(); + const tid = resolveTenantId(); + + const row = { + tenant_id: tid, + owner_id: payload.ownerScoped ? uid : null, + key: String(payload.key).trim(), + name: String(payload.name).trim(), + note_type: payload.note_type, + description: payload.description ? String(payload.description).trim() || null : null, + structure: payload.structure, + is_system: false, + is_global: false, + active: payload.active !== false + }; + + const { data, error } = await supabase.from('clinical_note_templates').insert([row]).select(CLINICAL_NOTE_TEMPLATE_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Atualiza template. is_system bloqueado em RLS. + */ +export async function update(templateId, patch) { + if (!templateId) throw new Error('ID inválido.'); + + const safePatch = { ...patch, updated_at: new Date().toISOString() }; + if ('is_system' in safePatch) delete safePatch.is_system; // RLS bloqueia mas defesa em profundidade + + const { data, error } = await supabase.from('clinical_note_templates').update(safePatch).eq('id', templateId).select(CLINICAL_NOTE_TEMPLATE_SELECT).single(); + + if (error) throw error; + return data; +} + +/** + * Soft delete via active=false. + */ +export async function softDelete(templateId) { + if (!templateId) throw new Error('ID inválido.'); + + const { error } = await supabase.from('clinical_note_templates').update({ active: false, updated_at: new Date().toISOString() }).eq('id', templateId); + + if (error) throw error; + return true; +} diff --git a/src/features/patients/prontuario/services/clinicalNotesRepository.js b/src/features/patients/prontuario/services/clinicalNotesRepository.js new file mode 100644 index 0000000..c012a0e --- /dev/null +++ b/src/features/patients/prontuario/services/clinicalNotesRepository.js @@ -0,0 +1,251 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/prontuario/services/clinicalNotesRepository.js +| +| Repository de clinical_notes (anamnese, evolução, plano, observação livre, +| resumo de caso). RLS owner-only (CFP sigilo profissional). +| +| Schema: ver migrations/20260520000001_clinical_notes_tables.sql +| Trigger AUTO-versiona em INSERT e UPDATE de content/title/deleted_at. +| +| ⚠️ Pré-requisito: migrations 20260520000001..3 executadas no banco. +| Sem isso, todas as funções abaixo retornam erro de "tabela não existe". +|-------------------------------------------------------------------------- +*/ +import { supabase } from '@/lib/supabase/client'; +import { useTenantStore } from '@/stores/tenantStore'; +import { assertTenantId, getUid } from './_tenantGuards'; +import { + CLINICAL_NOTE_SELECT, + CLINICAL_NOTE_SELECT_BRIEF, + CLINICAL_NOTE_VERSION_SELECT, + flattenNoteRow +} from './clinicalNotesSelects'; + +const VALID_NOTE_TYPES = ['anamnese', 'evolucao_sessao', 'plano_terapeutico', 'observacao_livre', 'resumo_caso']; + +function resolveTenantId(tenantIdArg) { + const tenantStore = useTenantStore(); + const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; + assertTenantId(tenantId); + return tenantId; +} + +function assertNoteType(noteType) { + if (!VALID_NOTE_TYPES.includes(noteType)) { + throw new Error(`Tipo de nota inválido. Aceitos: ${VALID_NOTE_TYPES.join(', ')}.`); + } +} + +/** + * Lista notas ativas (deleted_at IS NULL) de um paciente. + * Pinned primeiro, depois desc por created_at. + * + * @param {string} patientId + * @param {Object} [opts] + * @param {string} [opts.tenantId] + * @param {string|null} [opts.noteType] - filtra por tipo (anamnese, evolucao_sessao, etc) + * @param {boolean} [opts.includeDeleted=false] + * @param {boolean} [opts.brief=false] - usa SELECT brief sem content (lista/timeline) + */ +export async function listForPatient(patientId, { tenantId, noteType = null, includeDeleted = false, brief = false } = {}) { + if (!patientId) return []; + const tid = resolveTenantId(tenantId); + const select = brief ? CLINICAL_NOTE_SELECT_BRIEF : CLINICAL_NOTE_SELECT; + + let q = supabase + .from('clinical_notes') + .select(select) + .eq('tenant_id', tid) + .eq('patient_id', patientId) + .order('pinned', { ascending: false }) + .order('created_at', { ascending: false }); + + if (!includeDeleted) q = q.is('deleted_at', null); + if (noteType) q = q.eq('note_type', noteType); + + const { data, error } = await q; + if (error) throw error; + return (data || []).map(flattenNoteRow); +} + +/** + * Lista notas vinculadas a uma sessão (session_event_id). + * Útil pra mostrar "anotações desta sessão" no AgendaEventDialog. + */ +export async function listForSession(sessionEventId, { tenantId, brief = false } = {}) { + if (!sessionEventId) return []; + const tid = resolveTenantId(tenantId); + const select = brief ? CLINICAL_NOTE_SELECT_BRIEF : CLINICAL_NOTE_SELECT; + + const { data, error } = await supabase.from('clinical_notes').select(select).eq('tenant_id', tid).eq('session_event_id', sessionEventId).is('deleted_at', null).order('created_at', { ascending: false }); + + if (error) throw error; + return (data || []).map(flattenNoteRow); +} + +/** + * Lê uma nota completa por id. + */ +export async function getById(noteId, { tenantId } = {}) { + if (!noteId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + + const { data, error } = await supabase.from('clinical_notes').select(CLINICAL_NOTE_SELECT).eq('id', noteId).eq('tenant_id', tid).maybeSingle(); + + if (error) throw error; + return data ? flattenNoteRow(data) : null; +} + +/** + * Cria nota clínica. owner_id + tenant_id + created_by injetados pelo repository. + * Trigger no banco cria automaticamente version_number=1. + * + * @param {Object} payload + * @param {string} payload.patient_id - obrigatório + * @param {string} payload.note_type - obrigatório (CHECK no banco) + * @param {string} [payload.session_event_id] + * @param {string} [payload.template_id] + * @param {string} [payload.title] + * @param {string} [payload.content_text] + * @param {Object} [payload.content_structured] + * @param {boolean} [payload.pinned=false] + * @param {boolean} [payload.is_draft=false] + */ +export async function create(payload) { + if (!payload?.patient_id) throw new Error('patient_id obrigatório.'); + if (!payload?.note_type) throw new Error('note_type obrigatório.'); + assertNoteType(payload.note_type); + if (!payload.content_text && !payload.content_structured) { + throw new Error('Nota precisa de content_text ou content_structured (CHECK constraint).'); + } + + const uid = await getUid(); + const tid = resolveTenantId(); + + const row = { + patient_id: payload.patient_id, + owner_id: uid, + tenant_id: tid, + session_event_id: payload.session_event_id || null, + note_type: payload.note_type, + template_id: payload.template_id || null, + title: payload.title ? String(payload.title).trim() || null : null, + content_text: payload.content_text ? String(payload.content_text).trim() || null : null, + content_structured: payload.content_structured || null, + pinned: !!payload.pinned, + is_draft: !!payload.is_draft, + created_by: uid + }; + + const { data, error } = await supabase.from('clinical_notes').insert([row]).select(CLINICAL_NOTE_SELECT).single(); + + if (error) throw error; + return flattenNoteRow(data); +} + +/** + * Atualiza nota clínica. Repository injeta updated_by + updated_at é setado pelo trigger. + * Trigger cria nova versão se content/title/deleted_at mudaram. + * + * @param {string} noteId + * @param {Object} patch + * @param {Object} [opts] + * @param {string} [opts.tenantId] + */ +export async function update(noteId, patch, { tenantId } = {}) { + if (!noteId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + + if (patch?.note_type) assertNoteType(patch.note_type); + + const safePatch = { ...sanitize(patch), updated_by: uid }; + + const { data, error } = await supabase.from('clinical_notes').update(safePatch).eq('id', noteId).eq('tenant_id', tid).select(CLINICAL_NOTE_SELECT).single(); + + if (error) throw error; + return flattenNoteRow(data); +} + +/** + * Soft delete — seta deleted_at + deleted_by. Trigger cria versão snapshot. + * Hard delete bloqueado em RLS — só soft. + */ +export async function softDelete(noteId, { tenantId } = {}) { + if (!noteId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + + const { error } = await supabase + .from('clinical_notes') + .update({ deleted_at: new Date().toISOString(), deleted_by: uid, updated_by: uid }) + .eq('id', noteId) + .eq('tenant_id', tid); + + if (error) throw error; + return true; +} + +/** + * Restore — clears deleted_at/deleted_by. Trigger cria versão snapshot com reason='restore'. + */ +export async function restore(noteId, { tenantId } = {}) { + if (!noteId) throw new Error('ID inválido.'); + const tid = resolveTenantId(tenantId); + const uid = await getUid(); + + const { error } = await supabase.from('clinical_notes').update({ deleted_at: null, deleted_by: null, updated_by: uid }).eq('id', noteId).eq('tenant_id', tid); + + if (error) throw error; + return true; +} + +/** + * Toggle pinned (utilitário comum). + */ +export async function setPinned(noteId, pinned, { tenantId } = {}) { + return update(noteId, { pinned: !!pinned }, { tenantId }); +} + +/** + * Lista versões (audit trail) de uma nota. Ordem desc por version_number. + */ +export async function listVersions(noteId) { + if (!noteId) return []; + + const { data, error } = await supabase.from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).order('version_number', { ascending: false }); + + if (error) throw error; + return data || []; +} + +/** + * Lê snapshot de uma versão específica. + */ +export async function getVersion(noteId, versionNumber) { + if (!noteId) throw new Error('noteId obrigatório.'); + if (!versionNumber) throw new Error('versionNumber obrigatório.'); + + const { data, error } = await supabase.from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).eq('version_number', versionNumber).maybeSingle(); + + if (error) throw error; + return data || null; +} + +// ─── helpers internos ──────────────────────────────────────────────────────── + +function sanitize(patch) { + const out = { ...patch }; + if ('title' in out && typeof out.title === 'string') { + const t = out.title.trim(); + out.title = t === '' ? null : t; + } + if ('content_text' in out && typeof out.content_text === 'string') { + const t = out.content_text.trim(); + out.content_text = t === '' ? null : t; + } + return out; +} diff --git a/src/features/patients/prontuario/services/clinicalNotesSelects.js b/src/features/patients/prontuario/services/clinicalNotesSelects.js new file mode 100644 index 0000000..d910564 --- /dev/null +++ b/src/features/patients/prontuario/services/clinicalNotesSelects.js @@ -0,0 +1,63 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/prontuario/services/clinicalNotesSelects.js +| +| SELECTs canônicos do prontuário clínico (clinical_notes, versions, templates). +| Schema definido nas migrations 20260520000001_clinical_notes_tables.sql etc. +|-------------------------------------------------------------------------- +*/ + +/** SELECT completo de clinical_notes (excluindo deletadas via filter no caller). */ +export const CLINICAL_NOTE_SELECT = ` + id, tenant_id, owner_id, patient_id, session_event_id, note_type, + template_id, title, content_text, content_structured, + pinned, is_draft, created_at, updated_at, created_by, updated_by, + deleted_at, deleted_by +` + .replace(/\s+/g, ' ') + .trim(); + +/** SELECT brief — pra listagens/cards sem content pesado. */ +export const CLINICAL_NOTE_SELECT_BRIEF = ` + id, patient_id, session_event_id, note_type, template_id, title, + pinned, is_draft, created_at, updated_at +` + .replace(/\s+/g, ' ') + .trim(); + +/** SELECT de versões — audit trail completo. */ +export const CLINICAL_NOTE_VERSION_SELECT = ` + id, note_id, tenant_id, version_number, title, + content_text, content_structured, change_reason, + created_at, created_by +` + .replace(/\s+/g, ' ') + .trim(); + +/** SELECT de templates. */ +export const CLINICAL_NOTE_TEMPLATE_SELECT = ` + id, tenant_id, owner_id, key, name, note_type, description, + structure, is_system, is_global, active, created_at, updated_at +` + .replace(/\s+/g, ' ') + .trim(); + +/** + * Status derivado da nota — runtime, não persistido. + * draft: is_draft = true + * active: nem draft nem deletado + * deleted: deleted_at set + */ +export function deriveNoteStatus(row) { + if (!row) return 'active'; + if (row.deleted_at) return 'deleted'; + if (row.is_draft) return 'draft'; + return 'active'; +} + +export function flattenNoteRow(r) { + if (!r) return r; + return { ...r, status: deriveNoteStatus(r) }; +}