Files
rx.scoreodonto.com/dental-server/routes/images.js
T
VPS 4 Deploy Agent b6da56617a feat(rx): tenant_id (conta/dono) acima de clinica_id — multi-tenant 2 níveis
Hierarquia tenant_id (usuarios.id do dono) / clinica_id / pc / paciente, espelhando
o scoreodonto (conta multi-clínica planejada: um dono gerencia várias clínicas).

- Colunas tenant_id em clinics_devices e images (+índices); backfill device + 2776 imgs.
- Cadastro de device, socket (machine_token), JWT de client e uploads gravam tenant_id.
- Listagens (/images, /patients, /by-patient, /unique-clinics) filtram por tenant_id e/ou clinica_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:26:06 +02:00

989 lines
37 KiB
JavaScript

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=<conta> 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;