/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Arquivo: src/features/patients/services/patientsRepository.js | V#3 — fundação: queries de patients centralizadas. | | Pages e composables devem chamar este repo em vez de fazer | tenantDb().from('patients') direto. | | Inclui também reads cross-feature em escopo de paciente (agenda_eventos, | financial_records, documents, recurrence_rules, conversation_messages, | patient_support_contacts) — usados pelos sub-composables do prontuário. | Quando M4 (Financeiro) / M6 (Notificações/Conversations) padronizarem | esses domínios, as funções respectivas migram pra repositories nativos. |-------------------------------------------------------------------------- */ import { supabase } from '@/lib/supabase/client'; import { tenantDb } from '@/lib/supabase/tenantClient'; import { useTenantStore } from '@/stores/tenantStore'; import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards'; import { PATIENTS_SELECT_BASE, PATIENT_SESSIONS_SELECT, PATIENT_FINANCIAL_RECORDS_SELECT, PATIENT_DOCUMENTS_SELECT, PATIENT_MESSAGES_SELECT, PATIENT_RECURRENCE_RULES_SELECT, PATIENT_SUPPORT_CONTACTS_SELECT, PATIENT_GROUPS_SELECT, PATIENT_GROUPS_SELECT_BRIEF, PATIENT_TAGS_SELECT, PATIENT_TAGS_SELECT_BRIEF } from './patientsSelects'; // ─── Helpers internos ──────────────────────────────────────────────────────── function resolveTenantId(tenantIdArg) { const tenantStore = useTenantStore(); const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId; assertTenantId(tenantId); return tenantId; } // ───────────────────────────────────────────────────────────────────────── // Patients core // ----------------------------------------------------------------------------- /** * Lista pacientes do tenant ativo. Aceita filtros opcionais. */ export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) { const tid = resolveTenantId(tenantId); let q = tenantDb().from('patients').select(PATIENTS_SELECT_BASE); if (ownerId) q = q.eq('owner_id', ownerId); if (!includeInactive) q = q.neq('status', 'Inativo'); if (limit) q = q.limit(limit); q = q.order('created_at', { ascending: false }); const { data, error } = await q; if (error) throw error; return data || []; } export async function getPatientById(id, { tenantId } = {}) { if (!id) throw new Error('id obrigatório'); const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).maybeSingle(); if (error) throw error; return data; } export async function createPatient(payload) { const tid = resolveTenantId(payload?.tenant_id); // owner_id SEMPRE injetado do uid logado (não aceita do payload). // Audit baseline alta (2026-05-20): aceitar do payload permitiria // criar pacientes "de outro terapeuta". Repository é defesa em profundidade. const ownerId = await getUid(); // eslint-disable-next-line no-unused-vars const { owner_id: _dropped, tenant_id: _tenantDropped, ...rest } = payload || {}; const row = { ...rest, owner_id: ownerId }; const { data, error } = await tenantDb().from('patients').insert(row).select(PATIENTS_SELECT_BASE).single(); if (error) throw error; return data; } export async function updatePatient(id, patch, { tenantId } = {}) { if (!id) throw new Error('id obrigatório'); const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('patients').update(patch).eq('id', id).select(PATIENTS_SELECT_BASE).single(); if (error) throw error; return data; } export async function softDeletePatient(id, { tenantId } = {}) { if (!id) throw new Error('id obrigatório'); const tid = resolveTenantId(tenantId); const { error } = await tenantDb().from('patients').update({ status: 'Arquivado' }).eq('id', id); if (error) throw error; } // Pra restaurar paciente arquivado, use `reactivatePatient` do `usePatientLifecycle`. // ───────────────────────────────────────────────────────────────────────── // Patient Relations (groups + tags) // ----------------------------------------------------------------------------- /** * Retorna {groupIds, tagIds} de um paciente (joins junior tables). */ export async function getPatientRelations(patientId) { if (!patientId) return { groupIds: [], tagIds: [] }; const [{ data: g, error: ge }, { data: t, error: te }] = await Promise.all([ tenantDb().from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId), tenantDb().from('patient_patient_tag').select('tag_id').eq('patient_id', patientId) ]); if (ge) throw ge; if (te) throw te; return { groupIds: (g || []).map((x) => x.patient_group_id).filter(Boolean), tagIds: (t || []).map((x) => x.tag_id).filter(Boolean) }; } // ─── Groups ──────────────────────────────────────────────────────────────── export async function listGroups({ tenantId, ownerId = null } = {}) { const tid = resolveTenantId(tenantId); let q = tenantDb().from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('is_active', true); if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`); q = q.order('nome', { ascending: true }); const { data, error } = await q; if (error) throw error; return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system })); } export async function listGroupsByPatient(patientIds, { tenantId } = {}) { if (!patientIds?.length) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('patient_group_patient').select('patient_id, patient_group_id').in('patient_id', patientIds); if (error) throw error; return data || []; } /** * Lê grupos por ids (brief — id+name). Usado em prontuário pra mostrar pílulas. */ export async function getGroupsByIds(ids) { if (!ids?.length) return []; const { data, error } = await tenantDb().from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true }); if (error) throw error; return (data || []).map((g) => ({ id: g.id, name: g.nome })); } /** * Substitui o grupo do paciente (1:1 — sistema atual). */ export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) { if (!patientId) throw new Error('patientId obrigatório'); const tid = resolveTenantId(tenantId); const { error: del } = await tenantDb().from('patient_group_patient').delete().eq('patient_id', patientId); if (del) throw del; if (!groupId) return; const { error: ins } = await tenantDb().from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId }); if (ins) throw ins; } // ─── Tags ────────────────────────────────────────────────────────────────── export async function listTags({ tenantId, ownerId = null } = {}) { const tid = resolveTenantId(tenantId); let q = tenantDb().from('patient_tags').select(PATIENT_TAGS_SELECT); if (ownerId) q = q.eq('owner_id', ownerId); const { data, error } = await q; if (error) throw error; return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor })); } export async function listTagsByPatient(patientIds, { tenantId } = {}) { if (!patientIds?.length) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('patient_patient_tag').select('patient_id, tag_id').in('patient_id', patientIds); if (error) throw error; return data || []; } /** * Lê tags por ids (brief — id+name+color). Usado em prontuário. */ export async function getTagsByIds(ids) { if (!ids?.length) return []; const { data, error } = await tenantDb().from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true }); if (error) throw error; return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor })); } /** * Substitui as tags do paciente. */ export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId } = {}) { if (!patientId) throw new Error('patientId obrigatório'); if (!ownerId) throw new Error('ownerId obrigatório'); const tid = resolveTenantId(tenantId); const { error: del } = await tenantDb().from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId); if (del) throw del; const clean = Array.from(new Set((tagIds || []).filter(Boolean))); if (!clean.length) return; const { error: ins } = await tenantDb().from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id }))); if (ins) throw ins; } // ───────────────────────────────────────────────────────────────────────── // Sessions (agenda_eventos) em escopo de paciente // ----------------------------------------------------------------------------- // Cross-feature: chamadas pelo usePatientSessions. Migração futura: quando // agendaRepository for ampliado pra exportar listByPatient, este módulo deixa // de duplicar. /** * Lista sessões reais (agenda_eventos) do paciente, 100 mais recentes desc. */ export async function listSessionsByPatient(patientId, { tenantId } = {}) { if (!patientId) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('agenda_eventos') .select(PATIENT_SESSIONS_SELECT) .eq('patient_id', patientId) .order('inicio_em', { ascending: false }) .limit(100); if (error) throw error; return data || []; } /** * Cria sessão pro paciente. owner_id + tenant_id injetados pelo repository. * * payload aceita: inicio_em, fim_em, status?, modalidade?, tipo?, titulo?, * titulo_custom?, observacoes?, recurrence_id?, recurrence_date?, * determined_commitment_id?, price? */ export async function createPatientSession(patientId, payload) { if (!patientId) throw new Error('patientId obrigatório'); if (!payload?.inicio_em || !payload?.fim_em) throw new Error('Início/fim obrigatórios'); const uid = await getUid(); const tid = resolveTenantId(); const row = { patient_id: patientId, owner_id: uid, inicio_em: payload.inicio_em, fim_em: payload.fim_em, status: payload.status || 'agendado', modalidade: payload.modalidade || 'presencial', tipo: payload.tipo || 'sessao', titulo: payload.titulo ? String(payload.titulo).trim() || null : null, titulo_custom: payload.titulo_custom ? String(payload.titulo_custom).trim() || null : null, observacoes: payload.observacoes ? String(payload.observacoes).trim() || null : null, recurrence_id: payload.recurrence_id || null, recurrence_date: payload.recurrence_date || null, determined_commitment_id: payload.determined_commitment_id || null, price: payload.price ?? null }; const { data, error } = await tenantDb().from('agenda_eventos').insert([row]).select().single(); if (error) throw error; return data; } /** * Atualiza só o status de uma sessão (mutation comum em prontuário). */ export async function updatePatientSessionStatus(sessionId, status, { tenantId } = {}) { if (!sessionId) throw new Error('sessionId obrigatório'); const tid = resolveTenantId(tenantId); const { error } = await tenantDb().from('agenda_eventos').update({ status }).eq('id', sessionId); if (error) throw error; } /** * Procura sessão materializada por (recurrence_id, recurrence_date). * Usado pra decidir entre UPDATE (já existe) e INSERT (materializar virtual). */ export async function findSessionByRecurrence(recurrenceId, recurrenceDate) { if (!recurrenceId || !recurrenceDate) return null; const { data, error } = await tenantDb().from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle(); if (error) throw error; return data || null; } // ───────────────────────────────────────────────────────────────────────── // Financial Records em escopo de paciente // ----------------------------------------------------------------------------- // Cross-feature: migra pra features/financeiro/services no Módulo 4. /** * Lista lançamentos do paciente (type=receita, 100 mais recentes). */ export async function listFinancialRecordsByPatient(patientId, { tenantId } = {}) { if (!patientId) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('financial_records') .select(PATIENT_FINANCIAL_RECORDS_SELECT) .eq('patient_id', patientId) .eq('type', 'receita') .order('created_at', { ascending: false }) .limit(100); if (error) throw error; return data || []; } /** * Cria lançamento manual pro paciente (type=receita). */ export async function createFinancialRecord(patientId, payload) { if (!patientId) throw new Error('patientId obrigatório'); if (!payload?.amount || Number.isNaN(Number(payload.amount))) { throw new Error('Valor inválido'); } const uid = await getUid(); const tid = resolveTenantId(); const row = { patient_id: patientId, owner_id: uid, type: 'receita', amount: Number(payload.amount), due_date: payload.due_date || null, description: payload.description ? String(payload.description).trim() || null : null, payment_method: payload.payment_method || null, paid_at: null }; const { data, error } = await tenantDb().from('financial_records').insert([row]).select().single(); if (error) throw error; return data; } /** * Marca lançamento como pago (paid_at = now). */ export async function markFinancialRecordPaid(recordId, { tenantId } = {}) { if (!recordId) throw new Error('recordId obrigatório'); const tid = resolveTenantId(tenantId); const { error } = await tenantDb().from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId); if (error) throw error; } /** * Reverte: remove paid_at (volta pra pendente). */ export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) { if (!recordId) throw new Error('recordId obrigatório'); const tid = resolveTenantId(tenantId); const { error } = await tenantDb().from('financial_records').update({ paid_at: null }).eq('id', recordId); if (error) throw error; } // ───────────────────────────────────────────────────────────────────────── // Documents em escopo de paciente (deleted_at IS NULL) // ----------------------------------------------------------------------------- export async function listDocumentsByPatient(patientId, { tenantId } = {}) { if (!patientId) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('documents') .select(PATIENT_DOCUMENTS_SELECT) .eq('patient_id', patientId) .is('deleted_at', null) .order('created_at', { ascending: false }) .limit(200); if (error) throw error; return data || []; } // ───────────────────────────────────────────────────────────────────────── // Messages (conversation_messages) em escopo de paciente // ----------------------------------------------------------------------------- // Cross-feature: migra pra features/conversations no Módulo 6. export async function listMessagesByPatient(patientId, { tenantId } = {}) { if (!patientId) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('conversation_messages') .select(PATIENT_MESSAGES_SELECT) .eq('patient_id', patientId) .order('created_at', { ascending: false }) .limit(200); if (error) throw error; return data || []; } // ───────────────────────────────────────────────────────────────────────── // Recurrence Rules em escopo de paciente // ----------------------------------------------------------------------------- // Cross-feature: migra pra agendaRepository quando padronizado. export async function listRecurrencesByPatient(patientId, { tenantId } = {}) { if (!patientId) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('recurrence_rules') .select(PATIENT_RECURRENCE_RULES_SELECT) .eq('patient_id', patientId) .order('start_date', { ascending: false }); if (error) throw error; return data || []; } /** * Atualiza status da regra de recorrência (cancelado/ativo). */ export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {}) { if (!ruleId) throw new Error('ruleId obrigatório'); const tid = resolveTenantId(tenantId); const { error } = await tenantDb().from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId); if (error) throw error; } // ───────────────────────────────────────────────────────────────────────── // Support Contacts (patient_support_contacts) // ----------------------------------------------------------------------------- export async function listSupportContactsByPatient(patientId, { tenantId } = {}) { if (!patientId) return []; const tid = resolveTenantId(tenantId); const { data, error } = await tenantDb().from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('patient_id', patientId).order('is_primario', { ascending: false }); if (error) throw error; return data || []; } /** * Substitui todos os contatos do paciente (delete-then-insert). * Pattern original do composable. * * @param {Array} contacts - rows já mapeadas (sem patient_id/tenant_id/owner_id — injetados aqui) */ export async function replacePatientSupportContacts(patientId, contacts, { tenantId, ownerId } = {}) { if (!patientId) throw new Error('patientId obrigatório'); if (!ownerId) throw new Error('ownerId obrigatório'); const tid = resolveTenantId(tenantId); const { error: del } = await tenantDb().from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId); if (del) throw del; if (!contacts?.length) return; const rows = contacts.map((c) => ({ ...c, patient_id: patientId, owner_id: ownerId })); const { error: ins } = await tenantDb().from('patient_support_contacts').insert(rows); if (ins) throw ins; } // ───────────────────────────────────────────────────────────────────────── // Patient Intake Requests (convert flow) // ----------------------------------------------------------------------------- // Cross-feature: usado pelos 2 callers de "Cadastros recebidos" // (CadastrosRecebidosPage + MelissaCadastrosRecebidos). Centraliza a // transição intake → patient pra eliminar duplicação (Fase 2 — Graphify hotspot). /** * Marca um patient_intake_request como convertido em paciente. * Caller deve já ter criado o paciente via createPatient() antes. * * @param {string} intakeId * @param {string} patientId - id do paciente recém-criado * @param {Object} [opts] * @param {string} [opts.tenantId] */ export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}) { if (!intakeId) throw new Error('intakeId obrigatório.'); if (!patientId) throw new Error('patientId obrigatório.'); // tenant_id no patient_intake_requests pode ser nullable (intake público sem tenant) // — só filtramos se passado explícito. let q = supabase .from('patient_intake_requests') .update({ status: 'converted', converted_patient_id: patientId, updated_at: new Date().toISOString() }) .eq('id', intakeId); if (tenantId) { const tid = resolveTenantId(tenantId); q = q.eq('tenant_id', tid); } const { error } = await q; if (error) throw error; } // ───────────────────────────────────────────────────────────────────────── // Sessions count aggregate (RPC já existente) // ----------------------------------------------------------------------------- /** * Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER. */ export async function getSessionCounts(patientIds) { if (!patientIds?.length) return []; const tid = resolveTenantId(); const { data, error } = await supabase.rpc('get_patient_session_counts', { p_tenant_id: tid, p_patient_ids: patientIds }); if (error) throw error; return data || []; }