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:
+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