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
+11 -8
View File
@@ -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,
+89 -6
View File
@@ -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;
+4 -3
View File
@@ -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'
},
+15 -18
View File
@@ -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
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
// ================================================================