From 21f33c88a4be0beb796adaf9e1e8415daf615c89 Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Sun, 31 May 2026 01:56:18 +0200 Subject: [PATCH] feat(storage): reorganiza Wasabi com estrutura clinic_name/pc_name/patient_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adiciona coluna clinic_name em images (ALTER TABLE IF NOT EXISTS) - Todas as funções de storage recebem clinicName como parâmetro: saveImage, getImageBuffer, getImageUrl, deleteImage, getDownloadPresignedUrl, getUploadPresignedUrl - Chave Wasabi passa de pc_name/patient/arquivo → clinic_name/pc_name/patient/arquivo - server.js: usa socket.user.clinicName (preenchido via machine_token auth do DB) como fonte primária do nome da clínica nos 3 handlers de upload e na rota thumbnail - Todos os INSERTs em images passam a gravar clinic_name - Corrige bug oculto: wipe de clínica agora apaga o prefixo correto no Wasabi - Scripts utilitários atualizados: backfill-wasabi-thumbs, recreate-all-thumbs, migrate-to-wasabi - client-monitor.js: envia clinicName no client-identify (fallback para clientes legados) Co-Authored-By: Claude Sonnet 4.6 --- dental-server/backfill-wasabi-thumbs.js | 8 +- dental-server/database.js | 3 +- .../dental-client/public/version.txt | 2 +- dental-server/migrate-to-wasabi.js | 97 +++++++++++++++++++ dental-server/recreate-all-thumbs.js | 8 +- dental-server/routes/images.js | 56 +++++------ dental-server/server.js | 64 ++++++------ dental-server/storage.js | 46 +++++---- 8 files changed, 202 insertions(+), 82 deletions(-) create mode 100644 dental-server/migrate-to-wasabi.js diff --git a/dental-server/backfill-wasabi-thumbs.js b/dental-server/backfill-wasabi-thumbs.js index faf0c66..6a6aa83 100644 --- a/dental-server/backfill-wasabi-thumbs.js +++ b/dental-server/backfill-wasabi-thumbs.js @@ -19,8 +19,8 @@ async function run() { } const rows = await db.all(` - SELECT id, filename, client_name, patient_name - FROM images + SELECT id, filename, clinic_name, client_name, patient_name + FROM images WHERE thumb_filename IS NULL AND enabled = 1 `); @@ -34,7 +34,7 @@ async function run() { try { // 1. Obter URL de download do Wasabi - const url = await storage.getDownloadPresignedUrl(row.filename, row.client_name, row.patient_name); + const url = await storage.getDownloadPresignedUrl(row.filename, row.clinic_name, row.client_name, row.patient_name); if (!url) { console.error(`❌ Não foi possível gerar URL de download para ${row.filename}`); failCount++; @@ -57,7 +57,7 @@ async function run() { // 4. Salvar miniatura no Wasabi (usando a mesma lógica de rotas/VPS) const thumbFilename = `thumb_${row.filename}`; - await storage.saveImage(thumbFilename, thumbBuffer, row.client_name, row.patient_name, false); + await storage.saveImage(thumbFilename, thumbBuffer, row.clinic_name, row.client_name, row.patient_name, false); // 5. Atualizar o banco de dados await db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, row.id]); diff --git a/dental-server/database.js b/dental-server/database.js index c523b6a..51587d3 100644 --- a/dental-server/database.js +++ b/dental-server/database.js @@ -93,7 +93,8 @@ async function createTables() { // Garantir que a coluna exista caso a tabela já tenha sido criada antes await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS thumb_filename TEXT;'); - + await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS clinic_name VARCHAR(100);'); + console.log('✅ Tabela images criada/verificada no PostgreSQL'); // Tabela GTOs diff --git a/dental-server/dental-client/public/version.txt b/dental-server/dental-client/public/version.txt index 2f1a5aa..8a1af3b 100644 --- a/dental-server/dental-client/public/version.txt +++ b/dental-server/dental-client/public/version.txt @@ -1 +1 @@ -2.1.17 +2.1.29 diff --git a/dental-server/migrate-to-wasabi.js b/dental-server/migrate-to-wasabi.js new file mode 100644 index 0000000..f09e8d5 --- /dev/null +++ b/dental-server/migrate-to-wasabi.js @@ -0,0 +1,97 @@ +// ──────────────────────────────────────────────────────────────────────────── +// migrate-to-wasabi.js — Migração one-off: envia arquivos locais (originais + +// thumbs) para o Wasabi e verifica via HEAD. NÃO apaga o local (segurança). +// Uso: docker exec rx_scoreodonto_app node /app/migrate-to-wasabi.js +// ──────────────────────────────────────────────────────────────────────────── +const db = require('./database'); +const storage = require('./storage'); +const fs = require('fs'); +const path = require('path'); +const { S3Client, HeadObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3'); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; +const PROCESSED_DIR = process.env.PROCESSED_DIR || '/app/processed'; + +function sanitize(seg) { + if (!seg) return 'desconhecido'; + return seg.trim().replace(/[\/\\?#%*:"<>|]/g, '_'); +} +function contentTypeFor(filename) { + const ext = path.extname(filename).toLowerCase(); + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.webp') return 'image/webp'; + return 'image/png'; +} +function findLocal(filename) { + for (const dir of [UPLOAD_DIR, PROCESSED_DIR]) { + const p = path.join(dir, filename); + if (fs.existsSync(p)) return p; + } + return null; +} + +(async () => { + await db.initDatabase(); + await storage.loadConfigFromDb(); + const cfg = JSON.parse((await db.get("SELECT value FROM system_config WHERE key='wasabi_config'")).value); + const ep = cfg.wasabiEndpoint.startsWith('http') ? cfg.wasabiEndpoint : 'https://' + cfg.wasabiEndpoint; + const s3 = new S3Client({ + region: cfg.wasabiRegion, endpoint: ep, + credentials: { accessKeyId: cfg.wasabiAccessKey, secretAccessKey: cfg.wasabiSecretKey }, + forcePathStyle: true, + }); + const bucket = cfg.wasabiBucket; + + const rows = await db.all( + 'SELECT id, filename, thumb_filename, clinic_name, client_name, patient_name FROM images ORDER BY id' + ); + console.log(`\n🔎 ${rows.length} imagens no banco. Migrando para bucket "${bucket}"...\n`); + + let uploaded = 0, already = 0, missingLocal = 0, failed = 0; + + async function ensure(filename, clinic, client, patient) { + if (!filename) return; + const key = `${sanitize(clinic)}/${sanitize(client)}/${sanitize(patient)}/${filename}`; + // Já existe no Wasabi? + try { await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })); already++; return; } + catch (e) { if (e.name !== 'NotFound' && e.$metadata?.httpStatusCode !== 404) { /* outro erro: tenta upload mesmo assim */ } } + // Ler local + const local = findLocal(filename); + if (!local) { missingLocal++; return; } + try { + const body = await fs.promises.readFile(local); + await s3.send(new PutObjectCommand({ + Bucket: bucket, Key: key, + Body: body, ContentType: contentTypeFor(filename), + })); + uploaded++; + if (uploaded % 50 === 0) console.log(` … ${uploaded} enviados (${already} já existiam)`); + } catch (e) { failed++; if (failed <= 10) console.log(` ❌ falha ${key}: ${e.name} ${e.message}`); } + } + + // Monta lista de tarefas (original + thumb por imagem) e processa com concorrência + const tasks = []; + for (const r of rows) { + tasks.push([r.filename, r.clinic_name, r.client_name, r.patient_name]); + if (r.thumb_filename) tasks.push([r.thumb_filename, r.clinic_name, r.client_name, r.patient_name]); + } + const CONCURRENCY = Number(process.env.MIG_CONCURRENCY) || 40; + console.log(`📦 ${tasks.length} arquivos a verificar (concorrência ${CONCURRENCY})\n`); + + let idx = 0; + async function worker() { + while (idx < tasks.length) { + const i = idx++; + const [f, cl, c, p] = tasks[i]; + await ensure(f, cl, c, p); + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, worker)); + + console.log(`\n✅ Migração concluída:`); + console.log(` enviados agora: ${uploaded}`); + console.log(` já no Wasabi: ${already}`); + console.log(` sem local: ${missingLocal}`); + console.log(` falhas: ${failed}`); + process.exit(0); +})().catch(e => { console.error('ERRO FATAL:', e); process.exit(1); }); diff --git a/dental-server/recreate-all-thumbs.js b/dental-server/recreate-all-thumbs.js index 98e14ff..5d81d4f 100644 --- a/dental-server/recreate-all-thumbs.js +++ b/dental-server/recreate-all-thumbs.js @@ -15,8 +15,8 @@ async function run() { // Buscar todas as imagens que possuem miniatura const rows = await db.all(` - SELECT id, filename, thumb_filename, client_name, patient_name - FROM images + SELECT id, filename, thumb_filename, clinic_name, client_name, patient_name + FROM images WHERE thumb_filename IS NOT NULL AND enabled = 1 `); @@ -30,7 +30,7 @@ async function run() { try { // 1. Obter URL de download do Wasabi (baixar a ORIGINAL, não a miniatura pesada) - const url = await storage.getDownloadPresignedUrl(row.filename, row.client_name, row.patient_name); + const url = await storage.getDownloadPresignedUrl(row.filename, row.clinic_name, row.client_name, row.patient_name); if (!url) { console.error(`❌ Não foi possível gerar URL para a imagem original ${row.filename}`); failCount++; @@ -52,7 +52,7 @@ async function run() { const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 400, 400); // 4. Salvar miniatura substituindo a antiga - await storage.saveImage(row.thumb_filename, thumbBuffer, row.client_name, row.patient_name, false); + await storage.saveImage(row.thumb_filename, thumbBuffer, row.clinic_name, row.client_name, row.patient_name, false); console.log(`✅ Miniatura ultra-leve gerada e salva com sucesso para ID ${row.id} (${thumbBuffer.length} bytes)`); successCount++; diff --git a/dental-server/routes/images.js b/dental-server/routes/images.js index 86a6864..0ba068b 100644 --- a/dental-server/routes/images.js +++ b/dental-server/routes/images.js @@ -34,31 +34,32 @@ router.post('/upload', async (req, res) => { await imageProcessor.validateImage(imageBuffer); + const clinicName = 'Upload Manual'; const clientName = 'Upload Manual'; const patientName = patientData.name; const timestamp = Date.now(); const guid = Math.random().toString(36).substring(2, 15); const filename = `${timestamp}_${guid}.png`; - - await storage.saveImage(filename, imageBuffer, clientName, patientName, false); - + + await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false); + let thumbFilename = null; try { const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500); thumbFilename = `thumb_${filename}`; - await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientName, false); + await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false); } catch (e) { console.error('Erro ao gerar thumbnail manual:', e); } - + const doctor = patientData.doctor || ''; const remark = patientData.remark || ''; - + const result = await db.run( `INSERT INTO images ( - image_guid, patient_name, client_name, original_filename, filename, thumb_filename, enabled, doctor, remark - ) VALUES ($1, $2, $3, $4, $5, $6, 1, $7, $8)`, - [guid, patientName, clientName, 'manual_upload.png', filename, thumbFilename, doctor, remark] + image_guid, patient_name, clinic_name, client_name, original_filename, filename, thumb_filename, enabled, doctor, remark + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 1, $8, $9)`, + [guid, patientName, clinicName, clientName, 'manual_upload.png', filename, thumbFilename, doctor, remark] ); // Notificar clientes socket para atualizar galeria automaticamente @@ -217,9 +218,9 @@ router.delete('/by-patient', async (req, res) => { const images = await db.all('SELECT * FROM images WHERE patient_name = $1', [patientName]); for (const image of images) { - await storage.deleteImage(image.filename, image.client_name, image.patient_name); + await storage.deleteImage(image.filename, image.clinic_name, image.client_name, image.patient_name); } - + await db.run('DELETE FROM images WHERE patient_name = $1', [patientName]); res.json({ success: true, count: images.length }); } catch (error) { @@ -280,11 +281,11 @@ router.get('/:id/transformations', async (req, res) => { let originalBuffer; try { - originalBuffer = await storage.getImageBuffer(originalImage.filename, originalImage.client_name, originalImage.patient_name, false); + originalBuffer = await storage.getImageBuffer(originalImage.filename, originalImage.clinic_name, originalImage.client_name, originalImage.patient_name, false); } catch (e) { - originalBuffer = await storage.getImageBuffer(originalImage.filename, originalImage.client_name, originalImage.patient_name, true); + originalBuffer = await storage.getImageBuffer(originalImage.filename, originalImage.clinic_name, originalImage.client_name, originalImage.patient_name, true); } - + const transformations = [ { name: 'Original', rotation: 0, flipH: false, flipV: false }, { name: 'Flip Horizontal', rotation: 0, flipH: true, flipV: false }, @@ -336,18 +337,18 @@ router.post('/:id/transform', async (req, res) => { // Processar a imagem let originalBuffer; try { - originalBuffer = await storage.getImageBuffer(realOriginal.filename, realOriginal.client_name, realOriginal.patient_name, false); + originalBuffer = await storage.getImageBuffer(realOriginal.filename, realOriginal.clinic_name, realOriginal.client_name, realOriginal.patient_name, false); } catch (e) { - originalBuffer = await storage.getImageBuffer(realOriginal.filename, realOriginal.client_name, realOriginal.patient_name, true); + originalBuffer = await storage.getImageBuffer(realOriginal.filename, realOriginal.clinic_name, realOriginal.client_name, realOriginal.patient_name, true); } - + let processed = await imageProcessor.rotate(originalBuffer, rotation); processed = await imageProcessor.flip(processed, flipH, flipV); - + // Salvar nova versão em processed (local e nuvem) const timestamp = Date.now(); const processedFilename = `${realOriginal.image_guid}_${timestamp}.png`; - await storage.saveImage(processedFilename, processed, realOriginal.client_name, realOriginal.patient_name, true); + await storage.saveImage(processedFilename, processed, realOriginal.clinic_name, realOriginal.client_name, realOriginal.patient_name, true); // Desabilitar a imagem active atual para que não fique duplicada na interface await db.run('UPDATE images SET enabled = 0 WHERE id = $1', [req.params.id]); @@ -357,12 +358,13 @@ router.post('/:id/transform', async (req, res) => { // Inserir a nova imagem como a versão ativa const result = await db.run( `INSERT INTO images ( - image_guid, patient_name, client_name, original_filename, filename, + image_guid, patient_name, clinic_name, client_name, original_filename, filename, enabled, rotation, flip_horizontal, flip_vertical, original_image_id, created_at, remark, doctor - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, [ `${realOriginal.image_guid}_${timestamp}`, realOriginal.patient_name, + realOriginal.clinic_name, realOriginal.client_name, realOriginal.filename, processedFilename, @@ -457,8 +459,8 @@ router.delete('/:id', async (req, res) => { } // Apagar arquivo local e do S3 - await storage.deleteImage(image.filename, image.client_name, image.patient_name); - + await storage.deleteImage(image.filename, image.clinic_name, image.client_name, image.patient_name); + // Apagar do banco await db.run('DELETE FROM images WHERE id = $1', [req.params.id]); @@ -475,7 +477,7 @@ router.delete('/:id', async (req, res) => { // ================================================================ router.get('/:id/file-url', async (req, res) => { try { - const image = await db.get('SELECT filename, client_name, patient_name FROM images WHERE id = $1', [req.params.id]); + const image = await db.get('SELECT filename, clinic_name, client_name, patient_name FROM images WHERE id = $1', [req.params.id]); if (!image) return res.status(404).json({ error: 'Imagem não encontrada' }); let url; @@ -484,7 +486,7 @@ router.get('/:id/file-url', async (req, res) => { } else { // Se o arquivo está no Wasabi, reconstroem a URL try { - const wasabiUrl = await storage.getImageUrl(image.filename, image.client_name, image.patient_name); + const wasabiUrl = await storage.getImageUrl(image.filename, image.clinic_name, image.client_name, image.patient_name); url = wasabiUrl || `/uploads/${image.filename}`; } catch (e) { console.error('Erro ao gerar URL Wasabi:', e); @@ -524,7 +526,7 @@ router.put('/:id/toggle-enabled', async (req, res) => { router.post('/backfill-thumbs', async (req, res) => { try { const rows = await db.all( - `SELECT id, filename, client_name, patient_name + `SELECT id, filename, clinic_name, client_name, patient_name FROM images WHERE thumb_filename IS NULL AND enabled = true ORDER BY created_at DESC` @@ -551,7 +553,7 @@ async function runThumbBackfill(rows) { if (!imgBuffer) { failed++; continue; } const thumbBuffer = await imageProcessor.getThumbnail(imgBuffer); const thumbFilename = `thumb_${row.filename.replace(/\.[^.]+$/, '')}.jpg`; - await storage.saveImage(thumbFilename, thumbBuffer, row.client_name, row.patient_name, false); + await storage.saveImage(thumbFilename, thumbBuffer, row.clinic_name, row.client_name, row.patient_name, false); await db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, row.id]); done++; } catch (e) { diff --git a/dental-server/server.js b/dental-server/server.js index d306e7e..5e24b60 100644 --- a/dental-server/server.js +++ b/dental-server/server.js @@ -531,7 +531,7 @@ const publicImageRoutes = express.Router(); publicImageRoutes.get('/:id/thumbnail', async (req, res) => { try { const image = await db.get( - 'SELECT id, thumb_filename, filename, client_name, patient_name FROM images WHERE id = $1', + 'SELECT id, thumb_filename, filename, clinic_name, client_name, patient_name FROM images WHERE id = $1', [req.params.id] ); if (!image) return res.status(404).json({ error: 'Imagem não encontrada' }); @@ -543,7 +543,7 @@ publicImageRoutes.get('/:id/thumbnail', async (req, res) => { // 1. Já existe thumbnail — serve direto (local ou Wasabi) if (image.thumb_filename) { try { - const buf = await storage.getImageBuffer(image.thumb_filename, image.client_name, image.patient_name); + const buf = await storage.getImageBuffer(image.thumb_filename, image.clinic_name, image.client_name, image.patient_name); if (buf) { res.setHeader('Content-Type', 'image/jpeg'); return res.end(buf); @@ -555,7 +555,7 @@ publicImageRoutes.get('/:id/thumbnail', async (req, res) => { } // 2. Sem thumbnail — puxa a original, gera a thumb na hora - const originalBuf = await storage.getImageBuffer(image.filename, image.client_name, image.patient_name); + const originalBuf = await storage.getImageBuffer(image.filename, image.clinic_name, image.client_name, image.patient_name); if (!originalBuf) { return res.status(404).json({ error: 'Imagem original não encontrada' }); } @@ -568,7 +568,7 @@ publicImageRoutes.get('/:id/thumbnail', async (req, res) => { // Backfill em background: salva a thumb no Wasabi e atualiza o banco const thumbFilename = `thumb_${image.filename.replace(/\.[^.]+$/, '')}.jpg`; - storage.saveImage(thumbFilename, thumbBuf, image.client_name, image.patient_name, false) + storage.saveImage(thumbFilename, thumbBuf, image.clinic_name, image.client_name, image.patient_name, false) .then(() => db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, image.id])) .then(() => console.log(`✅ [thumb-backfill] Thumb gerada/salva: ${thumbFilename} (id ${image.id})`)) .catch(err => console.error(`⚠️ [thumb-backfill] Falha id ${image.id}:`, err.message)); @@ -830,6 +830,7 @@ io.on('connection', (socket) => { socketId: socket.id, type: data.type || 'unknown', name: clientName, + clinicName: socket.user?.clinicName || (data.clinicName || '').trim() || 'Clínica não identificada', version: data.version || '1.0.0', system: data.system || 'unknown', clientId: clientId, @@ -906,20 +907,21 @@ io.on('connection', (socket) => { // Buscar nome do cliente se identificado const clientInfo = connectedClients.find(c => c.socketId === socket.id); - const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado'; + const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; + const clinicName = socket.user?.clinicName || (clientInfo ? clientInfo.clinicName : 'Clínica não identificada'); const patientName = data.patientData ? `${data.patientData.firstName || ''} ${data.patientData.lastName || ''}`.trim() || 'Desconhecido' : 'Desconhecido'; - + // Salvar imagem original local e na nuvem const timestamp = Date.now(); const filename = `${timestamp}_${data.metadata.guid}.png`; - await storage.saveImage(filename, imageBuffer, clientName, patientName, false); - + await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false); + // 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, clientName, patientName, false); + await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false); } catch (thumbErr) { console.error('Erro ao gerar thumbnail:', thumbErr); // Continua mesmo se falhar o thumbnail, usando fallback no front @@ -936,20 +938,22 @@ io.on('connection', (socket) => { // Salvar no banco de dados const result = await db.run( `INSERT INTO images ( - image_guid, + image_guid, patient_name, + clinic_name, client_name, - original_filename, - filename, + original_filename, + filename, enabled, doctor, remark, thumb_filename, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, [ data.metadata.guid, data.patientData.name || 'Paciente não identificado', + clinicName, clientName, data.metadata.fileName, filename, @@ -960,7 +964,7 @@ io.on('connection', (socket) => { createdAt ] ); - + console.log('✅ Imagem salva no banco. ID:', result.lastID); console.log('✅ Arquivo submetido ao storage (Wasabi/Local):', filename); @@ -1015,15 +1019,16 @@ io.on('connection', (socket) => { } const clientInfo = connectedClients.find(c => c.socketId === socket.id); - const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado'; + const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; + const clinicName = socket.user?.clinicName || (clientInfo ? clientInfo.clinicName : 'Clínica não identificada'); const patientName = data.patientData ? `${data.patientData.firstName || ''} ${data.patientData.lastName || ''}`.trim() || 'Desconhecido' : '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, clientName, patientName, 'image/png'); - const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clientName, patientName, 'image/jpeg'); + + const originalUrl = await storage.getUploadPresignedUrl(filename, clinicName, clientName, patientName, 'image/png'); + const thumbnailUrl = await storage.getUploadPresignedUrl(thumbFilename, clinicName, clientName, patientName, 'image/jpeg'); if (!originalUrl || !thumbnailUrl) { if (typeof callback === 'function') callback({ success: false, error: 'Falha ao gerar URLs no S3.' }); @@ -1051,33 +1056,36 @@ io.on('connection', (socket) => { console.log('📥 [image-upload-direct] Notificação de upload recebida de:', socket.id); const clientInfo = connectedClients.find(c => c.socketId === socket.id); - const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado'; - + const clientName = clientInfo ? clientInfo.name : 'PC não identificado'; + const clinicName = socket.user?.clinicName || (clientInfo ? clientInfo.clinicName : 'Clínica não identificada'); + // Determinar data de criação const formatLocalDateTime = (date) => { const pad = (num) => String(num).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }; - + const createdAt = (data.patientData && data.patientData.photographTime) || formatLocalDateTime(new Date()); - + // Salvar no banco de dados com a referência para a thumbnail const result = await db.run( `INSERT INTO images ( - image_guid, + image_guid, patient_name, + clinic_name, client_name, - original_filename, - filename, + original_filename, + filename, enabled, doctor, remark, thumb_filename, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, [ data.metadata.guid, data.patientData.name || 'Paciente não identificado', + clinicName, clientName, data.metadata.fileName, data.filename, @@ -1253,7 +1261,7 @@ async function start() { try { const { runThumbBackfill } = require('./routes/images'); const missing = await db.all( - `SELECT id, filename, client_name, patient_name FROM images + `SELECT id, filename, clinic_name, client_name, patient_name FROM images WHERE thumb_filename IS NULL AND enabled = true ORDER BY created_at DESC` ); if (missing.length > 0) { diff --git a/dental-server/storage.js b/dental-server/storage.js index f40af73..29a39da 100644 --- a/dental-server/storage.js +++ b/dental-server/storage.js @@ -108,15 +108,16 @@ function sanitizePathSegment(segment) { /** * Retorna a URL assinada de uma imagem no Wasabi (ou null se não estiver lá ou Wasabi desativado) */ -async function getImageUrl(filename, clientName, patientName, isProcessed = false, expirySeconds = 3600) { +async function getImageUrl(filename, clinicName, clientName, patientName, isProcessed = false, expirySeconds = 3600) { if (!s3 || !currentBucket || !clientName || !patientName) { return null; } try { + const cleanClinic = sanitizePathSegment(clinicName); const cleanClient = sanitizePathSegment(clientName); const cleanPatient = sanitizePathSegment(patientName); - const key = `${cleanClient}/${cleanPatient}/${filename}`; + const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; // Gerar URL assinada com expiração (padrão: 1 hora) const url = await getSignedUrl( @@ -135,20 +136,21 @@ async function getImageUrl(filename, clientName, patientName, isProcessed = fals * 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, clientName, patientName, isProcessed = false) { +async function saveImage(filename, buffer, clinicName, clientName, patientName, isProcessed = false) { const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR; const destPath = path.join(targetDir, filename); // 1. Salvar localmente await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.writeFile(destPath, buffer); - + // 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 = `${cleanClient}/${cleanPatient}/${filename}`; + const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; const ext = path.extname(filename).toLowerCase(); let contentType = 'image/png'; @@ -185,7 +187,7 @@ async function saveImage(filename, buffer, clientName, patientName, isProcessed /** * Tenta ler o arquivo local. Se não existir, tenta baixar do Wasabi. */ -async function getImageBuffer(filename, clientName = null, patientName = null, isProcessed = false) { +async function getImageBuffer(filename, clinicName = null, clientName = null, patientName = null, isProcessed = false) { const targetDir = isProcessed ? PROCESSED_DIR : UPLOAD_DIR; const localPath = path.join(targetDir, filename); @@ -195,13 +197,15 @@ async function getImageBuffer(filename, clientName = null, patientName = null, i if (s3 && currentBucket) { try { + let clName = clinicName; let cName = clientName; let pName = patientName; if (!cName || !pName) { try { - const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]); + 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; } @@ -210,9 +214,10 @@ async function getImageBuffer(filename, clientName = null, patientName = null, i } } + const cleanClinic = sanitizePathSegment(clName); const cleanClient = sanitizePathSegment(cName); const cleanPatient = sanitizePathSegment(pName); - const key = `${cleanClient}/${cleanPatient}/${filename}`; + const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; const response = await s3.send(new GetObjectCommand({ Bucket: currentBucket, @@ -247,12 +252,12 @@ async function getImageBuffer(filename, clientName = null, patientName = null, i /** * Exclui a imagem do disco local e do S3. */ -async function deleteImage(filename, clientName = null, patientName = null) { +async function deleteImage(filename, clinicName = null, clientName = null, patientName = null) { const localPaths = [ path.join(UPLOAD_DIR, filename), path.join(PROCESSED_DIR, filename) ]; - + for (const localPath of localPaths) { try { if (fsSync.existsSync(localPath)) { @@ -263,20 +268,23 @@ async function deleteImage(filename, clientName = null, patientName = null) { if (s3 && currentBucket) { try { + let clName = clinicName; let cName = clientName; let pName = patientName; if (!cName || !pName) { - const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = $1 LIMIT 1', [filename]); + 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; } } + const cleanClinic = sanitizePathSegment(clName); const cleanClient = sanitizePathSegment(cName); const cleanPatient = sanitizePathSegment(pName); - const key = `${cleanClient}/${cleanPatient}/${filename}`; + const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; await s3.send(new DeleteObjectCommand({ Bucket: currentBucket, @@ -296,16 +304,18 @@ async function deleteImage(filename, clientName = null, patientName = null) { * Gera uma URL pré-assinada para download da imagem diretamente do Wasabi. * Retorna null se Wasabi não estiver configurado ou se não achar as chaves. */ -async function getDownloadPresignedUrl(filename, clientName = null, patientName = null) { +async function getDownloadPresignedUrl(filename, clinicName = null, clientName = null, patientName = null) { if (!s3 || !currentBucket) return null; try { + let clName = clinicName; let cName = clientName; let pName = patientName; if (!cName || !pName) { - const row = await db.get('SELECT client_name, patient_name FROM images WHERE filename = $1 OR thumb_filename = $2 LIMIT 1', [filename, filename]); + 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 { @@ -313,9 +323,10 @@ async function getDownloadPresignedUrl(filename, clientName = null, patientName } } + const cleanClinic = sanitizePathSegment(clName); const cleanClient = sanitizePathSegment(cName); const cleanPatient = sanitizePathSegment(pName); - const key = `${cleanClient}/${cleanPatient}/${filename}`; + const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; const command = new GetObjectCommand({ Bucket: currentBucket, @@ -334,13 +345,14 @@ async function getDownloadPresignedUrl(filename, clientName = null, patientName /** * Gera uma URL pré-assinada para upload da imagem diretamente para o Wasabi. */ -async function getUploadPresignedUrl(filename, clientName, patientName, contentType = 'image/png') { +async function getUploadPresignedUrl(filename, clinicName, clientName, patientName, contentType = 'image/png') { if (!s3 || !currentBucket) return null; try { + const cleanClinic = sanitizePathSegment(clinicName); const cleanClient = sanitizePathSegment(clientName); const cleanPatient = sanitizePathSegment(patientName); - const key = `${cleanClient}/${cleanPatient}/${filename}`; + const key = `${cleanClinic}/${cleanClient}/${cleanPatient}/${filename}`; const command = new PutObjectCommand({ Bucket: currentBucket,