feat: adicionar thumbnails, ajuste fino flutuante e correções no dental-client

This commit is contained in:
VPS 4 Builder
2026-05-24 23:47:54 +02:00
parent c38184653a
commit 5d47a8ebb5
29 changed files with 9579 additions and 101 deletions
+154 -36
View File
@@ -4,6 +4,8 @@ const db = require('../database');
const imageProcessor = require('../image-processor');
const path = require('path');
const fs = require('fs').promises;
const storage = require('../storage');
const sharp = require('sharp');
// ================================================================
// GET /api/images - Listar imagens
@@ -64,12 +66,12 @@ router.get('/patients', async (req, res) => {
const rows = await db.all(
`SELECT
patient_name,
doctor,
remark,
client_name,
MAX(doctor) AS doctor,
MAX(remark) AS remark,
MAX(client_name) AS client_name,
COUNT(*) AS image_count,
MAX(created_at) AS last_date,
(SELECT filename FROM images i2
(SELECT COALESCE(thumb_filename, filename) FROM images i2
WHERE i2.patient_name = images.patient_name AND i2.enabled = ${enabledVal}
ORDER BY i2.created_at DESC LIMIT 1) AS thumb_filename
FROM images
@@ -114,6 +116,107 @@ router.get('/by-patient', async (req, res) => {
}
});
// ================================================================
// POST /api/images/upload - Fazer upload manual de imagem
// ================================================================
router.post('/upload', async (req, res) => {
try {
const { imageBase64, filename, patientName, doctor, remark } = req.body;
if (!imageBase64) {
return res.status(400).json({ error: 'imageBase64 é obrigatório' });
}
// Decodificar imagem base64
let base64Data = imageBase64;
if (base64Data.includes(';base64,')) {
base64Data = base64Data.split(';base64,')[1];
}
const imageBuffer = Buffer.from(base64Data, 'base64');
// TODO(security): Adicionado limite de 10MB para prevenir Buffer Overflow/DoS
if (imageBuffer.length > 10 * 1024 * 1024) {
return res.status(400).json({ error: 'A imagem excede o limite de tamanho permitido de 10MB.' });
}
// Validar imagem (garante integridade do arquivo através do sharp)
await imageProcessor.validateImage(imageBuffer);
// Salvar imagem
const imageGuid = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Detectar formato
const imgMetadata = await sharp(imageBuffer).metadata();
const format = imgMetadata.format || 'png';
const ext = format === 'jpeg' ? 'jpg' : format;
const savedFilename = `${Date.now()}_${imageGuid}.${ext}`;
const clientName = 'Upload Web';
const patientVal = patientName ? patientName.trim() : 'Paciente não identificado';
await storage.saveImage(savedFilename, imageBuffer, clientName, patientVal, false);
// Gerar thumbnail WebP
let thumbFilename = null;
try {
const thumbBuffer = await sharp(imageBuffer)
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80, effort: 4 })
.toBuffer();
thumbFilename = `thumb_${Date.now()}_${imageGuid}.webp`;
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientVal, false);
console.log(`✅ Thumbnail gerado e salvo para upload manual: ${thumbFilename}`);
} catch (thumbErr) {
console.error('⚠️ Falha ao gerar thumbnail para upload manual:', thumbErr.message);
}
// Inserir no banco de dados
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 = formatLocalDateTime(new Date());
const result = await db.run(
`INSERT INTO images (
image_guid,
patient_name,
client_name,
original_filename,
filename,
enabled,
doctor,
remark,
thumb_filename,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
imageGuid,
patientVal,
clientName,
filename || 'upload_manual.png',
savedFilename,
1,
doctor || null,
remark || null,
thumbFilename,
createdAt
]
);
res.json({
success: true,
imageId: result.lastID,
filename: savedFilename,
message: 'Imagem enviada com sucesso!'
});
} catch (error) {
console.error('Erro no upload de imagem manual:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// GET /api/images/:id - Buscar imagem por ID
// ================================================================
@@ -164,19 +267,13 @@ router.get('/:id/transformations', async (req, res) => {
const originalId = image.original_image_id || image.id;
const originalImage = await db.get('SELECT * FROM images WHERE id = ?', [originalId]);
const originalPath = path.join(__dirname, '../uploads', originalImage.filename);
let originalBuffer;
let originalBuffer = await storage.getImageBuffer(originalImage.filename, false);
if (!originalBuffer) {
originalBuffer = await storage.getImageBuffer(originalImage.filename, true);
}
try {
originalBuffer = await fs.readFile(originalPath);
} catch (e) {
if (e.code === 'ENOENT') {
// Fallback for images corrupted by previous bug
const fallbackPath = path.join(__dirname, '../processed', originalImage.filename);
originalBuffer = await fs.readFile(fallbackPath);
} else {
throw e;
}
if (!originalBuffer) {
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
}
const transformations = [
@@ -228,17 +325,13 @@ router.post('/:id/transform', async (req, res) => {
const realOriginal = await db.get('SELECT * FROM images WHERE id = ?', [realOriginalId]);
// Processar a imagem
const originalPath = path.join(__dirname, '../uploads', realOriginal.filename);
let originalBuffer;
try {
originalBuffer = await fs.readFile(originalPath);
} catch (e) {
if (e.code === 'ENOENT') {
const fallbackPath = path.join(__dirname, '../processed', realOriginal.filename);
originalBuffer = await fs.readFile(fallbackPath);
} else {
throw e;
}
let originalBuffer = await storage.getImageBuffer(realOriginal.filename, false);
if (!originalBuffer) {
originalBuffer = await storage.getImageBuffer(realOriginal.filename, true);
}
if (!originalBuffer) {
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
}
let processed = await imageProcessor.rotate(originalBuffer, rotation);
@@ -247,8 +340,7 @@ router.post('/:id/transform', async (req, res) => {
// Salvar nova versão em processed
const timestamp = Date.now();
const processedFilename = `${realOriginal.image_guid}_${timestamp}.png`;
const processedPath = path.join(__dirname, '../processed', processedFilename);
await fs.writeFile(processedPath, processed);
await storage.saveImage(processedFilename, processed, realOriginal.client_name, realOriginal.patient_name, true);
// Desabilitar a imagem atual para que não fique duplicada na interface
await db.run('UPDATE images SET enabled = 0 WHERE id = ?', [req.params.id]);
@@ -339,6 +431,37 @@ router.get('/:id/download', async (req, res) => {
}
});
// ================================================================
// DELETE /api/images/by-patient - Apagar todas as imagens de um paciente
// ================================================================
router.delete('/by-patient', async (req, res) => {
try {
const patientName = req.query.name || '';
if (!patientName) {
return res.status(400).json({ error: 'Nome do paciente é obrigatório' });
}
// Buscar todas as imagens do paciente no banco de dados
const images = await db.all('SELECT filename, client_name, patient_name FROM images WHERE patient_name = ?', [patientName]);
// Apagar cada imagem do storage (Wasabi S3 + Local)
for (const img of images) {
await storage.deleteImage(img.filename, img.client_name, img.patient_name);
}
// Apagar os registros do banco de dados
await db.run('DELETE FROM images WHERE patient_name = ?', [patientName]);
res.json({
success: true,
message: `Todas as imagens do paciente "${patientName}" foram excluídas com sucesso do servidor e do Wasabi.`
});
} catch (error) {
console.error('Erro ao excluir imagens do paciente em lote:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// DELETE /api/images/:id - Apagar versão da imagem
// ================================================================
@@ -357,13 +480,8 @@ router.delete('/:id', async (req, res) => {
});
}
// Apagar arquivo
const filePath = path.join(__dirname, '../processed', image.filename);
try {
await fs.unlink(filePath);
} catch (e) {
console.warn('Arquivo não encontrado para deletar:', filePath);
}
// Apagar arquivo do storage (Wasabi S3 + Local)
await storage.deleteImage(image.filename, image.client_name, image.patient_name);
// Apagar do banco
await db.run('DELETE FROM images WHERE id = ?', [req.params.id]);