feat(storage): reorganiza Wasabi com estrutura clinic_name/pc_name/patient_name
continuous-integration/webhook Deploy concluído (VPS4)
continuous-integration/webhook Deploy concluído (VPS4)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.17
|
||||
2.1.29
|
||||
|
||||
@@ -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); });
|
||||
@@ -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++;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+36
-28
@@ -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) {
|
||||
|
||||
+29
-17
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user