feat(rx): multi-tenant por clinica_id + página /clients de clientes ativos
- clinica_id (tenant do scoreodonto, clinicas.id) como eixo de isolamento em clinics_devices e images (+índices); clinic_name vira rótulo legível. - Cadastro de device, socket (machine_token) e uploads (direct + legado) gravam clinica_id. - Listagens (/images, /patients, /by-patient, /unique-clinics) filtram por clinica_id. - /clients: lista clientes Windows ativos (inclui autenticados sem client-identify), tempo real + polling, exibe a clínica. - send-image marcado como legado (client real usa Direct Upload). Inclui trabalho de dev acumulado no working tree (não havia commits desde origin/main). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,11 +19,14 @@ const requireAdmin = (req, res, next) => {
|
||||
// ==============================================================
|
||||
router.get('/', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Filtro multi-tenant opcional: ?clinica_id=<tenant do scoreodonto>.
|
||||
const { clinica_id } = req.query;
|
||||
const clinics = await db.all(`
|
||||
SELECT id, clinic_name, email, pc_name, machine_token, last_ip, is_active, created_at
|
||||
FROM clinics_devices
|
||||
SELECT id, clinic_name, clinica_id, email, pc_name, machine_token, last_ip, is_active, created_at
|
||||
FROM clinics_devices
|
||||
WHERE ($1 = '' OR clinica_id = $1)
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
`, [clinica_id || '']);
|
||||
res.json({ success: true, clinics });
|
||||
} catch (error) {
|
||||
console.error('Erro ao listar clínicas:', error);
|
||||
@@ -35,8 +38,8 @@ router.get('/', requireAdmin, async (req, res) => {
|
||||
// 2. CADASTRAR CLÍNICA E GERAR TOKEN (ADMIN)
|
||||
// ==============================================================
|
||||
router.post('/', requireAdmin, async (req, res) => {
|
||||
const { clinic_name, email, password, pc_name } = req.body;
|
||||
|
||||
const { clinic_name, email, password, pc_name, clinica_id } = req.body;
|
||||
|
||||
if (!clinic_name || !email || !password) {
|
||||
return res.status(400).json({ success: false, message: 'Preencha todos os campos obrigatórios' });
|
||||
}
|
||||
@@ -68,9 +71,9 @@ router.post('/', requireAdmin, async (req, res) => {
|
||||
const machine_token = crypto.randomUUID();
|
||||
|
||||
await db.run(`
|
||||
INSERT INTO clinics_devices (clinic_name, email, password_hash, pc_name, machine_token)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id
|
||||
`, [clinic_name, email, password_hash, pc_name, machine_token]);
|
||||
INSERT INTO clinics_devices (clinic_name, email, password_hash, pc_name, machine_token, clinica_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
|
||||
`, [clinic_name, email, password_hash, pc_name, machine_token, clinica_id || null]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -3,17 +3,43 @@ const router = express.Router();
|
||||
const db = require('../database');
|
||||
const { verifyPassword, generateToken, hashPassword, authenticateToken } = require('../auth');
|
||||
|
||||
// ================================================================
|
||||
// Rate limiter simples em memória — 5 tentativas por IP em 15 min
|
||||
// ================================================================
|
||||
const RATE_WINDOW_MS = 15 * 60 * 1000;
|
||||
const RATE_MAX = 5;
|
||||
const _loginAttempts = new Map();
|
||||
|
||||
function checkLoginRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const attempts = (_loginAttempts.get(ip) || []).filter(t => now - t < RATE_WINDOW_MS);
|
||||
if (attempts.length >= RATE_MAX) return false;
|
||||
attempts.push(now);
|
||||
_loginAttempts.set(ip, attempts);
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearLoginRateLimit(ip) {
|
||||
_loginAttempts.delete(ip);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// POST /api/auth/login - Login
|
||||
// ================================================================
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const ip = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
|
||||
if (!checkLoginRateLimit(ip)) {
|
||||
return res.status(429).json({ error: 'Muitas tentativas. Aguarde 15 minutos.' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({
|
||||
error: 'Usuário e senha são obrigatórios'
|
||||
return res.status(400).json({
|
||||
error: 'Usuário e senha são obrigatórios'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,8 +50,8 @@ router.post('/login', async (req, res) => {
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
error: 'Usuário ou senha inválidos'
|
||||
return res.status(401).json({
|
||||
error: 'Usuário ou senha inválidos'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,11 +59,13 @@ router.post('/login', async (req, res) => {
|
||||
const validPassword = await verifyPassword(password, user.password_hash);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({
|
||||
error: 'Usuário ou senha inválidos'
|
||||
return res.status(401).json({
|
||||
error: 'Usuário ou senha inválidos'
|
||||
});
|
||||
}
|
||||
|
||||
clearLoginRateLimit(ip);
|
||||
|
||||
// Gerar token
|
||||
const token = generateToken(user);
|
||||
|
||||
@@ -377,4 +405,59 @@ router.put('/update-credentials', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// API TOKENS ESTÁTICOS
|
||||
// ================================================================
|
||||
|
||||
router.get('/tokens', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({ error: 'Acesso negado' });
|
||||
}
|
||||
const tokens = await db.all('SELECT id, name, token, created_at, last_used_at FROM api_tokens ORDER BY created_at DESC');
|
||||
res.json({ success: true, tokens });
|
||||
} catch (error) {
|
||||
console.error('Erro ao listar tokens:', error);
|
||||
res.status(500).json({ error: 'Erro ao buscar tokens' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tokens', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({ error: 'Acesso negado' });
|
||||
}
|
||||
const { name } = req.body;
|
||||
if (!name || name.trim() === '') {
|
||||
return res.status(400).json({ error: 'O nome da integração é obrigatório' });
|
||||
}
|
||||
|
||||
const crypto = require('crypto');
|
||||
const token = 'api_' + crypto.randomBytes(24).toString('hex');
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO api_tokens (name, token, created_at) VALUES ($1, $2, CURRENT_TIMESTAMP)',
|
||||
[name, token]
|
||||
);
|
||||
|
||||
res.json({ success: true, token, name });
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar token:', error);
|
||||
res.status(500).json({ error: 'Erro ao gerar token' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/tokens/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({ error: 'Acesso negado' });
|
||||
}
|
||||
await db.run('DELETE FROM api_tokens WHERE id = $1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar token:', error);
|
||||
res.status(500).json({ error: 'Erro ao excluir token' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -49,9 +49,10 @@ router.post('/login', async (req, res) => {
|
||||
// 5. Gerar Token JWT de Sessão
|
||||
// O Token vale por 2 horas, obrigando o cliente a re-logar ou pedir refresh
|
||||
const sessionToken = jwt.sign(
|
||||
{
|
||||
clinicId: device.id,
|
||||
clinicName: device.clinic_name,
|
||||
{
|
||||
clinicId: device.id,
|
||||
clinicaId: device.clinica_id, // tenant do scoreodonto
|
||||
clinicName: device.clinic_name,
|
||||
pcName: device.pc_name,
|
||||
role: 'client_device'
|
||||
},
|
||||
|
||||
@@ -50,34 +50,31 @@ router.get('/', async (req, res) => {
|
||||
);
|
||||
const total = parseInt(countRow.total);
|
||||
|
||||
// Adiciona limit e offset como parâmetros — evita interpolação direta (SQL injection)
|
||||
const limitIdx = i;
|
||||
const offsetIdx = i + 1;
|
||||
params.push(limit, offset);
|
||||
|
||||
const rows = await db.all(
|
||||
`SELECT g.*,
|
||||
COUNT(gi.id)::int AS image_count
|
||||
COUNT(gi.id)::int AS image_count,
|
||||
(
|
||||
SELECT '/api/images/' || i.id || '/thumbnail'
|
||||
FROM gto_images gi2
|
||||
JOIN images i ON i.id = gi2.image_id
|
||||
WHERE gi2.gto_id = g.id
|
||||
ORDER BY gi2.created_at ASC
|
||||
LIMIT 1
|
||||
) AS thumb_url
|
||||
FROM gtos g
|
||||
LEFT JOIN gto_images gi ON gi.gto_id = g.id
|
||||
${whereClause}
|
||||
GROUP BY g.id
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
LIMIT $${limitIdx} OFFSET $${offsetIdx}`,
|
||||
params
|
||||
);
|
||||
|
||||
// Adiciona thumb_url da primeira imagem vinculada para preview
|
||||
for (const row of rows) {
|
||||
if (parseInt(row.image_count) > 0) {
|
||||
const firstImg = await db.get(
|
||||
`SELECT i.id FROM gto_images gi
|
||||
JOIN images i ON i.id = gi.image_id
|
||||
WHERE gi.gto_id = $1
|
||||
ORDER BY gi.created_at ASC LIMIT 1`,
|
||||
[row.id]
|
||||
);
|
||||
row.thumb_url = firstImg ? `/api/images/${firstImg.id}/thumbnail` : null;
|
||||
} else {
|
||||
row.thumb_url = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ rows, total, page, limit, pages: Math.ceil(total / limit) });
|
||||
} catch (err) {
|
||||
console.error('Erro ao listar GTOs:', err);
|
||||
|
||||
+185
-43
@@ -52,6 +52,27 @@ router.get('/unique-clients', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// GET /api/images/unique-clinics - Tenants (clinica_id) com contagem + rótulo
|
||||
// ================================================================
|
||||
router.get('/unique-clinics', async (req, res) => {
|
||||
try {
|
||||
const rows = await db.all(
|
||||
`SELECT 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
|
||||
GROUP BY clinica_id
|
||||
ORDER BY clinic_name`
|
||||
);
|
||||
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")
|
||||
@@ -105,8 +126,19 @@ router.get('/download-all', async (req, res) => {
|
||||
router.post('/upload-file', async (req, res) => {
|
||||
try {
|
||||
const multer = require('multer');
|
||||
// Configura multer em memória (max 50MB)
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } }).single('image');
|
||||
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}` });
|
||||
@@ -222,16 +254,28 @@ router.get('/', async (req, res) => {
|
||||
const showDisabled = req.query.disabled === 'true';
|
||||
const search = req.query.search || '';
|
||||
const clientName = req.query.client || '';
|
||||
|
||||
const clinicaId = req.query.clinica_id || ''; // tenant do scoreodonto (isolamento)
|
||||
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 (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}%`);
|
||||
@@ -256,51 +300,84 @@ router.get('/patients', async (req, res) => {
|
||||
const showDisabled = req.query.disabled === 'true';
|
||||
const search = req.query.search || '';
|
||||
const clientName = req.query.client || '';
|
||||
const clinicaId = req.query.clinica_id || ''; // tenant do scoreodonto (isolamento)
|
||||
const clinicName = req.query.clinic || ''; // rótulo legível (filtro opcional)
|
||||
const enabledVal = showDisabled ? 0 : 1;
|
||||
|
||||
let paramIndex = 1;
|
||||
let where = `WHERE enabled = $${paramIndex++}`;
|
||||
let params = [enabledVal];
|
||||
|
||||
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 (clinicaId) {
|
||||
whereStr += ` AND clinica_id = $${paramIndex}`;
|
||||
params.push(clinicaId);
|
||||
paramIndex++;
|
||||
}
|
||||
if (clientName) {
|
||||
where += ` AND client_name = $${paramIndex}`;
|
||||
whereStr += ` AND client_name = $${paramIndex}`;
|
||||
params.push(clientName);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (clinicName) {
|
||||
whereStr += ` AND clinic_name = $${paramIndex}`;
|
||||
params.push(clinicName);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where += ` AND (patient_name LIKE $${paramIndex} OR image_guid LIKE $${paramIndex + 1})`;
|
||||
whereStr += ` AND (patient_name ILIKE $${paramIndex} OR image_guid ILIKE $${paramIndex + 1})`;
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
paramIndex += 2;
|
||||
}
|
||||
|
||||
// Group by patient_name — pick thumbnail from most recent image
|
||||
const rows = await db.all(
|
||||
`SELECT
|
||||
patient_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 id FROM images i2
|
||||
WHERE i2.patient_name = images.patient_name AND i2.enabled = ${enabledVal}
|
||||
ORDER BY i2.created_at DESC LIMIT 1) AS latest_image_id,
|
||||
(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
|
||||
${where}
|
||||
GROUP BY patient_name
|
||||
ORDER BY MAX(created_at) DESC
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
// 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` : '';
|
||||
@@ -321,17 +398,20 @@ router.get('/by-patient', async (req, res) => {
|
||||
try {
|
||||
const patientName = req.query.name || '';
|
||||
const showDisabled = req.query.disabled === 'true';
|
||||
const clinicaId = req.query.clinica_id || ''; // tenant do scoreodonto (isolamento)
|
||||
|
||||
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 AND enabled = $2
|
||||
ORDER BY created_at DESC`,
|
||||
[patientName, showDisabled ? 0 : 1]
|
||||
);
|
||||
let sql = `SELECT * FROM images WHERE patient_name = $1 AND enabled = $2`;
|
||||
const params = [patientName, showDisabled ? 0 : 1];
|
||||
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) {
|
||||
@@ -459,6 +539,68 @@ router.delete('/by-patient', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 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
|
||||
// ================================================================
|
||||
|
||||
Reference in New Issue
Block a user