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>
This commit is contained in:
@@ -95,10 +95,14 @@ async function createTables() {
|
|||||||
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS thumb_filename TEXT;');
|
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS thumb_filename TEXT;');
|
||||||
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS clinic_name VARCHAR(100);');
|
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS clinic_name VARCHAR(100);');
|
||||||
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS image_remark TEXT;');
|
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS image_remark TEXT;');
|
||||||
// Multi-tenant: clinica_id = tenant do scoreodonto (clinicas.id, ex. "c_<ts>_<rand>").
|
// Multi-tenant (2 níveis, espelha o scoreodonto):
|
||||||
// clinic_name vira só rótulo legível; clinica_id é o eixo de isolamento.
|
// tenant_id = conta/dono (usuarios.id "u_<ts>_<rand>") — agrupa várias clínicas.
|
||||||
|
// clinica_id = clínica (clinicas.id "c_<ts>_<rand>") — unidade operacional.
|
||||||
|
// clinic_name vira só rótulo legível. Hierarquia: tenant_id / clinica_id / pc / paciente.
|
||||||
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS clinica_id VARCHAR(64);');
|
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS clinica_id VARCHAR(64);');
|
||||||
|
await client.query('ALTER TABLE images ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(64);');
|
||||||
await client.query('CREATE INDEX IF NOT EXISTS idx_images_clinica_id ON images(clinica_id);');
|
await client.query('CREATE INDEX IF NOT EXISTS idx_images_clinica_id ON images(clinica_id);');
|
||||||
|
await client.query('CREATE INDEX IF NOT EXISTS idx_images_tenant_id ON images(tenant_id);');
|
||||||
|
|
||||||
console.log('✅ Tabela images criada/verificada no PostgreSQL');
|
console.log('✅ Tabela images criada/verificada no PostgreSQL');
|
||||||
|
|
||||||
@@ -143,9 +147,11 @@ async function createTables() {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
// Multi-tenant: vincula o device ao tenant do scoreodonto (clinicas.id).
|
// Multi-tenant: device vinculado à clínica (clinica_id) e à conta/dono (tenant_id).
|
||||||
await client.query('ALTER TABLE clinics_devices ADD COLUMN IF NOT EXISTS clinica_id VARCHAR(64);');
|
await client.query('ALTER TABLE clinics_devices ADD COLUMN IF NOT EXISTS clinica_id VARCHAR(64);');
|
||||||
|
await client.query('ALTER TABLE clinics_devices ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(64);');
|
||||||
await client.query('CREATE INDEX IF NOT EXISTS idx_devices_clinica_id ON clinics_devices(clinica_id);');
|
await client.query('CREATE INDEX IF NOT EXISTS idx_devices_clinica_id ON clinics_devices(clinica_id);');
|
||||||
|
await client.query('CREATE INDEX IF NOT EXISTS idx_devices_tenant_id ON clinics_devices(tenant_id);');
|
||||||
console.log('✅ Tabela clinics_devices criada/verificada no PostgreSQL');
|
console.log('✅ Tabela clinics_devices criada/verificada no PostgreSQL');
|
||||||
|
|
||||||
// Tabela de configurações do sistema (key-value)
|
// Tabela de configurações do sistema (key-value)
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ const requireAdmin = (req, res, next) => {
|
|||||||
// ==============================================================
|
// ==============================================================
|
||||||
router.get('/', requireAdmin, async (req, res) => {
|
router.get('/', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Filtro multi-tenant opcional: ?clinica_id=<tenant do scoreodonto>.
|
// Filtros multi-tenant opcionais: ?tenant_id=<conta> e/ou ?clinica_id=<clínica>.
|
||||||
const { clinica_id } = req.query;
|
const { clinica_id, tenant_id } = req.query;
|
||||||
const clinics = await db.all(`
|
const clinics = await db.all(`
|
||||||
SELECT id, clinic_name, clinica_id, email, pc_name, machine_token, last_ip, is_active, created_at
|
SELECT id, clinic_name, clinica_id, tenant_id, email, pc_name, machine_token, last_ip, is_active, created_at
|
||||||
FROM clinics_devices
|
FROM clinics_devices
|
||||||
WHERE ($1 = '' OR clinica_id = $1)
|
WHERE ($1 = '' OR tenant_id = $1)
|
||||||
|
AND ($2 = '' OR clinica_id = $2)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`, [clinica_id || '']);
|
`, [tenant_id || '', clinica_id || '']);
|
||||||
res.json({ success: true, clinics });
|
res.json({ success: true, clinics });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erro ao listar clínicas:', error);
|
console.error('Erro ao listar clínicas:', error);
|
||||||
@@ -38,7 +39,7 @@ router.get('/', requireAdmin, async (req, res) => {
|
|||||||
// 2. CADASTRAR CLÍNICA E GERAR TOKEN (ADMIN)
|
// 2. CADASTRAR CLÍNICA E GERAR TOKEN (ADMIN)
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
router.post('/', requireAdmin, async (req, res) => {
|
router.post('/', requireAdmin, async (req, res) => {
|
||||||
const { clinic_name, email, password, pc_name, clinica_id } = req.body;
|
const { clinic_name, email, password, pc_name, clinica_id, tenant_id } = req.body;
|
||||||
|
|
||||||
if (!clinic_name || !email || !password) {
|
if (!clinic_name || !email || !password) {
|
||||||
return res.status(400).json({ success: false, message: 'Preencha todos os campos obrigatórios' });
|
return res.status(400).json({ success: false, message: 'Preencha todos os campos obrigatórios' });
|
||||||
@@ -71,9 +72,9 @@ router.post('/', requireAdmin, async (req, res) => {
|
|||||||
const machine_token = crypto.randomUUID();
|
const machine_token = crypto.randomUUID();
|
||||||
|
|
||||||
await db.run(`
|
await db.run(`
|
||||||
INSERT INTO clinics_devices (clinic_name, email, password_hash, pc_name, machine_token, clinica_id)
|
INSERT INTO clinics_devices (clinic_name, email, password_hash, pc_name, machine_token, clinica_id, tenant_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id
|
||||||
`, [clinic_name, email, password_hash, pc_name, machine_token, clinica_id || null]);
|
`, [clinic_name, email, password_hash, pc_name, machine_token, clinica_id || null, tenant_id || null]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ router.post('/login', async (req, res) => {
|
|||||||
const sessionToken = jwt.sign(
|
const sessionToken = jwt.sign(
|
||||||
{
|
{
|
||||||
clinicId: device.id,
|
clinicId: device.id,
|
||||||
clinicaId: device.clinica_id, // tenant do scoreodonto
|
tenantId: device.tenant_id, // conta/dono (scoreodonto)
|
||||||
|
clinicaId: device.clinica_id, // clínica (scoreodonto)
|
||||||
clinicName: device.clinic_name,
|
clinicName: device.clinic_name,
|
||||||
pcName: device.pc_name,
|
pcName: device.pc_name,
|
||||||
role: 'client_device'
|
role: 'client_device'
|
||||||
|
|||||||
@@ -53,19 +53,24 @@ router.get('/unique-clients', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// GET /api/images/unique-clinics - Tenants (clinica_id) com contagem + rótulo
|
// 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) => {
|
router.get('/unique-clinics', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const tenantId = req.query.tenant_id || '';
|
||||||
const rows = await db.all(
|
const rows = await db.all(
|
||||||
`SELECT clinica_id,
|
`SELECT tenant_id,
|
||||||
|
clinica_id,
|
||||||
MAX(clinic_name) AS clinic_name,
|
MAX(clinic_name) AS clinic_name,
|
||||||
COUNT(*)::integer AS image_count,
|
COUNT(*)::integer AS image_count,
|
||||||
COUNT(DISTINCT patient_name)::integer AS patient_count
|
COUNT(DISTINCT patient_name)::integer AS patient_count
|
||||||
FROM images
|
FROM images
|
||||||
WHERE clinica_id IS NOT NULL AND enabled = 1
|
WHERE clinica_id IS NOT NULL AND enabled = 1
|
||||||
GROUP BY clinica_id
|
AND ($1 = '' OR tenant_id = $1)
|
||||||
ORDER BY clinic_name`
|
GROUP BY tenant_id, clinica_id
|
||||||
|
ORDER BY clinic_name`,
|
||||||
|
[tenantId]
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -254,13 +259,19 @@ router.get('/', async (req, res) => {
|
|||||||
const showDisabled = req.query.disabled === 'true';
|
const showDisabled = req.query.disabled === 'true';
|
||||||
const search = req.query.search || '';
|
const search = req.query.search || '';
|
||||||
const clientName = req.query.client || '';
|
const clientName = req.query.client || '';
|
||||||
const clinicaId = req.query.clinica_id || ''; // tenant do scoreodonto (isolamento)
|
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 clinicName = req.query.clinic || ''; // rótulo legível (filtro opcional)
|
||||||
|
|
||||||
let query = `SELECT * FROM images WHERE enabled = $1`;
|
let query = `SELECT * FROM images WHERE enabled = $1`;
|
||||||
let params = [showDisabled ? 0 : 1];
|
let params = [showDisabled ? 0 : 1];
|
||||||
let paramIndex = 2;
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
query += ` AND tenant_id = $${paramIndex++}`;
|
||||||
|
params.push(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
if (clinicaId) {
|
if (clinicaId) {
|
||||||
query += ` AND clinica_id = $${paramIndex++}`;
|
query += ` AND clinica_id = $${paramIndex++}`;
|
||||||
params.push(clinicaId);
|
params.push(clinicaId);
|
||||||
@@ -300,7 +311,8 @@ router.get('/patients', async (req, res) => {
|
|||||||
const showDisabled = req.query.disabled === 'true';
|
const showDisabled = req.query.disabled === 'true';
|
||||||
const search = req.query.search || '';
|
const search = req.query.search || '';
|
||||||
const clientName = req.query.client || '';
|
const clientName = req.query.client || '';
|
||||||
const clinicaId = req.query.clinica_id || ''; // tenant do scoreodonto (isolamento)
|
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 clinicName = req.query.clinic || ''; // rótulo legível (filtro opcional)
|
||||||
const enabledVal = showDisabled ? 0 : 1;
|
const enabledVal = showDisabled ? 0 : 1;
|
||||||
|
|
||||||
@@ -313,6 +325,11 @@ router.get('/patients', async (req, res) => {
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
let whereStr = `WHERE 1=1`;
|
let whereStr = `WHERE 1=1`;
|
||||||
|
if (tenantId) {
|
||||||
|
whereStr += ` AND tenant_id = $${paramIndex}`;
|
||||||
|
params.push(tenantId);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
if (clinicaId) {
|
if (clinicaId) {
|
||||||
whereStr += ` AND clinica_id = $${paramIndex}`;
|
whereStr += ` AND clinica_id = $${paramIndex}`;
|
||||||
params.push(clinicaId);
|
params.push(clinicaId);
|
||||||
@@ -398,7 +415,8 @@ router.get('/by-patient', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const patientName = req.query.name || '';
|
const patientName = req.query.name || '';
|
||||||
const showDisabled = req.query.disabled === 'true';
|
const showDisabled = req.query.disabled === 'true';
|
||||||
const clinicaId = req.query.clinica_id || ''; // tenant do scoreodonto (isolamento)
|
const tenantId = req.query.tenant_id || ''; // conta/dono (isolamento por conta)
|
||||||
|
const clinicaId = req.query.clinica_id || ''; // clínica (isolamento por unidade)
|
||||||
|
|
||||||
if (!patientName) {
|
if (!patientName) {
|
||||||
return res.status(400).json({ error: 'Parâmetro name obrigatório' });
|
return res.status(400).json({ error: 'Parâmetro name obrigatório' });
|
||||||
@@ -406,6 +424,10 @@ router.get('/by-patient', async (req, res) => {
|
|||||||
|
|
||||||
let sql = `SELECT * FROM images WHERE patient_name = $1 AND enabled = $2`;
|
let sql = `SELECT * FROM images WHERE patient_name = $1 AND enabled = $2`;
|
||||||
const params = [patientName, showDisabled ? 0 : 1];
|
const params = [patientName, showDisabled ? 0 : 1];
|
||||||
|
if (tenantId) {
|
||||||
|
sql += ` AND tenant_id = $${params.length + 1}`;
|
||||||
|
params.push(tenantId);
|
||||||
|
}
|
||||||
if (clinicaId) {
|
if (clinicaId) {
|
||||||
sql += ` AND clinica_id = $${params.length + 1}`;
|
sql += ` AND clinica_id = $${params.length + 1}`;
|
||||||
params.push(clinicaId);
|
params.push(clinicaId);
|
||||||
|
|||||||
+11
-5
@@ -896,7 +896,7 @@ io.use(async (socket, next) => {
|
|||||||
// machine_token válido (com ou sem email+senha)
|
// machine_token válido (com ou sem email+senha)
|
||||||
console.log(`✅ [machine_token] Conexão autorizada — Clínica: ${device.clinic_name} PC: ${device.pc_name}`);
|
console.log(`✅ [machine_token] Conexão autorizada — Clínica: ${device.clinic_name} PC: ${device.pc_name}`);
|
||||||
socket.clientType = 'sensor';
|
socket.clientType = 'sensor';
|
||||||
socket.user = { role: 'client_device', clinicId: device.id, clinicaId: device.clinica_id, clinicName: device.clinic_name, pcName: device.pc_name };
|
socket.user = { role: 'client_device', clinicId: device.id, tenantId: device.tenant_id, clinicaId: device.clinica_id, clinicName: device.clinic_name, pcName: device.pc_name };
|
||||||
await db.run(`UPDATE clinics_devices SET last_ip = $1 WHERE id = $2`, [socket.handshake.address, device.id]);
|
await db.run(`UPDATE clinics_devices SET last_ip = $1 WHERE id = $2`, [socket.handshake.address, device.id]);
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -1117,7 +1117,8 @@ io.on('connection', (socket) => {
|
|||||||
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
|
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
|
||||||
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
|
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
|
||||||
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
|
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
|
||||||
const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // tenant do scoreodonto
|
const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // clínica (scoreodonto)
|
||||||
|
const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto)
|
||||||
const patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido';
|
const patientName = (data.patientData && data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido';
|
||||||
|
|
||||||
// Salvar imagem original local e na nuvem
|
// Salvar imagem original local e na nuvem
|
||||||
@@ -1151,6 +1152,7 @@ io.on('connection', (socket) => {
|
|||||||
patient_name,
|
patient_name,
|
||||||
clinic_name,
|
clinic_name,
|
||||||
clinica_id,
|
clinica_id,
|
||||||
|
tenant_id,
|
||||||
client_name,
|
client_name,
|
||||||
original_filename,
|
original_filename,
|
||||||
filename,
|
filename,
|
||||||
@@ -1159,12 +1161,13 @@ io.on('connection', (socket) => {
|
|||||||
remark,
|
remark,
|
||||||
thumb_filename,
|
thumb_filename,
|
||||||
created_at
|
created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`,
|
||||||
[
|
[
|
||||||
data.metadata.guid,
|
data.metadata.guid,
|
||||||
(data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido',
|
(data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido',
|
||||||
clinicName,
|
clinicName,
|
||||||
clinicaId,
|
clinicaId,
|
||||||
|
tenantId,
|
||||||
clientName,
|
clientName,
|
||||||
data.metadata.fileName,
|
data.metadata.fileName,
|
||||||
filename,
|
filename,
|
||||||
@@ -1269,7 +1272,8 @@ io.on('connection', (socket) => {
|
|||||||
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
|
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
|
||||||
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
|
const clientName = clientInfo ? clientInfo.name : 'PC não identificado';
|
||||||
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
|
const clinicName = socket.user?.clinicName || clientInfo?.clinicName || 'Clínica não identificada';
|
||||||
const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // tenant do scoreodonto
|
const clinicaId = socket.user?.clinicaId || clientInfo?.clinicaId || null; // clínica (scoreodonto)
|
||||||
|
const tenantId = socket.user?.tenantId || clientInfo?.tenantId || null; // conta/dono (scoreodonto)
|
||||||
|
|
||||||
// Determinar data de criação
|
// Determinar data de criação
|
||||||
const formatLocalDateTime = (date) => {
|
const formatLocalDateTime = (date) => {
|
||||||
@@ -1286,6 +1290,7 @@ io.on('connection', (socket) => {
|
|||||||
patient_name,
|
patient_name,
|
||||||
clinic_name,
|
clinic_name,
|
||||||
clinica_id,
|
clinica_id,
|
||||||
|
tenant_id,
|
||||||
client_name,
|
client_name,
|
||||||
original_filename,
|
original_filename,
|
||||||
filename,
|
filename,
|
||||||
@@ -1294,12 +1299,13 @@ io.on('connection', (socket) => {
|
|||||||
remark,
|
remark,
|
||||||
thumb_filename,
|
thumb_filename,
|
||||||
created_at
|
created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`,
|
||||||
[
|
[
|
||||||
data.metadata.guid,
|
data.metadata.guid,
|
||||||
(data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido',
|
(data.patientData.name && data.patientData.name.trim()) ? data.patientData.name.trim() : 'Desconhecido',
|
||||||
clinicName,
|
clinicName,
|
||||||
clinicaId,
|
clinicaId,
|
||||||
|
tenantId,
|
||||||
clientName,
|
clientName,
|
||||||
data.metadata.fileName,
|
data.metadata.fileName,
|
||||||
data.filename,
|
data.filename,
|
||||||
|
|||||||
Reference in New Issue
Block a user