314 lines
10 KiB
JavaScript
314 lines
10 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}`;
|
|
}
|
|
|
|
// ── 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'));
|
|
}
|