Files
agenciapsilmno/src/services/Documents.service.js
T
Leonardo d6eb992f71 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>
2026-04-19 22:00:06 -03:00

366 lines
12 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/services/Documents.service.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
const BUCKET = 'documents';
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
return uid;
}
async function getActiveTenantId(uid) {
const { data, error } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.single();
if (error) throw error;
if (!data?.tenant_id) throw new Error('Tenant não encontrado.');
return data.tenant_id;
}
function buildStoragePath(tenantId, patientId, fileName) {
const timestamp = Date.now();
const safe = String(fileName || 'arquivo').replace(/[^a-zA-Z0-9._-]/g, '_');
return `${tenantId}/${patientId}/${timestamp}-${safe}`;
}
// V#51: SHA-256 hex do conteúdo. Calculado no client antes do upload.
async function computeSha256Hex(file) {
if (!file || !window?.crypto?.subtle) return null;
try {
const buf = await file.arrayBuffer();
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hashBuf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
} catch {
return null; // hash é best-effort; falha não bloqueia upload
}
}
// ── Upload ──────────────────────────────────────────────────
/**
* Faz upload de arquivo ao Storage e registra na tabela documents.
*
* @param {File} file - Objeto File do input
* @param {string} patientId - UUID do paciente
* @param {object} meta - { tipo_documento, categoria, descricao, tags[], agenda_evento_id, visibilidade }
* @returns {object} - Registro criado em documents
*/
export async function uploadDocument(file, patientId, meta = {}) {
if (!file) throw new Error('Nenhum arquivo selecionado.');
if (!patientId) throw new Error('Paciente não informado.');
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
// V#51: hash SHA-256 do conteúdo ANTES do upload (integridade)
const contentSha256 = await computeSha256Hex(file);
// Upload ao Storage
const path = buildStoragePath(tenantId, patientId, file.name);
const { error: upErr } = await supabase.storage
.from(BUCKET)
.upload(path, file, { contentType: file.type });
if (upErr) throw upErr;
// Insert na tabela
const row = {
owner_id: ownerId,
tenant_id: tenantId,
patient_id: patientId,
bucket_path: path,
storage_bucket: BUCKET,
nome_original: file.name,
mime_type: file.type || null,
tamanho_bytes: file.size || null,
content_sha256: contentSha256,
tipo_documento: meta.tipo_documento || 'outro',
categoria: meta.categoria || null,
descricao: meta.descricao || null,
tags: meta.tags || [],
agenda_evento_id: meta.agenda_evento_id || null,
visibilidade: meta.visibilidade || 'privado',
compartilhado_portal: meta.compartilhado_portal || false,
compartilhado_supervisor: meta.compartilhado_supervisor || false,
enviado_pelo_paciente: meta.enviado_pelo_paciente || false,
status_revisao: meta.enviado_pelo_paciente ? 'pendente' : 'aprovado',
uploaded_by: ownerId
};
const { data, error } = await supabase
.from('documents')
.insert(row)
.select('*')
.single();
if (error) {
// Tenta limpar o arquivo do Storage em caso de erro no insert
await supabase.storage.from(BUCKET).remove([path]).catch(() => {});
throw error;
}
return data;
}
// ── List ────────────────────────────────────────────────────
/**
* Lista documentos de um paciente (excluindo soft-deleted).
*
* @param {string} patientId
* @param {object} filters - { tipo_documento, categoria, tag, search }
*/
export async function listDocuments(patientId, filters = {}) {
const ownerId = await getOwnerId();
let query = supabase
.from('documents')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('uploaded_at', { ascending: false });
if (filters.tipo_documento) {
query = query.eq('tipo_documento', filters.tipo_documento);
}
if (filters.categoria) {
query = query.eq('categoria', filters.categoria);
}
if (filters.tag) {
query = query.contains('tags', [filters.tag]);
}
if (filters.search) {
query = query.ilike('nome_original', `%${filters.search}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
/**
* Lista todos os documentos do owner (todos os pacientes).
*/
export async function listAllDocuments(filters = {}) {
const ownerId = await getOwnerId();
let query = supabase
.from('documents')
.select('*, patients!inner(nome_completo)')
.eq('owner_id', ownerId)
.is('deleted_at', null)
.order('uploaded_at', { ascending: false });
if (filters.tipo_documento) {
query = query.eq('tipo_documento', filters.tipo_documento);
}
if (filters.search) {
query = query.ilike('nome_original', `%${filters.search}%`);
}
const { data, error } = await query;
if (error) throw error;
return data || [];
}
// ── Get one ─────────────────────────────────────────────────
export async function getDocument(id) {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('documents')
.select('*')
.eq('id', id)
.eq('owner_id', ownerId)
.single();
if (error) throw error;
return data;
}
// ── Update ──────────────────────────────────────────────────
export async function updateDocument(id, payload) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const row = {};
if (payload.tipo_documento !== undefined) row.tipo_documento = payload.tipo_documento;
if (payload.categoria !== undefined) row.categoria = payload.categoria;
if (payload.descricao !== undefined) row.descricao = payload.descricao;
if (payload.tags !== undefined) row.tags = payload.tags;
if (payload.visibilidade !== undefined) row.visibilidade = payload.visibilidade;
if (payload.compartilhado_portal !== undefined) row.compartilhado_portal = payload.compartilhado_portal;
if (payload.compartilhado_supervisor !== undefined) row.compartilhado_supervisor = payload.compartilhado_supervisor;
if (payload.status_revisao !== undefined) {
row.status_revisao = payload.status_revisao;
row.revisado_por = ownerId;
row.revisado_em = new Date().toISOString();
}
row.updated_at = new Date().toISOString();
const { data, error } = await supabase
.from('documents')
.update(row)
.eq('id', id)
.eq('owner_id', ownerId)
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Soft Delete ─────────────────────────────────────────────
/**
* Soft delete com retencao. O arquivo permanece no Storage.
* retencaoAnos: numero de anos de retencao (padrao 5 — CFP).
*/
export async function softDeleteDocument(id, retencaoAnos = 5) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const retencaoAte = new Date();
retencaoAte.setFullYear(retencaoAte.getFullYear() + retencaoAnos);
const { error } = await supabase
.from('documents')
.update({
deleted_at: new Date().toISOString(),
deleted_by: ownerId,
retencao_ate: retencaoAte.toISOString(),
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
/**
* Restaurar documento soft-deleted.
*/
export async function restoreDocument(id) {
const ownerId = await getOwnerId();
if (!id) throw new Error('ID inválido.');
const { error } = await supabase
.from('documents')
.update({
deleted_at: null,
deleted_by: null,
retencao_ate: null,
updated_at: new Date().toISOString()
})
.eq('id', id)
.eq('owner_id', ownerId);
if (error) throw error;
return true;
}
// ── Download URL ────────────────────────────────────────────
/**
* Gera URL assinada para download (valida por 60s por padrao).
*/
export async function getDownloadUrl(bucketPath, expiresIn = 60, bucket = BUCKET) {
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(bucketPath, expiresIn);
if (error) throw error;
return data?.signedUrl;
}
// ── Tags (autocomplete) ────────────────────────────────────
/**
* Retorna tags unicas ja usadas pelo owner (para autocomplete).
*/
export async function getUsedTags() {
const ownerId = await getOwnerId();
const { data, error } = await supabase
.from('documents')
.select('tags')
.eq('owner_id', ownerId)
.is('deleted_at', null);
if (error) throw error;
const set = new Set();
for (const row of data || []) {
for (const tag of row.tags || []) {
if (tag) set.add(tag);
}
}
return [...set].sort((a, b) => a.localeCompare(b, 'pt-BR'));
}
// ── V#51: verificação de integridade ──────────────────────────
/**
* Baixa o documento e recalcula o SHA-256, compara com o registrado.
* @returns {{ ok: boolean, expected: string|null, actual: string|null }}
*/
export async function verifyDocumentIntegrity(docId) {
const { data: doc, error } = await supabase
.from('documents')
.select('id, bucket_path, storage_bucket, content_sha256')
.eq('id', docId)
.single();
if (error) throw error;
if (!doc?.content_sha256) {
return { ok: false, expected: null, actual: null, reason: 'sem_hash_registrado' };
}
const { data: blob, error: dlErr } = await supabase.storage
.from(doc.storage_bucket || BUCKET)
.download(doc.bucket_path);
if (dlErr) throw dlErr;
const buf = await blob.arrayBuffer();
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const actual = Array.from(new Uint8Array(hashBuf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return {
ok: actual === doc.content_sha256,
expected: doc.content_sha256,
actual
};
}