const express = require('express'); const router = express.Router(); const db = require('../database'); const imageProcessor = require('../image-processor'); const crypto = require('crypto'); const path = require('path'); const fs = require('fs').promises; const storage = require('../storage'); async function emitAndAwaitAck(io, action, payload) { if (!io) return { success: true, warning: 'Socket.IO não inicializado no servidor.' }; const eventId = crypto.randomUUID(); payload.eventId = eventId; payload.action = action; const ackPromise = new Promise((resolve) => { if (global.pendingAcks) { global.pendingAcks.set(eventId, resolve); setTimeout(() => { if (global.pendingAcks.has(eventId)) { global.pendingAcks.delete(eventId); resolve(null); // Timeout } }, 4000); } else { resolve(null); } }); io.emit('remote-crud-patient', payload); const result = await ackPromise; if (result && result.success) { return { success: true, message: `Sincronizado com PC: ${result.pcName || 'Desconhecido'}`, ackInfo: result }; } else if (result && !result.success) { return { success: true, warning: `Erro no app Windows (${result.pcName || 'Desconhecido'}): ${result.error || 'Erro local'}` }; } else { return { success: true, warning: 'Salvo na nuvem, porém o App Windows não respondeu. Verifique o PC do app client Windows ou nome do PC - o app pode estar atualizando, o PC desligado, o app fechado ou sem internet no PC ou clínica.' }; } } // ================================================================ // GET /api/images/unique-clients - Buscar lista leve de clínicas // ================================================================ router.get('/unique-clients', async (req, res) => { try { const clients = await db.all('SELECT DISTINCT client_name FROM images WHERE client_name IS NOT NULL'); res.json(clients.map(c => c.client_name)); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/unique-clinics - Clínicas (clinica_id) de uma conta (tenant_id), // com rótulo e contagens. ?tenant_id= filtra; sem ele, todas. // ================================================================ router.get('/unique-clinics', async (req, res) => { try { const tenantId = req.query.tenant_id || ''; const rows = await db.all( `SELECT tenant_id, clinica_id, MAX(clinic_name) AS clinic_name, COUNT(*)::integer AS image_count, COUNT(DISTINCT patient_name)::integer AS patient_count FROM images WHERE clinica_id IS NOT NULL AND enabled = 1 AND ($1 = '' OR tenant_id = $1) GROUP BY tenant_id, clinica_id ORDER BY clinic_name`, [tenantId] ); res.json(rows); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/download-all — Baixar todas as imagens de um paciente em ZIP // Usado pela tela Galeria do mobile (Sheet → "Baixar todas") // ================================================================ router.get('/download-all', async (req, res) => { try { const patientName = req.query.patient; if (!patientName) return res.status(400).json({ error: 'Parâmetro patient obrigatório' }); const images = await db.all( 'SELECT id, filename, original_filename, clinic_name, client_name, patient_name FROM images WHERE patient_name = $1 AND enabled = 1', [patientName] ); if (images.length === 0) return res.status(404).json({ error: 'Nenhuma imagem encontrada para este paciente' }); const archiver = require('archiver'); const sanitized = patientName.replace(/[^a-zA-Z0-9À-ÿ _-]/g, '_'); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${sanitized}_imagens.zip"`); const archive = archiver('zip', { zlib: { level: 5 } }); archive.pipe(res); for (const img of images) { try { let buf = null; try { buf = await storage.getImageBuffer(img.filename, img.clinic_name, img.client_name, img.patient_name); } catch (_) {} if (!buf && img.patient_name !== 'Desconhecido') { try { buf = await storage.getImageBuffer(img.filename, img.clinic_name, img.client_name, 'Desconhecido'); } catch (_) {} } if (buf) { const ext = path.extname(img.original_filename || img.filename) || '.png'; archive.append(buf, { name: `${img.original_filename || img.id}${ext === img.original_filename ? '' : ''}` }); } } catch (e) { console.error(`[download-all] Falha ao incluir imagem id ${img.id}:`, e.message); } } await archive.finalize(); } catch (error) { console.error('Erro ao gerar ZIP:', error); if (!res.headersSent) res.status(500).json({ error: error.message }); } }); // ================================================================ // POST /api/images/upload-file — Upload via multipart/form-data (mobile) // Aceita arquivo via campo "image" + campos patientName, doctor, remark // ================================================================ router.post('/upload-file', async (req, res) => { try { const multer = require('multer'); const ALLOWED_MIMETYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp']; // fileFilter rejeita o arquivo antes de carregar em memória — primeira linha de defesa const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (ALLOWED_MIMETYPES.includes(file.mimetype)) { cb(null, true); } else { cb(new Error(`Tipo de arquivo não permitido: ${file.mimetype}`)); } } }).single('image'); upload(req, res, async function (err) { if (err) return res.status(400).json({ error: `Erro no upload: ${err.message}` }); if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado no campo "image"' }); const patientName = req.body.patientName || req.body.patient || 'Desconhecido'; const doctor = req.body.doctor || ''; const remark = req.body.remark || ''; const imageBuffer = req.file.buffer; await imageProcessor.validateImage(imageBuffer); const clinicName = 'Upload Mobile'; const clientName = 'Upload Mobile'; const timestamp = Date.now(); const guid = Math.random().toString(36).substring(2, 15); const ext = path.extname(req.file.originalname) || '.png'; const filename = `${timestamp}_${guid}${ext}`; await storage.saveImage(filename, imageBuffer, clinicName, clientName, patientName, false); let thumbFilename = null; try { const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500); thumbFilename = `thumb_${timestamp}_${guid}.jpg`; await storage.saveImage(thumbFilename, thumbBuffer, clinicName, clientName, patientName, false); } catch (e) { console.error('Erro ao gerar thumbnail (upload-file):', e); } await db.run( `INSERT INTO images ( 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, req.file.originalname, filename, thumbFilename, doctor, remark] ); if (req.app.get('io') || global.io) { const io = req.app.get('io') || global.io; if (io) io.emit('new-image', { filename, thumb: thumbFilename, patientName }); } res.json({ success: true, filename, thumbFilename }); }); } catch (error) { console.error('Erro no upload-file:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // POST /api/images/upload - Envio Manual via Web // ================================================================ router.post('/upload', async (req, res) => { try { const { imageBase64, patientData } = req.body; if (!imageBase64 || !patientData || !patientData.name) { return res.status(400).json({ error: 'Dados incompletos: imageBase64 e patientData.name são obrigatórios.' }); } const base64Data = imageBase64.replace(/^data:image\/\w+;base64,/, ""); const imageBuffer = Buffer.from(base64Data, 'base64'); 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, clinicName, clientName, patientName, false); let thumbFilename = null; try { const thumbBuffer = await imageProcessor.getThumbnail(imageBuffer, 500, 500); thumbFilename = `thumb_${filename}`; 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, 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 if (req.app.get('io') || global.io) { const io = req.app.get('io') || global.io; if (io) io.emit('new-image', { filename: filename, thumb: thumbFilename, patientName: patientName }); } res.json({ success: true }); } catch (error) { console.error('Erro no upload manual:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images - Listar imagens // ================================================================ router.get('/', async (req, res) => { try { const showDisabled = req.query.disabled === 'true'; const search = req.query.search || ''; const clientName = req.query.client || ''; const tenantId = req.query.tenant_id || ''; // conta/dono (isolamento por conta) const clinicaId = req.query.clinica_id || ''; // clínica (isolamento por unidade) const clinicName = req.query.clinic || ''; // rótulo legível (filtro opcional) let query = `SELECT * FROM images WHERE enabled = $1`; let params = [showDisabled ? 0 : 1]; let paramIndex = 2; if (tenantId) { query += ` AND tenant_id = $${paramIndex++}`; params.push(tenantId); } if (clinicaId) { query += ` AND clinica_id = $${paramIndex++}`; params.push(clinicaId); } if (clientName) { query += ` AND client_name = $${paramIndex++}`; params.push(clientName); } if (clinicName) { query += ` AND clinic_name = $${paramIndex++}`; params.push(clinicName); } if (search) { query += ` AND (patient_name LIKE $${paramIndex} OR image_guid LIKE $${paramIndex + 1} OR filename LIKE $${paramIndex + 2})`; params.push(`%${search}%`, `%${search}%`, `%${search}%`); paramIndex += 3; } query += ` ORDER BY created_at DESC LIMIT 100`; const images = await db.all(query, params); res.json(images); } catch (error) { console.error('Erro ao listar imagens:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/patients - Listar pacientes agrupados // ================================================================ router.get('/patients', async (req, res) => { try { const showDisabled = req.query.disabled === 'true'; const search = req.query.search || ''; const clientName = req.query.client || ''; const tenantId = req.query.tenant_id || ''; // conta/dono (isolamento por conta) const clinicaId = req.query.clinica_id || ''; // clínica (isolamento por unidade) const clinicName = req.query.clinic || ''; // rótulo legível (filtro opcional) const enabledVal = showDisabled ? 0 : 1; let paramIndex = 1; let params = []; // Pagination — limite máximo de 200 por página const page = Math.max(1, parseInt(req.query.page) || 1); const limit = Math.min(200, parseInt(req.query.limit) || 50); const offset = (page - 1) * limit; let whereStr = `WHERE 1=1`; if (tenantId) { whereStr += ` AND tenant_id = $${paramIndex}`; params.push(tenantId); paramIndex++; } if (clinicaId) { whereStr += ` AND clinica_id = $${paramIndex}`; params.push(clinicaId); paramIndex++; } if (clientName) { whereStr += ` AND client_name = $${paramIndex}`; params.push(clientName); paramIndex++; } if (clinicName) { whereStr += ` AND clinic_name = $${paramIndex}`; params.push(clinicName); paramIndex++; } if (search) { whereStr += ` AND (patient_name ILIKE $${paramIndex} OR image_guid ILIKE $${paramIndex + 1})`; params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } // Adiciona limit e offset como parâmetros — evita interpolação direta (SQL injection) const limitIdx = paramIndex; const offsetIdx = paramIndex + 1; params.push(limit, offset); const query = ` WITH stats AS ( SELECT patient_name, SUM(CASE WHEN enabled = ${enabledVal} THEN 1 ELSE 0 END)::integer AS image_count, SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END)::integer AS pending_count, MAX(CASE WHEN enabled = ${enabledVal} THEN created_at END) AS max_created_at FROM images ${whereStr} GROUP BY patient_name HAVING SUM(CASE WHEN enabled = ${enabledVal} THEN 1 ELSE 0 END) > 0 ORDER BY max_created_at DESC LIMIT $${limitIdx} OFFSET $${offsetIdx} ) SELECT s.patient_name, s.image_count, s.pending_count, s.max_created_at AS last_date, i.doctor, i.remark, i.client_name, i.id AS latest_image_id, COALESCE(i.thumb_filename, i.filename) AS thumb_filename FROM stats s LEFT JOIN LATERAL ( SELECT doctor, remark, client_name, id, thumb_filename, filename FROM images WHERE patient_name = s.patient_name AND enabled = ${enabledVal} ORDER BY created_at DESC LIMIT 1 ) i ON true ORDER BY s.max_created_at DESC `; // Execute the optimized query const rows = await db.all(query, params); for (const row of rows) { row.thumb_url = row.latest_image_id ? `/api/images/${row.latest_image_id}/thumbnail` : ''; } res.json(rows); } catch (error) { console.error('Erro ao listar pacientes:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/by-patient - Imagens de um paciente // ================================================================ router.get('/by-patient', async (req, res) => { try { const patientName = req.query.name || ''; const showDisabled = req.query.disabled === 'true'; const tenantId = req.query.tenant_id || ''; // conta/dono (isolamento por conta) const clinicaId = req.query.clinica_id || ''; // clínica (isolamento por unidade) if (!patientName) { return res.status(400).json({ error: 'Parâmetro name obrigatório' }); } let sql = `SELECT * FROM images WHERE patient_name = $1 AND enabled = $2`; const params = [patientName, showDisabled ? 0 : 1]; if (tenantId) { sql += ` AND tenant_id = $${params.length + 1}`; params.push(tenantId); } if (clinicaId) { sql += ` AND clinica_id = $${params.length + 1}`; params.push(clinicaId); } sql += ` ORDER BY created_at DESC`; const images = await db.all(sql, params); // Adicionar lazy-load URLs (não fazer requisições async aqui) for (const image of images) { image.file_url = `/api/images/${image.id}/file-url`; image.thumb_url = `/api/images/${image.id}/thumbnail`; } res.json(images); } catch (error) { console.error('Erro ao buscar imagens do paciente:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // POST /api/images/patients - Criar paciente // Insere um registro "fantasma" (enabled=0) para manter os dados // ================================================================ router.post('/patients', async (req, res) => { try { const { firstName, lastName, doctor, birthday, gender, phone, remark } = req.body; const fullName = `${firstName || ''} ${lastName || ''}`.trim(); if (!fullName) return res.status(400).json({ error: 'Nome obrigatório' }); const dummyGuid = crypto.randomUUID(); await db.run( `INSERT INTO images ( image_guid, patient_name, doctor, remark, enabled, filename, original_filename ) VALUES ($1, $2, $3, $4, 0, $5, $6)`, [ dummyGuid, fullName, doctor || null, remark || null, 'dummy_patient_record', 'dummy_patient_record' ] ); const io = req.app.get('io') || global.io; const syncResult = await emitAndAwaitAck(io, 'create', { patient: { id: dummyGuid, firstName, lastName, doctor, birthday, gender, phone, remark, fullName } }); res.json(syncResult); } catch (error) { console.error('Erro ao criar paciente:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // PUT /api/images/patients/:name - Editar dados de um paciente // Atualiza patient_name, doctor e remark em todas as imagens do paciente. // ================================================================ router.put('/patients/:name', async (req, res) => { try { const oldName = decodeURIComponent(req.params.name); const { newName, doctor, remark } = req.body; if (!oldName) return res.status(400).json({ error: 'Nome do paciente obrigatório.' }); const updates = []; const params = []; let i = 1; if (newName !== undefined && newName.trim() !== '') { updates.push(`patient_name = $${i++}`); params.push(newName.trim()); } if (doctor !== undefined) { updates.push(`doctor = $${i++}`); params.push(doctor); } if (remark !== undefined) { updates.push(`remark = $${i++}`); params.push(remark); } if (updates.length === 0) return res.status(400).json({ error: 'Nenhum campo para atualizar.' }); params.push(oldName); const result = await db.run( `UPDATE images SET ${updates.join(', ')} WHERE patient_name = $${i}`, params ); const io = req.app.get('io') || global.io; const syncResult = await emitAndAwaitAck(io, 'update', { oldName: oldName, patient: { newName: newName?.trim(), doctor, remark } }); res.json({ ...syncResult, updated: result.changes }); } catch (error) { console.error('Erro ao editar paciente:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // DELETE /api/images/by-patient - Excluir todas 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: 'Parâmetro name obrigatório' }); const images = await db.all('SELECT * FROM images WHERE patient_name = $1', [patientName]); for (const image of images) { 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]); const io = req.app.get('io') || global.io; const syncResult = await emitAndAwaitAck(io, 'delete', { patientName }); res.json({ ...syncResult, count: images.length }); } catch (error) { console.error('Erro ao excluir imagens do paciente:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/:id/thumbnail — Serve o thumbnail da imagem // ================================================================ router.get('/:id/thumbnail', async (req, res) => { try { const image = await db.get( 'SELECT id, filename, thumb_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' }); // Cache de 7 dias res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); res.setHeader('Content-Type', 'image/jpeg'); // Tenta servir o thumb_filename se existir if (image.thumb_filename) { try { const thumbBuf = await storage.getImageBuffer( image.thumb_filename, image.clinic_name, image.client_name, image.patient_name ); return res.send(thumbBuf); } catch (_) { // thumb corrompido/ausente — gera on-the-fly abaixo } } // Fallback: gera thumbnail on-the-fly a partir da imagem original let originalBuf = null; try { originalBuf = await storage.getImageBuffer( image.filename, image.clinic_name, image.client_name, image.patient_name ); } catch (_) {} // Segundo fallback: uploads antigos gravados com patientName='Desconhecido' if (!originalBuf && image.patient_name !== 'Desconhecido') { try { originalBuf = await storage.getImageBuffer( image.filename, image.clinic_name, image.client_name, 'Desconhecido' ); } catch (_) {} } if (!originalBuf) return res.status(404).json({ error: 'Arquivo da imagem não encontrado' }); const thumbBuf = await imageProcessor.getThumbnail(originalBuf, 500, 500); // Salva para evitar regeneração na próxima requisição try { const thumbFilename = `thumb_${image.filename.replace(/\.[^.]+$/, '')}.jpg`; await storage.saveImage(thumbFilename, thumbBuf, image.clinic_name, image.client_name, image.patient_name, false); await db.run('UPDATE images SET thumb_filename = $1 WHERE id = $2', [thumbFilename, image.id]); } catch (_) {} return res.send(thumbBuf); } catch (error) { console.error('[thumbnail] Erro:', error.message); res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/:id - Buscar imagem por ID // ================================================================ router.get('/:id', async (req, res) => { try { const image = await db.get('SELECT * FROM images WHERE id = $1', [req.params.id]); if (!image) { return res.status(404).json({ error: 'Imagem não encontrada' }); } res.json(image); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/stats - Estatísticas // ================================================================ router.get('/stats', async (req, res) => { try { const total = await db.get('SELECT COUNT(*) as count FROM images'); const enabled = await db.get('SELECT COUNT(*) as count FROM images WHERE enabled = 1'); const withTooth = await db.get('SELECT COUNT(*) as count FROM images WHERE tooth_number IS NOT NULL'); res.json({ total: total.count, enabled: enabled.count, disabled: total.count - enabled.count, withToothInfo: withTooth.count, withoutToothInfo: total.count - withTooth.count }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/:id/transformations - Gerar transformações // ================================================================ router.get('/:id/transformations', async (req, res) => { try { const image = await db.get('SELECT * FROM images WHERE id = $1', [req.params.id]); if (!image) { return res.status(404).json({ error: 'Imagem não encontrada' }); } // Buscar a imagem original se esta é uma transformação const originalId = image.original_image_id || image.id; const originalImage = await db.get('SELECT * FROM images WHERE id = $1', [originalId]); let originalBuffer; try { 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.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 }, { name: 'Flip Vertical', rotation: 0, flipH: false, flipV: true }, { name: 'Rotação +90°', rotation: 90, flipH: false, flipV: false }, { name: 'Rotação -90°', rotation: -90, flipH: false, flipV: false } ]; const results = []; for (const t of transformations) { try { let processed = await imageProcessor.rotate(originalBuffer, t.rotation); processed = await imageProcessor.flip(processed, t.flipH, t.flipV); const base64 = `data:image/png;base64,${processed.toString('base64')}`; results.push({ ...t, base64, size: processed.length }); } catch (error) { console.error(`Erro ao processar transformação ${t.name}:`, error); } } res.json(results); } catch (error) { console.error('Erro ao gerar transformações:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // POST /api/images/:id/transform - Salvar transformação // ================================================================ router.post('/:id/transform', async (req, res) => { try { const { rotation, flipH, flipV, image_remark } = req.body; const originalImage = await db.get('SELECT * FROM images WHERE id = $1', [req.params.id]); if (!originalImage) { return res.status(404).json({ error: 'Imagem não encontrada' }); } // Buscar a imagem original real se esta é uma transformação const realOriginalId = originalImage.original_image_id || originalImage.id; const realOriginal = await db.get('SELECT * FROM images WHERE id = $1', [realOriginalId]); // Processar a imagem let originalBuffer; try { 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.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.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]); const newImageRemark = image_remark !== undefined ? image_remark : originalImage.image_remark; // Inserir a nova imagem como a versão ativa const result = await db.run( `INSERT INTO images ( image_guid, patient_name, clinic_name, client_name, original_filename, filename, enabled, rotation, flip_horizontal, flip_vertical, original_image_id, created_at, remark, image_remark, doctor ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`, [ `${realOriginal.image_guid}_${timestamp}`, realOriginal.patient_name, realOriginal.clinic_name, realOriginal.client_name, realOriginal.filename, processedFilename, 1, // Nova imagem fica ativa rotation, flipH ? 1 : 0, flipV ? 1 : 0, realOriginalId, originalImage.created_at, // Manter a data de criação original originalImage.remark, // Manter a observação do paciente intacta newImageRemark, // A observação específica da imagem realOriginal.doctor ] ); res.json({ success: true, imageId: result.lastID, message: 'Orientação atualizada com sucesso!' }); } catch (error) { console.error('Erro ao salvar transformação:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // PUT /api/images/:id/remark - Atualizar observação da imagem // ================================================================ router.put('/:id/remark', async (req, res) => { try { const { image_remark } = req.body; await db.run( 'UPDATE images SET image_remark = $1 WHERE id = $2', [image_remark, req.params.id] ); res.json({ success: true, message: 'Observação da imagem atualizada!' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // PUT /api/images/:id/patient-info - Atualizar dados do paciente // ================================================================ router.put('/:id/patient-info', async (req, res) => { try { const { patientNameResumed, toothNumber, toothSide } = req.body; await db.run( `UPDATE images SET patient_name_resumed = $1, tooth_number = $2, tooth_side = $3, enabled = 1 WHERE id = $4`, [patientNameResumed, toothNumber, toothSide, req.params.id] ); res.json({ success: true, message: 'Dados do paciente atualizados!' }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/:id/download - Download da imagem // ================================================================ router.get('/:id/download', async (req, res) => { try { const image = await db.get('SELECT * FROM images WHERE id = $1', [req.params.id]); if (!image) { return res.status(404).json({ error: 'Imagem não encontrada' }); } // Nome: nome-resumido_numero_lado.png if (image.patient_name_resumed && image.tooth_number && image.tooth_side) { const filename = `${image.patient_name_resumed}_${image.tooth_number}_${image.tooth_side}.png`; const filePath = path.join(__dirname, '../processed', image.filename); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.download(filePath, filename); } else { res.status(400).json({ error: 'Dados do paciente incompletos' }); } } catch (error) { console.error('Erro no download:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // DELETE /api/images/:id - Apagar versão da imagem // ================================================================ router.delete('/:id', async (req, res) => { try { const image = await db.get('SELECT * FROM images WHERE id = $1', [req.params.id]); if (!image) { return res.status(404).json({ error: 'Imagem não encontrada' }); } // NUNCA apagar a original if (!image.original_image_id) { return res.status(400).json({ error: 'Não é possível apagar a imagem original', code: 'ORIGINAL_NOT_DELETABLE' }); } // Apagar arquivo local e do S3 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]); res.json({ success: true, message: 'Imagem apagada com sucesso' }); } catch (error) { console.error('Erro ao apagar imagem:', error); res.status(500).json({ error: error.message }); } }); // ================================================================ // GET /api/images/:id/file-url — Retorna a URL de visualização // full-res de uma imagem (para o painel Original do modal) // ================================================================ router.get('/:id/file-url', async (req, res) => { try { 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; if (image.filename.startsWith('http')) { url = image.filename; } else { // Se o arquivo está no Wasabi, reconstroem a URL try { 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); url = `/uploads/${image.filename}`; } } res.json({ url }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ================================================================ // PUT /api/images/:id/toggle-enabled - Habilitar/Desabilitar // ================================================================ router.put('/:id/toggle-enabled', async (req, res) => { try { const image = await db.get('SELECT * FROM images WHERE id = $1', [req.params.id]); if (!image) { return res.status(404).json({ error: 'Imagem não encontrada' }); } const newEnabled = image.enabled ? 0 : 1; await db.run('UPDATE images SET enabled = $1 WHERE id = $2', [newEnabled, req.params.id]); res.json({ success: true, enabled: newEnabled === 1 }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================================ // POST /api/images/backfill-thumbs — Gera thumbnails para imagens // que ainda não têm thumb_filename. Roda em background; retorna // imediatamente com o total de imagens na fila. // ================================================================ router.post('/backfill-thumbs', async (req, res) => { try { const rows = await db.all( `SELECT id, filename, clinic_name, client_name, patient_name FROM images WHERE thumb_filename IS NULL AND enabled = 1 ORDER BY created_at DESC` ); res.json({ queued: rows.length, message: 'Backfill iniciado em background' }); // Processa sem bloquear a resposta setImmediate(() => runThumbBackfill(rows)); } catch (e) { res.status(500).json({ error: e.message }); } }); async function runThumbBackfill(rows) { let done = 0, failed = 0; for (const row of rows) { try { // Busca a original via storage (disco local → Wasabi com path correto → fallback 'Desconhecido') let imgBuffer = null; try { imgBuffer = await storage.getImageBuffer(row.filename, row.clinic_name, row.client_name, row.patient_name); } catch (_) {} // Fallback legado: uploads antigos foram gravados com patientName='Desconhecido' no Wasabi if (!imgBuffer && row.patient_name !== 'Desconhecido') { try { imgBuffer = await storage.getImageBuffer(row.filename, row.clinic_name, row.client_name, 'Desconhecido'); } catch (_) {} } if (!imgBuffer) { failed++; continue; } const thumbBuffer = await imageProcessor.getThumbnail(imgBuffer); const thumbFilename = `thumb_${row.filename.replace(/\.[^.]+$/, '')}.jpg`; 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++; console.log(`[backfill-thumbs] ✅ id ${row.id}: ${thumbFilename}`); } catch (e) { console.error(`[backfill-thumbs] ❌ id ${row.id}:`, e.message); failed++; } } console.log(`[backfill-thumbs] concluído: ${done} geradas, ${failed} falhas de ${rows.length} total`); } // Exporta a função para ser chamada no boot do servidor module.exports = router; module.exports.runThumbBackfill = runThumbBackfill;