Sessoes 6cont-10: hardening em 6 areas + scan completo do SaaS
Continuacao de 7c20b51. Esta etapa fechou TODA revisao senior do SaaS
(15 areas auditadas) + refator parcial de pacientes.
Ver commit.md para descricao completa por sessao.
# Estado final do projeto
- A# auditoria abertos: 1 (A#31 Deploy real)
- V# verificacoes abertos: 14 (todos medios/baixos adiados com plano)
- Criticos: 0
- Altos: 0
- Vitest: 208/208 (era 192, +16 nos novos composables)
- SQL integration: 33/33
- E2E (Playwright): 5/5
- Areas auditadas: 15
# Highlights
- Documentos 100% fechado (V#50/51/52: portal-paciente policy + content_sha256 + 4 cron jobs retention)
- Tenants V#1 P0: tenant_invites com RLS off + 0 policies (mesmo padrao A#30)
- Calendario 100% fechado: feriados WITH CHECK
- Addons V#1 P0 (dinheiro): addon_transactions WITH CHECK saas_admin
- Central SaaS V#1: faq write so saas_admin (era tenant_admin)
- Servicos/Prontuarios 100% fechado: services/medicos/insurance_plans + cascades
- Pacientes V#9: 2 composables novos (useCep, usePatientSupportContacts) + repo estendido + script extraido (template intocado, fica para quando houver E2E)
# 8 migrations novas neste commit
- 20260419000011_documents_portal_patient_policy.sql
- 20260419000012_documents_content_hash.sql
- 20260419000013_cron_retention_jobs.sql
- 20260419000014_financial_security_hardening.sql
- 20260419000015_communication_security_hardening.sql
- 20260419000016_tenants_calendario_hardening.sql
- 20260419000017_addons_central_saas_hardening.sql
- 20260419000018_servicos_prontuarios_hardening.sql
Total acumulado: 18 migrations (Sessoes 1-10).
# A#31 reformulado pra proxima sessao
"Deploy real" muda escopo: como nao ha cloud Supabase nem secrets reais
ainda (MVP), proxima sessao vira "Preparacao completa pra deploy" (DEPLOY.md,
validar migrations num container limpo, audit edge functions, listar env vars,
script db.cjs deploy-check).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/patients/services/patientsRepository.js
|
||||
| V#3 — fundação: queries de patients centralizadas.
|
||||
|
|
||||
| Mesmo padrão de feature/agenda/services/agendaRepository.js. Pages devem
|
||||
| chamar este repo em vez de fazer supabase.from('patients') direto.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
|
||||
|
||||
const PATIENTS_SELECT_BASE = `
|
||||
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
|
||||
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
|
||||
cpf, rg, data_nascimento, naturalidade, nacionalidade, genero, estado_civil,
|
||||
profissao, escolaridade, status, status_pagamento,
|
||||
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
|
||||
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
|
||||
cobranca_no_responsavel,
|
||||
onde_nos_conheceu, encaminhado_por, observacoes,
|
||||
last_attended_at, created_at, updated_at,
|
||||
risco_sinalizado_por, convenio_id, patient_scope
|
||||
`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Patients core
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lista pacientes do tenant ativo. Aceita filtros opcionais.
|
||||
* @param {object} opts - { tenantId, ownerId?, includeInactive?, limit? }
|
||||
*/
|
||||
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
|
||||
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId).is('deleted_at', null);
|
||||
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');
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select(PATIENTS_SELECT_BASE)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPatient(payload) {
|
||||
const tenantId = payload?.tenant_id;
|
||||
assertTenantId(tenantId);
|
||||
const ownerId = payload?.owner_id || (await getUid());
|
||||
const row = { ...payload, tenant_id: tenantId, owner_id: ownerId };
|
||||
const { data, error } = await supabase.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');
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.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');
|
||||
assertTenantId(tenantId);
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ deleted_at: new Date().toISOString(), status: 'Arquivado' })
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Groups
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function listGroups({ tenantId, ownerId = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
let q = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').eq('tenant_id', tenantId).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 [];
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select('patient_id, patient_group_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Tags
|
||||
// -----------------------------------------------------------------------------
|
||||
export async function listTags({ tenantId, ownerId = null } = {}) {
|
||||
assertTenantId(tenantId);
|
||||
let q = supabase.from('patient_tags').select('id, nome, cor, owner_id').eq('tenant_id', tenantId);
|
||||
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 [];
|
||||
assertTenantId(tenantId);
|
||||
const { data, error } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.select('patient_id, tag_id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('patient_id', patientIds);
|
||||
if (error) throw error;
|
||||
return data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna {groupIds, tagIds} de um paciente.
|
||||
*/
|
||||
export async function getPatientRelations(patientId) {
|
||||
if (!patientId) return { groupIds: [], tagIds: [] };
|
||||
const [{ data: g, error: ge }, { data: t, error: te }] = await Promise.all([
|
||||
supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', patientId),
|
||||
supabase.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)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId);
|
||||
if (del) throw del;
|
||||
if (!groupId) return;
|
||||
const { error: ins } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tenantId });
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitui as tags do paciente (lista). Limpa antigas do owner + inserta as novas.
|
||||
*/
|
||||
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 { error: del } = await supabase
|
||||
.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 supabase
|
||||
.from('patient_patient_tag')
|
||||
.insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tenantId })));
|
||||
if (ins) throw ins;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Sessões agregadas (V#8 — get_patient_session_counts RPC)
|
||||
// -----------------------------------------------------------------------------
|
||||
/**
|
||||
* Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER.
|
||||
* @param {string[]} patientIds
|
||||
* @returns {Array<{patient_id, session_count, last_session_at}>}
|
||||
*/
|
||||
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 || [];
|
||||
}
|
||||
Reference in New Issue
Block a user