feat(rx): Wasabi key tenant_id/clinica_id/paciente/arquivo + fallback legado

storage.js: escrita usa key nova (tenant/clinica/paciente) quando o device tem
tenant+clinica; leitura testa existência e cai para a key legada (clinic_name/
client_name/...) e raiz — não quebra objetos antigos antes do resync. deleteImage
apaga todas as keys. getUploadPresignedUrl/saveImage recebem tenantId/clinicaId.

Verificado em dev: presigned URL -> u_…/c_…/paciente/arquivo; leitura de imagem
existente (key legada) -> HTTP 200 via fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-12 17:38:42 +02:00
parent b6da56617a
commit 28ded7bbf9
2 changed files with 96 additions and 144 deletions
+8 -5
View File
@@ -1121,17 +1121,17 @@ io.on('connection', (socket) => {
const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto)
const patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido';
// Salvar imagem original local e na nuvem
// Salvar imagem original local e na nuvem (key nova tenant/clinica quando houver)
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`;
await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false);
await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false, tenantId, clinicaId);
// Gerar e salvar miniatura (Thumbnail) — 500x500, qualidade 80%
let thumbFilename = null;
try {
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500);
thumbFilename = `thumb_${filename}`;
await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false);
await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false, tenantId, clinicaId);
} catch (thumbErr) {
console.error('Erro ao gerar thumbnail:', thumbErr);
// Continua mesmo se falhar o thumbnail, usando fallback no front
@@ -1235,14 +1235,17 @@ io.on('connection', (socket) => {
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // clínica (scoreodonto)
const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto)
const patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido';
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`;
const thumbFilename = `${timestamp}_${data.metadata.guid}_thumb.jpg`;
const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png');
const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg');
// Key NOVA tenant/clinica/paciente quando temos o tenant; senão legada.
const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png', tenantId, clinicaId);
const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg', tenantId, clinicaId);
if (!originalUrl || !thumbnailUrl) {
if (typeof callback === 'function') callback({ success: false, error: 'Falha ao gerar URLs no S3.' });
+78 -129
View File
@@ -105,20 +105,59 @@ function sanitizePathSegment(segment) {
return segment.trim().replace(/[\/\\?#%*:"<>|]/g, '_');
}
// ── Chaves de objeto (multi-tenant) ───────────────────────────────────────────
// NOVA (a partir de 2026-06): tenant_id / clinica_id / paciente / arquivo.
// LEGADA (objetos antigos): clinic_name / client_name(pc) / paciente / arquivo.
// Durante a transição (antes do resync), objetos antigos seguem na key legada,
// então a LEITURA precisa testar a existência e cair pra legada quando preciso.
function newKey(tenantId, clinicaId, patientName, filename) {
return `${sanitizePathSegment(tenantId)}/${sanitizePathSegment(clinicaId)}/${sanitizePathSegment(patientName)}/${filename}`;
}
function legacyKey(clinicName, clientName, patientName, filename) {
return `${sanitizePathSegment(clinicName)}/${sanitizePathSegment(clientName)}/${sanitizePathSegment(patientName)}/${filename}`;
}
// Metadados da imagem pelo nome do arquivo (para reads que não recebem tudo).
async function metaFor(filename) {
try {
return await db.get(
'SELECT tenant_id, clinica_id, clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $1 LIMIT 1',
[filename]
);
} catch { return null; }
}
// Candidatas de key (nova → legada → raiz), deduplicadas.
function candidateKeys(filename, m, fb = {}) {
const tId = m?.tenant_id, cId = m?.clinica_id;
const cn = m?.clinic_name ?? fb.clinicName, cl = m?.client_name ?? fb.clientName, pn = m?.patient_name ?? fb.patientName;
const keys = [];
if (tId && cId) keys.push(newKey(tId, cId, pn, filename));
keys.push(legacyKey(cn, cl, pn, filename));
keys.push(filename); // raiz (legados muito antigos)
return [...new Set(keys)];
}
// Retorna a 1ª key que EXISTE no bucket (HEAD), ou null.
async function pickExistingKey(keys) {
for (const Key of keys) {
try {
await s3.send(new HeadObjectCommand({ Bucket: currentBucket, Key }));
return Key;
} catch (_) { /* não existe, tenta próxima */ }
}
return null;
}
/**
* Retorna a URL assinada de uma imagem no Wasabi (ou null se não estiver lá ou Wasabi desativado)
*/
async function getImageUrl(filename, clinicName, clientName, patientName, isProcessed = false, expirySeconds = 3600) {
if (!s3 || !currentBucket || !clientName || !patientName) {
if (!s3 || !currentBucket) {
return null;
}
try {
const cleanClinic = sanitizePathSegment(clinicName);
const cleanClient = sanitizePathSegment(clientName);
const cleanPatient = sanitizePathSegment(patientName);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
const m = await metaFor(filename);
const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
if (!key) return null;
// Gerar URL assinada com expiração (padrão: 1 hora)
const url = await getSignedUrl(
s3,
@@ -136,7 +175,7 @@ async function getImageUrl(filename, clinicName, clientName, patientName, isProc
* Salva a imagem. Primeiro no disco local e depois, se o Wasabi estiver ativo, faz o upload.
* Remove a versão local temporária se o upload pro Wasabi der certo.
*/
async function saveImage(filename, buffer, clinicName, clientName, patientName, isProcessed = false) {
async function saveImage(filename, buffer, clinicName, clientName, patientName, isProcessed = false, tenantId = null, clinicaId = null) {
const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR;
const destPath = path.join(targetDir, filename);
@@ -147,10 +186,10 @@ async function saveImage(filename, buffer, clinicName, clientName, patientName,
// 2. Se Wasabi estiver ativo, despachar para a nuvem
if (s3 && currentBucket) {
try {
const cleanClinic = sanitizePathSegment(clinicName);
const cleanClient = sanitizePathSegment(clientName);
const cleanPatient = sanitizePathSegment(patientName);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
// Key nova (tenant/clinica/paciente) quando temos o tenant; senão legada.
const key = (tenantId && clinicaId)
? newKey(tenantId, clinicaId, patientName, filename)
: legacyKey(clinicName, clientName, patientName, filename);
const ext = path.extname(filename).toLowerCase();
let contentType = 'image/png';
@@ -196,65 +235,29 @@ async function getImageBuffer(filename, clinicName = null, clientName = null, pa
}
if (s3 && currentBucket) {
// Tenta as keys candidatas (nova → legada → raiz) e baixa a 1ª que existir.
const m = await metaFor(filename);
const keys = candidateKeys(filename, m, { clinicName, clientName, patientName });
for (const key of keys) {
try {
let clName = clinicName;
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
try {
const row = await db.get('SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]);
if (row) {
clName = row.clinic_name;
cName = row.client_name;
pName = row.patient_name;
}
} catch (dbErr) {
console.error('⚠️ [Storage] Erro ao buscar metadados do banco:', dbErr.message);
}
}
const cleanClinic = sanitizePathSegment(clName);
const cleanClient = sanitizePathSegment(cName);
const cleanPatient = sanitizePathSegment(pName);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
const response = await s3.send(new GetObjectCommand({
Bucket: currentBucket,
Key: key
}));
const response = await s3.send(new GetObjectCommand({ Bucket: currentBucket, Key: key }));
if (response.Body) {
const bytes = await response.Body.transformToByteArray();
const buffer = Buffer.from(bytes);
// Cache local — evita round-trip ao Wasabi nas próximas requisições
try {
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, buffer);
await fs.writeFile(localPath, buffer); // cache local
} catch (_) {}
return buffer;
}
} catch (error) {
if (error.name === 'NoSuchKey') {
// Tenta achar na pasta root do bucket se o padrão client/patient não foi usado no passado
try {
const response = await s3.send(new GetObjectCommand({ Bucket: currentBucket, Key: filename }));
if (response.Body) {
const bytes = await response.Body.transformToByteArray();
const buffer = Buffer.from(bytes);
try {
await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, buffer);
} catch (_) {}
return buffer;
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
console.error(`❌ [Wasabi] Falha ao recuperar ${key}:`, error.message);
}
} catch (fallbackError) {
console.error(`❌ [Wasabi] Arquivo não encontrado no S3: ${filename}`);
}
} else {
console.error(`❌ [Wasabi] Falha ao recuperar do S3 para ${filename}:`, error.message);
// senão: não existe nessa key, tenta a próxima
}
}
console.error(`❌ [Wasabi] Arquivo não encontrado em nenhuma key: ${filename}`);
}
throw new Error('File not found locally or in Wasabi');
@@ -279,32 +282,13 @@ async function deleteImage(filename, clinicName = null, clientName = null, patie
if (s3 && currentBucket) {
try {
let clName = clinicName;
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
const row = await db.get('SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 LIMIT 1', [filename]);
if (row) {
clName = row.clinic_name;
cName = row.client_name;
pName = row.patient_name;
// Apaga todas as keys possíveis (nova, legada e raiz) — cobre transição.
const m = await metaFor(filename);
const keys = candidateKeys(filename, m, { clinicName, clientName, patientName });
for (const Key of keys) {
await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key })).catch(() => {});
}
}
const cleanClinic = sanitizePathSegment(clName);
const cleanClient = sanitizePathSegment(cName);
const cleanPatient = sanitizePathSegment(pName);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
await s3.send(new DeleteObjectCommand({
Bucket: currentBucket,
Key: key
}));
console.log(`🗑️ [Wasabi] Objeto excluído: ${key}`);
// Tentar deletar da root tbm por redundância de arquivos legados
await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key: filename })).catch(() => {});
console.log(`🗑️ [Wasabi] Objeto excluído (keys: ${keys.length}): ${filename}`);
} catch (err) {
handleWasabiError(err, `Falha ao excluir objeto ${filename} do S3`);
}
@@ -319,31 +303,10 @@ async function getDownloadPresignedUrl(filename, clinicName = null, clientName =
if (!s3 || !currentBucket) return null;
try {
let clName = clinicName;
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
const row = await db.get('SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]);
if (row) {
clName = row.clinic_name;
cName = row.client_name;
pName = row.patient_name;
} else {
return null;
}
}
const cleanClinic = sanitizePathSegment(clName);
const cleanClient = sanitizePathSegment(cName);
const cleanPatient = sanitizePathSegment(pName);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
const command = new GetObjectCommand({
Bucket: currentBucket,
Key: key
});
const m = await metaFor(filename);
const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
if (!key) return null;
const command = new GetObjectCommand({ Bucket: currentBucket, Key: key });
// URL válida por 1 hora
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return signedUrl;
@@ -355,15 +318,15 @@ async function getDownloadPresignedUrl(filename, clinicName = null, clientName =
/**
* Gera uma URL pré-assinada para upload da imagem diretamente para o Wasabi.
* Usa a key NOVA (tenant/clinica/paciente) quando tenant+clinica vierem; senão legada.
*/
async function getUploadPresignedUrl(filename, clinicName, clientName, patientName, contentType = 'image/png') {
async function getUploadPresignedUrl(filename, clinicName, clientName, patientName, contentType = 'image/png', tenantId = null, clinicaId = null) {
if (!s3 || !currentBucket) return null;
try {
const cleanClinic = sanitizePathSegment(clinicName);
const cleanClient = sanitizePathSegment(clientName);
const cleanPatient = sanitizePathSegment(patientName);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
const key = (tenantId && clinicaId)
? newKey(tenantId, clinicaId, patientName, filename)
: legacyKey(clinicName, clientName, patientName, filename);
const command = new PutObjectCommand({
Bucket: currentBucket,
@@ -397,23 +360,9 @@ async function fileExists(filename, clinicName = null, clientName = null, patien
if (!s3 || !currentBucket) return false;
try {
let clName = clinicName;
let cName = clientName;
let pName = patientName;
if (!cName || !pName) {
try {
const row = await db.get(
'SELECT clinic_name, client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1',
[filename, filename]
);
if (row) { clName = row.clinic_name; cName = row.client_name; pName = row.patient_name; }
} catch (_) {}
}
const key = `${sanitizePathSegment(clName)}/${sanitizePathSegment(cName)}/${sanitizePathSegment(pName)}/${filename}`;
await s3.send(new HeadObjectCommand({ Bucket: currentBucket, Key: key }));
return true;
const m = await metaFor(filename);
const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
return !!key;
} catch (e) {
if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false;
// Erro de rede/auth — assume não existe para forçar re-upload