diff --git a/dental-server/server.js b/dental-server/server.js index 26ceb32..639dd76 100644 --- a/dental-server/server.js +++ b/dental-server/server.js @@ -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.' }); diff --git a/dental-server/storage.js b/dental-server/storage.js index 52004b6..d1f47cb 100644 --- a/dental-server/storage.js +++ b/dental-server/storage.js @@ -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