Documentos Pacientes, Template Documentos Pacientes Saas, Documentos prontuários, Documentos Externos, Visualização Externa, Permissão de Visualização, Render Otimização

This commit is contained in:
Leonardo
2026-03-30 14:08:19 -03:00
parent 0658e2e9bf
commit d088a89fb7
112 changed files with 115867 additions and 5266 deletions

View File

@@ -0,0 +1,313 @@
/*
|--------------------------------------------------------------------------
| 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}`;
}
// ── 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);
// 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,
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'));
}