f17e9ee786
Fluxos anon identificam tenant por token/slug e nao resolvem o schema fisico. Decisao (opcao C): manter em public com RLS por token. Volta a global: patient_intake_requests, patient_invites, patient_invite_attempts, document_share_links, agendador_configuracoes, agendador_solicitacoes. - migration 20260613000001_f1b: remove as 6 do _tenant_template (template v2, 78 tabelas). Smoke: clone gera 78, zero tabelas anon no schema, drop limpo - frontend: 38 cadeias em 14 arquivos revertidas tenantDb().from() -> supabase.from() com tenant_id/owner_id restaurado (via comparacao com main) - edge: convert-abandoned-intakes restaurada do main (SELECT global) - save-intake-progress: ja usava public, sem mudanca - doc F0 atualizado: 78 tenant + 59 global Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
517 lines
22 KiB
JavaScript
517 lines
22 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| 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 { data, error } = await supabase.rpc('get_patient_session_counts', { p_patient_ids: patientIds });
|
|
if (error) throw error;
|
|
return data || [];
|
|
}
|