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:
VPS 4 Deploy Agent
2026-06-12 16:17:09 +02:00
parent d9182cff09
commit 31022c6a6f
55 changed files with 6773 additions and 1350 deletions
+185 -43
View File
@@ -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
// ================================================================