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:
@@ -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.' });
|
||||
|
||||
+88
-139
@@ -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) {
|
||||
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);
|
||||
// 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 {
|
||||
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);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(localPath), { recursive: true });
|
||||
await fs.writeFile(localPath, buffer); // cache local
|
||||
} catch (_) {}
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
} 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;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error(`❌ [Wasabi] Arquivo não encontrado no S3: ${filename}`);
|
||||
} catch (error) {
|
||||
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
|
||||
console.error(`❌ [Wasabi] Falha ao recuperar ${key}:`, error.message);
|
||||
}
|
||||
} 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,22 +318,22 @@ 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,
|
||||
Key: key,
|
||||
ContentType: contentType
|
||||
});
|
||||
|
||||
|
||||
// URL válida por 1 hora
|
||||
const signedUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
|
||||
return signedUrl;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user