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 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 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 timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`; 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% // Gerar e salvar miniatura (Thumbnail) — 500x500, qualidade 80%
let thumbFilename = null; let thumbFilename = null;
try { try {
const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500); const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500);
thumbFilename = `thumb_${filename}`; 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) { } catch (thumbErr) {
console.error('Erro ao gerar thumbnail:', thumbErr); console.error('Erro ao gerar thumbnail:', thumbErr);
// Continua mesmo se falhar o thumbnail, usando fallback no front // 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 clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada'; 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 patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido';
const timestamp = Date.now(); const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.png`; const filename = `${timestamp}_${data.metadata.guid}.png`;
const thumbFilename = `${timestamp}_${data.metadata.guid}_thumb.jpg`; const thumbFilename = `${timestamp}_${data.metadata.guid}_thumb.jpg`;
const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png'); // Key NOVA tenant/clinica/paciente quando temos o tenant; senão legada.
const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg'); 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 (!originalUrl || !thumbnailUrl) {
if (typeof callback === 'function') callback({ success: false, error: 'Falha ao gerar URLs no S3.' }); 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, '_'); 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) * 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) { async function getImageUrl(filename, clinicName, clientName, patientName, isProcessed = false, expirySeconds = 3600) {
if (!s3 || !currentBucket || !clientName || !patientName) { if (!s3 || !currentBucket) {
return null; return null;
} }
try { try {
const cleanClinic = sanitizePathSegment(clinicName); const m = await metaFor(filename);
const cleanClient = sanitizePathSegment(clientName); const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
const cleanPatient = sanitizePathSegment(patientName); if (!key) return null;
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
// Gerar URL assinada com expiração (padrão: 1 hora) // Gerar URL assinada com expiração (padrão: 1 hora)
const url = await getSignedUrl( const url = await getSignedUrl(
s3, 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. * 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. * 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 targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR;
const destPath = path.join(targetDir, filename); 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 // 2. Se Wasabi estiver ativo, despachar para a nuvem
if (s3 && currentBucket) { if (s3 && currentBucket) {
try { try {
const cleanClinic = sanitizePathSegment(clinicName); // Key nova (tenant/clinica/paciente) quando temos o tenant; senão legada.
const cleanClient = sanitizePathSegment(clientName); const key = (tenantId && clinicaId)
const cleanPatient = sanitizePathSegment(patientName); ? newKey(tenantId, clinicaId, patientName, filename)
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; : legacyKey(clinicName, clientName, patientName, filename);
const ext = path.extname(filename).toLowerCase(); const ext = path.extname(filename).toLowerCase();
let contentType = 'image/png'; let contentType = 'image/png';
@@ -196,65 +235,29 @@ async function getImageBuffer(filename, clinicName = null, clientName = null, pa
} }
if (s3 && currentBucket) { 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 { try {
let clName = clinicName; const response = await s3.send(new GetObjectCommand({ Bucket: currentBucket, Key: key }));
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
}));
if (response.Body) { if (response.Body) {
const bytes = await response.Body.transformToByteArray(); const bytes = await response.Body.transformToByteArray();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
// Cache local — evita round-trip ao Wasabi nas próximas requisições
try { try {
await fs.mkdir(path.dirname(localPath), { recursive: true }); await fs.mkdir(path.dirname(localPath), { recursive: true });
await fs.writeFile(localPath, buffer); await fs.writeFile(localPath, buffer); // cache local
} catch (_) {} } catch (_) {}
return buffer; return buffer;
} }
} catch (error) { } catch (error) {
if (error.name === 'NoSuchKey') { if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
// Tenta achar na pasta root do bucket se o padrão client/patient não foi usado no passado console.error(`❌ [Wasabi] Falha ao recuperar ${key}:`, error.message);
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;
} }
} catch (fallbackError) { // senão: não existe nessa key, tenta a próxima
console.error(`❌ [Wasabi] Arquivo não encontrado no S3: ${filename}`);
}
} else {
console.error(`❌ [Wasabi] Falha ao recuperar do S3 para ${filename}:`, error.message);
} }
} }
console.error(`❌ [Wasabi] Arquivo não encontrado em nenhuma key: ${filename}`);
} }
throw new Error('File not found locally or in Wasabi'); 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) { if (s3 && currentBucket) {
try { try {
let clName = clinicName; // Apaga todas as keys possíveis (nova, legada e raiz) — cobre transição.
let cName = clientName; const m = await metaFor(filename);
let pName = patientName; const keys = candidateKeys(filename, m, { clinicName, clientName, patientName });
for (const Key of keys) {
if (!cName || !pName) { await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, Key })).catch(() => {});
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;
} }
} console.log(`🗑️ [Wasabi] Objeto excluído (keys: ${keys.length}): ${filename}`);
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(() => {});
} catch (err) { } catch (err) {
handleWasabiError(err, `Falha ao excluir objeto ${filename} do S3`); 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; if (!s3 || !currentBucket) return null;
try { try {
let clName = clinicName; const m = await metaFor(filename);
let cName = clientName; const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
let pName = patientName; if (!key) return null;
const command = new GetObjectCommand({ Bucket: currentBucket, Key: key });
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
});
// URL válida por 1 hora // URL válida por 1 hora
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 }); const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return signedUrl; 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. * 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; if (!s3 || !currentBucket) return null;
try { try {
const cleanClinic = sanitizePathSegment(clinicName); const key = (tenantId && clinicaId)
const cleanClient = sanitizePathSegment(clientName); ? newKey(tenantId, clinicaId, patientName, filename)
const cleanPatient = sanitizePathSegment(patientName); : legacyKey(clinicName, clientName, patientName, filename);
const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`;
const command = new PutObjectCommand({ const command = new PutObjectCommand({
Bucket: currentBucket, Bucket: currentBucket,
@@ -397,23 +360,9 @@ async function fileExists(filename, clinicName = null, clientName = null, patien
if (!s3 || !currentBucket) return false; if (!s3 || !currentBucket) return false;
try { try {
let clName = clinicName; const m = await metaFor(filename);
let cName = clientName; const key = await pickExistingKey(candidateKeys(filename, m, { clinicName, clientName, patientName }));
let pName = patientName; return !!key;
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;
} catch (e) { } catch (e) {
if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false; if (e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404) return false;
// Erro de rede/auth — assume não existe para forçar re-upload // Erro de rede/auth — assume não existe para forçar re-upload