feat: adicionar gerenciamento de clínicas e tokens

This commit is contained in:
VPS 4 Builder
2026-05-26 02:59:11 +02:00
parent 5e86717d12
commit 75c4bf3f87
14 changed files with 1241 additions and 2123 deletions
Binary file not shown.
+6 -5
View File
@@ -60,14 +60,15 @@ async function verifyPassword(password, hash) {
// GERAR TOKEN JWT
// ================================================================
function generateToken(user) {
function generateToken(user, expiresIn = JWT_EXPIRES_IN) {
return jwt.sign(
{
id: user.id,
username: user.username
username: user.username,
is_admin: user.is_admin
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
{ expiresIn: expiresIn }
);
}
@@ -90,8 +91,8 @@ async function createInitialUser() {
// Inserir usuário
await db.run(
`INSERT INTO users (username, email, password_hash, created_at)
VALUES (?, ?, ?, NOW())`,
`INSERT INTO users (username, email, password_hash, is_admin, created_at)
VALUES (?, ?, ?, 1, NOW())`,
['rcesar', 'rcesar@rcesar.com', passwordHash]
);
+197 -144
View File
@@ -1,4 +1,4 @@
const { Pool } = require('pg');
const mysql = require('mysql2/promise');
const fs = require('fs').promises;
const path = require('path');
@@ -14,13 +14,16 @@ function rewriteQuery(sql) {
// Replace NOW() with datetime('now')
query = query.replace(/\bNOW\(\)/gi, "datetime('now')");
return query;
}
// Rewrite ON DUPLICATE KEY UPDATE for users
if (query.includes('ON DUPLICATE KEY UPDATE')) {
query = `INSERT INTO users (username, email, password_hash, created_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(username) DO UPDATE SET
email = excluded.email,
password_hash = excluded.password_hash`;
}
// Convert MySQL ? to Postgres $1, $2
function convertSqlForPg(sql) {
let i = 1;
return sql.replace(/\?/g, () => '$' + (i++));
return query;
}
// ================================================================
@@ -41,18 +44,13 @@ async function initSQLite() {
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
clinic_name TEXT,
pc_name TEXT,
is_admin INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
console.log('✅ Tabela settings SQLite criada/verificada');
sqliteDb.exec(`
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -71,7 +69,6 @@ async function initSQLite() {
flip_vertical INTEGER DEFAULT 0,
doctor TEXT,
remark TEXT,
thumb_filename TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
@@ -100,16 +97,24 @@ async function initSQLite() {
// Migrações para adicionar colunas se o banco já existia
try {
sqliteDb.exec("ALTER TABLE images ADD COLUMN doctor TEXT");
} catch (e) {}
} catch (e) {
// Coluna já existe
}
try {
sqliteDb.exec("ALTER TABLE images ADD COLUMN remark TEXT");
} catch (e) {}
try {
sqliteDb.exec("ALTER TABLE images ADD COLUMN thumb_filename TEXT");
} catch (e) {}
} catch (e) {
// Coluna já existe
}
console.log('✅ Tabelas SQLite users e images criadas/verificadas');
// Migrações para adicionar colunas em users se o banco já existia
try { sqliteDb.exec("ALTER TABLE users ADD COLUMN clinic_name TEXT"); } catch (e) {}
try { sqliteDb.exec("ALTER TABLE users ADD COLUMN pc_name TEXT"); } catch (e) {}
try { sqliteDb.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0"); } catch (e) {}
// Criar administrador padrão se não houver usuários
try {
const stmtCount = sqliteDb.prepare('SELECT COUNT(*) as count FROM users');
const userCount = stmtCount.get().count;
@@ -118,10 +123,10 @@ async function initSQLite() {
const bcrypt = require('bcrypt');
const passwordHash = bcrypt.hashSync('admin1234', 10);
const stmtInsert = sqliteDb.prepare(`
INSERT INTO users (username, email, password_hash)
VALUES (?, ?, ?)
INSERT INTO users (username, email, password_hash, clinic_name, pc_name, is_admin)
VALUES (?, ?, ?, ?, ?, 1)
`);
stmtInsert.run('admin', 'admin@dental.local', passwordHash);
stmtInsert.run('admin', 'admin@dental.local', passwordHash, 'Admin Clinic', 'Admin PC');
console.log('✅ Usuário admin padrão criado com sucesso!');
}
} catch (error) {
@@ -138,185 +143,250 @@ async function initDatabase() {
try {
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
port: process.env.DB_PORT || 3306,
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
};
const dbName = process.env.DB_NAME || 'dental_images';
const tempPool = new Pool({
// Criar pool temporário SEM database para poder criar o banco
const tempPool = mysql.createPool({
...dbConfig,
database: 'postgres',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
console.log('✅ Pool de conexões MySQL criado');
// Verificar se banco existe, se não criar
try {
const res = await tempPool.query(`SELECT 1 FROM pg_database WHERE datname = $1`, [dbName]);
if (res.rowCount === 0) {
const [databases] = await tempPool.query(
`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`,
[dbName]
);
if (databases.length === 0) {
console.log(`⚠️ Banco ${dbName} não encontrado. Criando...`);
await tempPool.query(`CREATE DATABASE "${dbName}"`);
await tempPool.query(`CREATE DATABASE ${dbName} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
console.log(`✅ Banco ${dbName} criado!`);
} else {
console.log(`✅ Banco ${dbName} já existe`);
}
} catch (error) {
console.error('❌ Erro ao verificar/criar banco no PostgreSQL:', error.message);
} finally {
await tempPool.end();
console.error('❌ Erro ao verificar/criar banco:', error);
throw error;
}
pool = new Pool({
// Fechar pool temporário
await tempPool.end();
// Criar pool definitivo COM database
pool = mysql.createPool({
...dbConfig,
database: dbName,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0
});
const client = await pool.connect();
console.log('✅ Conectado ao PostgreSQL');
client.release();
// Testar conexão
const connection = await pool.getConnection();
console.log('✅ Conectado ao MySQL');
connection.release();
// Criar tabelas
await createTables();
} catch (error) {
console.error('❌ Erro ao conectar ao PostgreSQL:', error.message);
console.error('❌ Erro ao conectar ao MySQL:', error.message);
console.error('Verifique:');
console.error('1. MySQL está rodando? (sudo systemctl status mysql)');
console.error('2. Credenciais corretas no .env?');
console.error('3. Usuário tem permissão para criar databases?');
throw error;
}
}
// ================================================================
// CRIAR TABELAS POSTGRESQL
// CRIAR TABELAS MYSQL
// ================================================================
async function createTables() {
const client = await pool.connect();
const connection = await pool.getConnection();
try {
// Tabela de usuários
await client.query(`
await connection.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
clinic_name VARCHAR(100) NULL,
pc_name VARCHAR(50) NULL,
is_admin TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// Tabela de configurações
await client.query(`
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
)
`);
console.log('✅ Tabela settings PostgreSQL criada/verificada');
console.log('✅ Tabela users criada/verificada');
// Tabela de imagens
await client.query(`
await connection.query(`
CREATE TABLE IF NOT EXISTS images (
id SERIAL PRIMARY KEY,
id INT AUTO_INCREMENT PRIMARY KEY,
image_guid VARCHAR(255) NOT NULL,
patient_name TEXT,
client_name VARCHAR(100),
original_filename TEXT,
filename TEXT NOT NULL,
enabled SMALLINT DEFAULT 1,
enabled TINYINT(1) DEFAULT 1,
tooth_number VARCHAR(10),
tooth_side VARCHAR(10),
patient_name_resumed VARCHAR(50),
original_image_id INT,
rotation INT DEFAULT 0,
flip_horizontal SMALLINT DEFAULT 0,
flip_vertical SMALLINT DEFAULT 0,
flip_horizontal INT DEFAULT 0,
flip_vertical INT DEFAULT 0,
doctor VARCHAR(255) NULL,
remark TEXT NULL,
thumb_filename VARCHAR(500) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_images_enabled (enabled),
INDEX idx_images_original (original_image_id),
INDEX idx_patient_name (patient_name(100)),
INDEX idx_image_guid (image_guid),
INDEX idx_client_name (client_name),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_images_enabled ON images (enabled)`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_images_original ON images (original_image_id)`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_patient_name ON images (patient_name)`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_image_guid ON images (image_guid)`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_client_name ON images (client_name)`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_created_at ON images (created_at)`);
console.log('✅ Tabela images criada/verificada');
// Tabela GTOs
await client.query(`
await connection.query(`
CREATE TABLE IF NOT EXISTS gtos (
id SERIAL PRIMARY KEY,
id INT AUTO_INCREMENT PRIMARY KEY,
gto_number VARCHAR(100) NOT NULL,
patient_name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_gtos_patient (patient_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_gtos_patient ON gtos (patient_name)`);
console.log('✅ Tabela gtos criada/verificada');
// Tabela de vínculo GTO-Images
await client.query(`
await connection.query(`
CREATE TABLE IF NOT EXISTS gto_images (
id SERIAL PRIMARY KEY,
id INT AUTO_INCREMENT PRIMARY KEY,
gto_id INT NOT NULL,
image_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (gto_id) REFERENCES gtos(id) ON DELETE CASCADE,
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
)
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
UNIQUE INDEX idx_gto_image (gto_id, image_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_gto_image ON gto_images (gto_id, image_id)`);
console.log('✅ Tabela gto_images criada/verificada');
// Verificar e adicionar colunas se não existirem (migração)
try {
await client.query(`ALTER TABLE images ADD COLUMN client_name VARCHAR(100)`);
await client.query(`CREATE INDEX idx_client_name ON images (client_name)`);
console.log('✅ Coluna client_name adicionada');
} catch (e) { /* já existe */ }
const columns = await connection.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'images'
AND COLUMN_NAME = 'client_name'
`);
try {
await client.query(`ALTER TABLE images ADD COLUMN doctor VARCHAR(255)`);
} catch (e) { /* já existe */ }
try {
await client.query(`ALTER TABLE images ADD COLUMN remark TEXT`);
} catch (e) { /* já existe */ }
try {
await client.query(`ALTER TABLE images ADD COLUMN thumb_filename VARCHAR(500)`);
console.log('✅ Coluna thumb_filename adicionada');
} catch (e) { /* já existe */ }
// Inserir usuários padrões se a tabela estiver vazia
try {
const userCountRes = await client.query('SELECT COUNT(*) as count FROM users');
const userCount = parseInt(userCountRes.rows[0].count, 10);
if (userCount === 0) {
console.log('⚠️ Nenhum usuário encontrado no PostgreSQL. Criando usuários padrões...');
const bcrypt = require('bcrypt');
const hash1 = await bcrypt.hash('Rc362514', 10);
const hash2 = await bcrypt.hash('admin1234', 10);
await client.query(
`INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)`,
['rcesar', 'rcesar@rcesar.com', hash1]
);
await client.query(
`INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)`,
['admin', 'admin@dental.local', hash2]
);
console.log('✅ Usuários padrões (rcesar e admin) criados com sucesso no PostgreSQL!');
if (columns[0].length === 0) {
console.log('⚠️ Coluna client_name não encontrada. Adicionando...');
await connection.query(`
ALTER TABLE images
ADD COLUMN client_name VARCHAR(100) NULL
AFTER patient_name
`);
await connection.query(`
ALTER TABLE images
ADD INDEX idx_client_name (client_name)
`);
console.log('✅ Coluna client_name adicionada');
}
} catch (e) {
console.error('⚠️ Erro ao inicializar usuários padrões no PostgreSQL:', e.message);
} catch (error) {
console.warn('⚠️ Erro ao verificar/adicionar coluna client_name:', error.message);
}
// Migração para coluna doctor
try {
const columnsDoctor = await connection.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'images'
AND COLUMN_NAME = 'doctor'
`);
if (columnsDoctor[0].length === 0) {
console.log('⚠️ Coluna doctor não encontrada. Adicionando...');
await connection.query(`
ALTER TABLE images
ADD COLUMN doctor VARCHAR(255) NULL
AFTER client_name
`);
}
} catch (error) {
console.warn('⚠️ Erro ao verificar/adicionar coluna doctor:', error.message);
}
// Migração para coluna remark
try {
const columnsRemark = await connection.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'images'
AND COLUMN_NAME = 'remark'
`);
if (columnsRemark[0].length === 0) {
console.log('⚠️ Coluna remark não encontrada. Adicionando...');
await connection.query(`
ALTER TABLE images
ADD COLUMN remark TEXT NULL
AFTER doctor
`);
}
} catch (error) {
console.warn('⚠️ Erro ao verificar/adicionar coluna remark:', error.message);
}
// Migração para colunas da tabela users (clinic_name, pc_name, is_admin)
try {
const columnsUsers = await connection.query(`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'users'
AND COLUMN_NAME = 'clinic_name'
`);
if (columnsUsers[0].length === 0) {
console.log('⚠️ Colunas de clínica em users não encontradas. Adicionando...');
await connection.query(`
ALTER TABLE users
ADD COLUMN clinic_name VARCHAR(100) NULL AFTER password_hash,
ADD COLUMN pc_name VARCHAR(50) NULL AFTER clinic_name,
ADD COLUMN is_admin TINYINT(1) DEFAULT 0 AFTER pc_name
`);
// O primeiro usuário (admin padrão) deve virar admin se já existe
await connection.query(`UPDATE users SET is_admin = 1 ORDER BY id ASC LIMIT 1`);
}
} catch (error) {
console.warn('⚠️ Erro ao verificar/adicionar colunas de clínica em users:', error.message);
}
} catch (error) {
console.error('Erro ao criar tabelas:', error);
} finally {
client.release();
connection.release();
}
}
@@ -339,9 +409,8 @@ function get(query, params = []) {
return new Promise(async (resolve, reject) => {
try {
const pgQuery = convertSqlForPg(query);
const res = await pool.query(pgQuery, params);
resolve(res.rows[0] || null);
const [rows] = await pool.query(query, params);
resolve(rows[0] || null);
} catch (error) {
reject(error);
}
@@ -363,9 +432,8 @@ function all(query, params = []) {
return new Promise(async (resolve, reject) => {
try {
const pgQuery = convertSqlForPg(query);
const res = await pool.query(pgQuery, params);
resolve(res.rows);
const [rows] = await pool.query(query, params);
resolve(rows);
} catch (error) {
reject(error);
}
@@ -390,25 +458,10 @@ function run(query, params = []) {
return new Promise(async (resolve, reject) => {
try {
let pgQuery = convertSqlForPg(query);
// Auto-append RETURNING id for INSERT if not present (except for settings table)
if (pgQuery.trim().toUpperCase().startsWith('INSERT') &&
!pgQuery.toUpperCase().includes('RETURNING') &&
!pgQuery.toUpperCase().includes('SETTINGS')) {
pgQuery += ' RETURNING id';
}
const res = await pool.query(pgQuery, params);
let lastID = null;
if (res.rows && res.rows.length > 0 && res.rows[0].id) {
lastID = res.rows[0].id;
}
const [result] = await pool.query(query, params);
resolve({
lastID: lastID,
changes: res.rowCount
lastID: result.insertId,
changes: result.affectedRows
});
} catch (error) {
reject(error);
+123 -130
View File
@@ -16,7 +16,7 @@
"express": "^4.18.2",
"express-session": "^1.17.3",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"mysql2": "^3.6.0",
"sharp": "^0.32.6",
"socket.io": "^4.7.2"
},
@@ -157,6 +157,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmmirror.com/b4a/-/b4a-1.7.3.tgz",
@@ -596,6 +605,15 @@
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -991,6 +1009,15 @@
"node": ">=10"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1226,6 +1253,12 @@
"node": ">=8"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -1317,6 +1350,36 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/lru.min": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.2.tgz",
"integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz",
@@ -1501,6 +1564,54 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.15.3",
"resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -1656,122 +1767,6 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -2024,6 +2019,11 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz",
@@ -2322,12 +2322,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 10.x"
"node": ">= 0.6"
}
},
"node_modules/statuses": {
@@ -2588,14 +2589,6 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz",
+2 -4
View File
@@ -19,15 +19,13 @@
"socket.io": "^4.7.2",
"express": "^4.18.2",
"sharp": "^0.32.6",
"pg": "^8.11.3",
"mysql2": "^3.6.0",
"dotenv": "^16.3.1",
"cors": "^2.8.5",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"cookie-parser": "^1.4.6",
"express-session": "^1.17.3",
"@aws-sdk/client-s3": "^3.500.0",
"@aws-sdk/lib-storage": "^3.500.0"
"express-session": "^1.17.3"
},
"engines": {
"node": ">=16.0.0",
File diff suppressed because it is too large Load Diff
+468
View File
@@ -0,0 +1,468 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ScoreOdonto - Cadastro de Clínica</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0a12;
--card: rgba(18, 16, 28, 0.85);
--border: rgba(255, 255, 255, 0.08);
--primary: #7c4dff;
--primary-hover: #651fff;
--accent: #00e5ff;
--success: #00e676;
--danger: #ff1744;
--text: #f5f5f7;
--muted: #8e8e93;
--mono: 'JetBrains Mono', monospace;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
background-image:
radial-gradient(at 0% 0%, rgba(124, 77, 255, 0.2) 0px, transparent 60%),
radial-gradient(at 100% 100%, rgba(0, 229, 255, 0.12) 0px, transparent 60%);
background-attachment: fixed;
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
width: 100%;
max-width: 500px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 24px;
padding: 40px 30px;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
transition: all 0.4s ease;
}
.header {
text-align: center;
margin-bottom: 35px;
}
.logo-icon {
font-size: 2.5rem;
margin-bottom: 10px;
display: inline-block;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.header h1 {
font-size: 1.8rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, var(--muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
.header p {
font-size: 0.9rem;
color: var(--muted);
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 8px;
font-weight: 500;
}
input {
width: 100%;
background: rgba(0,0,0,0.4);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 0.95rem;
transition: all 0.3s ease;
}
input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 229, 255, 0.15);
background: rgba(0,0,0,0.5);
}
.btn {
width: 100%;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
border: none;
border-radius: 12px;
padding: 16px;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 20px rgba(124, 77, 255, 0.3);
margin-top: 10px;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(124, 77, 255, 0.4);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
background: var(--muted);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ---- SUCCESS SCREEN ---- */
.hidden {
display: none !important;
}
.success-header {
text-align: center;
margin-bottom: 25px;
}
.success-badge {
width: 60px;
height: 60px;
background: rgba(0, 230, 118, 0.1);
border: 1px solid var(--success);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
margin: 0 auto 15px auto;
color: var(--success);
box-shadow: 0 0 20px rgba(0, 230, 118, 0.2);
}
.success-header h2 {
font-size: 1.5rem;
color: #fff;
margin-bottom: 8px;
}
.success-instructions {
background: rgba(124, 77, 255, 0.05);
border: 1px dashed rgba(124, 77, 255, 0.2);
padding: 15px;
border-radius: 12px;
font-size: 0.85rem;
color: var(--text);
margin-bottom: 25px;
line-height: 1.4;
}
.copy-card {
background: rgba(0,0,0,0.5);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
margin-bottom: 20px;
}
.copy-item {
margin-bottom: 15px;
}
.copy-item:last-child {
margin-bottom: 0;
}
.copy-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.copy-label {
font-size: 0.75rem;
color: var(--muted);
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.copy-btn {
background: none;
border: none;
color: var(--accent);
font-size: 0.8rem;
cursor: pointer;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
transition: background 0.2s;
}
.copy-btn:hover {
background: rgba(0, 229, 255, 0.1);
}
.copy-val {
font-family: var(--mono);
font-size: 0.9rem;
color: #fff;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
padding: 8px 12px;
border-radius: 8px;
word-break: break-all;
display: block;
}
.btn-copy-all {
background: linear-gradient(135deg, #00e5ff 0%, #00b0ff 100%);
color: #0b0a12;
box-shadow: 0 8px 20px rgba(0, 229, 255, 0.25);
}
.btn-copy-all:hover {
box-shadow: 0 12px 24px rgba(0, 229, 255, 0.35);
}
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: #fff;
color: #0b0a12;
padding: 12px 24px;
border-radius: 50px;
font-size: 0.9rem;
font-weight: 600;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
z-index: 1000;
}
.toast.show {
transform: translateX(-50%) translateY(0);
}
.alert-error {
background: rgba(255, 23, 68, 0.1);
border: 1px solid var(--danger);
color: #ff5252;
padding: 12px 16px;
border-radius: 12px;
font-size: 0.9rem;
margin-bottom: 20px;
display: none;
}
</style>
</head>
<body>
<div id="toast" class="toast">Texto copiado!</div>
<div class="container">
<!-- 1. REGISTRATION FORM PANEL -->
<div id="panel-register">
<header class="header">
<span class="logo-icon">🦷</span>
<h1>Cadastrar Clínica</h1>
<p>Crie o acesso para obter o token de conexão do notebook</p>
</header>
<div id="error-alert" class="alert-error">Erro ao cadastrar. Tente novamente.</div>
<form id="register-form">
<div class="form-group">
<label for="clinicName">🏥 Nome da Clínica</label>
<input type="text" id="clinicName" placeholder="Ex: Clínica Score Dente" required autocomplete="off">
</div>
<div class="form-group">
<label for="email">📧 E-mail de Conexão</label>
<input type="email" id="email" placeholder="Ex: conexao@clinica.com" required autocomplete="off">
</div>
<div class="form-group">
<label for="pcName">🖥️ Nome do Computador (PC)</label>
<input type="text" id="pcName" placeholder="Ex: NOTEBOOK-CONSULTORIO" required autocomplete="off">
</div>
<div class="form-group">
<label for="password">🔑 Senha de Conexão</label>
<input type="password" id="password" placeholder="Crie uma senha de acesso" required>
</div>
<button type="submit" id="btn-submit" class="btn">🚀 Cadastrar e Gerar Token</button>
</form>
</div>
<!-- 2. SUCCESS DATA PANEL -->
<div id="panel-success" class="hidden">
<div class="success-header">
<div class="success-badge"></div>
<h2>Clínica Registrada!</h2>
<p>Guarde e insira os dados abaixo no instalador Windows</p>
</div>
<div class="success-instructions">
Abra a tela de <strong>Configurações do Dental Client</strong> no computador local, e cole os campos abaixo exatamente como exibidos.
</div>
<div class="copy-card">
<div class="copy-item">
<div class="copy-item-header">
<span class="copy-label">E-mail do Servidor</span>
<button class="copy-btn" onclick="copyText('val-email')">Copiar</button>
</div>
<span id="val-email" class="copy-val">teste@clinica.com</span>
</div>
<div class="copy-item">
<div class="copy-item-header">
<span class="copy-label">Senha do Servidor</span>
<button class="copy-btn" onclick="copyText('val-password')">Copiar</button>
</div>
<span id="val-password" class="copy-val">••••••••</span>
</div>
<div class="copy-item">
<div class="copy-item-header">
<span class="copy-label">Token do Servidor</span>
<button class="copy-btn" onclick="copyText('val-token')">Copiar</button>
</div>
<span id="val-token" class="copy-val">token_gerado_no_servidor</span>
</div>
</div>
<button class="btn btn-copy-all" onclick="copyAllData()">📋 Copiar Todos os Dados</button>
<button class="btn btn-outline" style="background:transparent; border: 1px solid var(--border); margin-top:10px;" onclick="resetForm()">Cadastrar Outra Clínica</button>
</div>
</div>
<script>
const registerForm = document.getElementById('register-form');
const btnSubmit = document.getElementById('btn-submit');
const errorAlert = document.getElementById('error-alert');
const panelRegister = document.getElementById('panel-register');
const panelSuccess = document.getElementById('panel-success');
// Form fields values stored temporarily for display/copy
let registeredEmail = '';
let registeredPassword = '';
let registeredToken = '';
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const clinicName = document.getElementById('clinicName').value.trim();
const email = document.getElementById('email').value.trim();
const pcName = document.getElementById('pcName').value.trim();
const password = document.getElementById('password').value;
btnSubmit.disabled = true;
btnSubmit.textContent = 'Processando...';
errorAlert.style.display = 'none';
try {
const response = await fetch('/api/auth/register-clinic', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ clinicName, email, pcName, password })
});
const data = await response.json();
if (response.ok && data.success) {
// Save for displays
registeredEmail = data.clinic.email;
registeredPassword = password; // Raw password from form
registeredToken = data.clinic.token;
// Populate success display values
document.getElementById('val-email').textContent = registeredEmail;
document.getElementById('val-password').textContent = registeredPassword;
document.getElementById('val-token').textContent = registeredToken;
// Transition panels
panelRegister.classList.add('hidden');
panelSuccess.classList.remove('hidden');
showToast('Cadastro concluído com sucesso!');
} else {
errorAlert.textContent = data.error || 'Erro ao realizar o cadastro';
errorAlert.style.display = 'block';
}
} catch (err) {
errorAlert.textContent = 'Erro de conexão com o servidor: ' + err.message;
errorAlert.style.display = 'block';
} finally {
btnSubmit.disabled = false;
btnSubmit.textContent = '🚀 Cadastrar e Gerar Token';
}
});
function copyText(elementId) {
const val = document.getElementById(elementId).textContent;
navigator.clipboard.writeText(val)
.then(() => showToast('Copiado para a área de transferência!'))
.catch(() => showToast('Erro ao copiar'));
}
function copyAllData() {
const allText = `ScoreOdonto Dental Client - Credenciais de Conexão\\n\\n` +
`E-mail do Servidor: \${registeredEmail}\\n` +
`Senha do Servidor : \${registeredPassword}\\n` +
`Token do Servidor : \${registeredToken}\\n`;
navigator.clipboard.writeText(allText)
.then(() => showToast('Todos os dados copiados com sucesso!'))
.catch(() => showToast('Erro ao copiar'));
}
function resetForm() {
registerForm.reset();
panelSuccess.classList.add('hidden');
panelRegister.classList.remove('hidden');
}
</script>
</body>
</html>
+65 -179
View File
@@ -6,7 +6,7 @@
<title>Gerenciador de Imagens Dentais</title>
<meta name="description" content="Sistema de gerenciamento de imagens de raio-X dental">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css?v=2.7">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-container">
@@ -19,33 +19,18 @@
<a href="/" class="nav-item active">
<span class="icon">🖼️</span> Pacientes
</a>
<!-- Menu Accordion para Sistemas Clientes -->
<div class="nav-item" id="clientsAccordionToggle" onclick="toggleClientsSubmenu()" style="cursor:pointer; display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 10px; overflow: hidden;">
<span class="icon">🏢</span> <span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Sistemas Clientes</span>
</div>
<span id="clientsChevron" style="font-size: 0.8rem; transition: transform 0.3s;"></span>
</div>
<!-- Submenu dos Clientes -->
<div id="clientsSubmenu" class="sidebar-submenu" style="display: none; padding-left: 10px; background: rgba(0,0,0,0.02);"></div>
<a href="#" class="nav-item" onclick="openCreatePatientModal(); return false;">
<span class="icon"></span> Novo Paciente
</a>
<a href="/clients" class="nav-item">
<span class="icon">🖥️</span> Dados do Cliente
<span class="icon">🖥️</span> Dispositivos
</a>
<a href="#" class="nav-item admin-only" id="navAdminClinics" style="display: none;" onclick="showAdminClinics(); return false;">
<span class="icon">🏢</span> Gerenciar Clínicas
</a>
<a href="#" class="nav-item" onclick="openSettingsModal(); return false;">
<span class="icon">⚙️</span> Configurações
</a>
<a href="#" class="nav-item" onclick="openPluginsModal(); return false;">
<span class="icon">🔌</span> Plugins / Wasabi
</a>
<a href="#" class="nav-item" onclick="openSyncModal(); return false;">
<span class="icon">🔄</span> Sincronização
</a>
<a href="/reset" class="nav-item" style="color: #dc3545;">
<span class="icon">⚠️</span> Zerar Sistema
</a>
@@ -68,6 +53,9 @@
<h1 id="mainTitle">Galeria de Imagens</h1>
</div>
<div class="header-actions">
<select id="clientFilter" class="client-filter">
<option value="">📋 Todos os Clientes</option>
</select>
<input
type="search"
id="searchInput"
@@ -105,6 +93,30 @@
<p>As imagens aparecerão aqui quando forem enviadas pelo cliente Windows</p>
</div>
<!-- Admin Clínicas State -->
<div id="adminClinicsState" style="display: none; padding: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>🏢 Gerenciamento de Clínicas</h2>
<button class="btn btn-primary" onclick="openCreateClinicModal()">+ Nova Clínica</button>
</div>
<div class="table-responsive" style="background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); overflow: hidden;">
<table class="data-table" id="clinicsTable" style="width: 100%; border-collapse: collapse; text-align: left;">
<thead style="background: #f8f9fa; border-bottom: 1px solid #eee;">
<tr>
<th style="padding: 15px;">ID</th>
<th style="padding: 15px;">Login (Username)</th>
<th style="padding: 15px;">Clínica</th>
<th style="padding: 15px;">Email</th>
<th style="padding: 15px;">Nome PC</th>
<th style="padding: 15px; text-align: center;">Ações</th>
</tr>
</thead>
<tbody id="clinicsTableBody">
</tbody>
</table>
</div>
</div>
<!-- Images / Patients Grid -->
<div id="imagesGrid" class="images-grid"> </div>
@@ -137,30 +149,14 @@
<!-- View 1: Transforms -->
<div id="transformViewWrapper" style="display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%;">
<div id="transformOptions" class="transform-grid" style="flex: 1; min-height: 0;"></div>
<div id="transformActionArea" style="display: none; margin-top: 15px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0; border: 1px solid #e3e8ee;">
<textarea id="newImageRemark" class="search-input" rows="2" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;" placeholder="Nova observação (opcional)"></textarea>
<div id="transformActionArea" style="display: none; margin-top: 20px; background: #f8f9fa; padding: 15px; border-radius: 8px; flex-shrink: 0;">
<label style="font-weight: 600; display: block; margin-bottom: 8px;">Nova Observação / Detalhes (Opcional):</label>
<textarea id="newImageRemark" class="search-input" rows="2" style="width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 6px;" placeholder="Ex: Raio-X Dente 45..."></textarea>
<div style="display: flex; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 1.1rem;" onclick="clearTransformSelection()">Cancelar</button>
<button id="btnSaveTransform" class="btn btn-primary" style="flex: 2; padding: 12px; font-size: 1.1rem;" onclick="saveSelectedTransform()">Salvar Nova Imagem</button>
</div>
</div>
<!-- Bloco de Ajuste Fino (Integrado na legenda do card selecionado) -->
<div id="fineTuneContainer" style="display: none;" onclick="event.stopPropagation()">
<div class="fine-tune-left-btns">
<button type="button" onclick="adjustFineTune(-5)" title="Rotacionar -5°">-5°</button>
<button type="button" onclick="adjustFineTune(-1)" title="Rotacionar -1°">-1°</button>
</div>
<div id="fineTuneLabelPlaceholder"></div>
<div class="fine-tune-right-btns">
<button type="button" onclick="adjustFineTune(1)" title="Rotacionar +1°">+1°</button>
<button type="button" onclick="adjustFineTune(5)" title="Rotacionar +5°">+5°</button>
</div>
<button type="button" id="btnResetFineTune" onclick="resetFineTune()" style="display: none;" title="Resetar ajuste fino">↩️ Reset</button>
</div>
</div>
<!-- View 2: GTO List & Link -->
@@ -301,171 +297,61 @@
</div>
</div>
<!-- Plugins / Wasabi Modal -->
<div id="pluginsModal" class="modal">
<div class="modal-content">
<!-- Modal Nova Clínica -->
<div class="modal" id="createClinicModal">
<div class="modal-content" style="max-width: 500px; height: auto; border-radius: var(--radius);">
<div class="modal-header">
<h2>🔌 Configuração do Wasabi Storage</h2>
<span class="modal-close" onclick="closePluginsModal()">&times;</span>
<h2>Nova Clínica (Acesso Desktop)</h2>
<div class="modal-close" onclick="closeCreateClinicModal()">&times;</div>
</div>
<div class="modal-body">
<form id="pluginsForm" onsubmit="updatePluginsConfig(event)">
<div style="background: #f8f9fa; padding: 12px 18px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555;">
Configure as credenciais e bucket do **Wasabi S3** para habilitar o armazenamento na nuvem. Se desativado, o sistema usará o armazenamento local do servidor de forma automática.
</div>
<form id="createClinicForm" onsubmit="submitCreateClinic(event)">
<div class="form-group">
<label for="wasabiAccessKey">Access Key *</label>
<input type="text" id="wasabiAccessKey" placeholder="Ex: AIPL5M4G..." required>
<label>Login (Username) *</label>
<input type="text" id="clinicUsername" class="form-control" required placeholder="Ex: clinica_sorriso">
</div>
<div class="form-group">
<label for="wasabiSecretKey">Secret Key *</label>
<input type="password" id="wasabiSecretKey" placeholder="Sua Secret Key" required>
<label>Email *</label>
<input type="email" id="clinicEmail" class="form-control" required placeholder="Ex: contato@clinicasorriso.com">
</div>
<div class="form-group">
<label for="wasabiBucket">Bucket Ativo *</label>
<input type="text" id="wasabiBucket" placeholder="Ex: meu-bucket-rx" required>
<label>Senha de Acesso *</label>
<input type="password" id="clinicPassword" class="form-control" required placeholder="Senha para o cliente desktop">
</div>
<div style="display: flex; gap: 12px;">
<div class="form-group" style="flex: 1;">
<label for="wasabiRegion">Região</label>
<input type="text" id="wasabiRegion" placeholder="Ex: us-east-1 (padrão)">
</div>
<div class="form-group" style="flex: 1;">
<label for="wasabiEndpoint">Endpoint</label>
<input type="text" id="wasabiEndpoint" placeholder="Ex: s3.wasabisys.com">
</div>
<div class="form-group">
<label>Nome da Clínica (Opcional)</label>
<input type="text" id="clinicName" class="form-control" placeholder="Ex: Clínica Odonto Sorriso">
</div>
<div id="pluginsStatusAlert" style="display: none; padding: 10px; border-radius: 6px; font-size: 0.9rem; margin-top: 15px;"></div>
<div class="form-actions" style="margin-top: 25px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #eee; padding-top: 15px;">
<button type="button" class="btn btn-secondary" onclick="closePluginsModal()">Cancelar</button>
<button type="submit" class="btn btn-primary" id="btnSavePlugins">Salvar Configurações</button>
<div class="form-group">
<label>Nome do PC (Opcional)</label>
<input type="text" id="clinicPcName" class="form-control" placeholder="Ex: PC-Recepção">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; margin-top: 15px;">Cadastrar Clínica</button>
</form>
</div>
</div>
</div>
<!-- Sync / Upload Modal -->
<div id="syncModal" class="modal">
<div class="modal-content" style="max-width: 650px; height: auto; border-radius: var(--radius);">
<!-- Modal Mostrar Token -->
<div class="modal" id="showTokenModal">
<div class="modal-content" style="max-width: 600px; border-radius: var(--radius);">
<div class="modal-header">
<h2>🔄 Área de Sincronização</h2>
<span class="modal-close" onclick="closeSyncModal()">&times;</span>
<h2>🔑 Token de Acesso Desktop</h2>
<div class="modal-close" onclick="closeShowTokenModal()">&times;</div>
</div>
<div class="modal-body">
<div class="sync-tabs">
<button type="button" id="tabBtnDevices" class="sync-tab-btn active" onclick="switchSyncTab('devices')">💻 Dispositivos Conectados</button>
<button type="button" id="tabBtnUpload" class="sync-tab-btn" onclick="switchSyncTab('upload')">📤 Envio Manual</button>
</div>
<!-- Tab 1: Dispositivos -->
<div id="tab-devices" class="sync-tab-content active">
<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 20px; font-size: 0.95rem; color: #555; line-height: 1.5;">
Os computadores clientes com Windows instalados e ativos enviam imagens em tempo real. Você pode solicitar que eles façam uma sincronização manual forçada com o servidor enviando um sinal.
</div>
<div id="syncDevicesLoading" class="loading" style="padding: 20px 0;">
<div class="spinner" style="width: 30px; height: 30px; margin-bottom: 10px;"></div>
<p>Buscando dispositivos online...</p>
</div>
<div id="syncDevicesEmpty" style="display: none; text-align: center; padding: 30px 20px; color: #666;">
<span style="font-size: 2.5rem; display: block; margin-bottom: 10px;">📭</span>
<p>Nenhum dispositivo Windows conectado no momento.</p>
</div>
<div id="syncDeviceList" class="sync-device-list" style="display: none;">
<!-- Preenchido via Javascript -->
</div>
<div style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Fechar</button>
<button type="button" class="btn btn-primary" onclick="triggerSyncFromModal()" id="btnTriggerSyncModal">
<span class="icon">🔄</span> Enviar Sinal de Sincronização
</button>
</div>
</div>
<!-- Tab 2: Envio Manual -->
<div id="tab-upload" class="sync-tab-content">
<form id="manualUploadForm" onsubmit="handleManualUploadSubmit(event)">
<div class="form-group">
<label for="uploadPatientSelect">Paciente *</label>
<div style="display: flex; gap: 10px;">
<select id="uploadPatientSelect" onchange="toggleNewPatientFields()" required style="flex: 1;">
<option value="">-- Selecione o Paciente --</option>
<option value="NEW_PATIENT"> Novo Paciente...</option>
</select>
<button type="button" class="btn btn-secondary" onclick="refreshUploadPatients()" title="Atualizar Lista" style="width: 44px; padding: 0; display: flex; align-items: center; justify-content: center;">
🔄
</button>
</div>
</div>
<!-- Campos para Novo Paciente (ocultos por padrão) -->
<div id="newPatientFields" style="display: none; background: #f8f9fa; padding: 15px; border-radius: 8px; border: 1px solid #eee; margin-bottom: 18px; animation: slideDown 0.2s ease;">
<h4 style="margin: 0 0 12px 0; color: var(--dark-color); font-size: 0.95rem;">Dados do Novo Paciente</h4>
<div style="display: flex; gap: 12px; margin-bottom: 12px;">
<div style="flex: 1;">
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Nome *</label>
<input type="text" id="uploadNewPatientFirstName" placeholder="Ex: Maria" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
</div>
<div style="flex: 1;">
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Sobrenome *</label>
<input type="text" id="uploadNewPatientLastName" placeholder="Ex: Oliveira" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
</div>
</div>
<div>
<label style="font-size: 0.82rem; font-weight: 600; display: block; margin-bottom: 5px;">Dentista Responsável</label>
<input type="text" id="uploadNewPatientDoctor" placeholder="Nome do dentista (opcional)" style="width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;">
</div>
</div>
<div class="form-group">
<label for="uploadRemark">Observações / Detalhes (Opcional)</label>
<textarea id="uploadRemark" placeholder="Ex: Raio-X Panorâmico" rows="2" style="width: 100%; padding: 10px; border: 2px solid var(--border-color); border-radius: var(--radius-sm); font-family: inherit; font-size: 1rem; resize: vertical;"></textarea>
</div>
<!-- Zona de Drag and Drop -->
<div class="drag-drop-zone" id="uploadDragDropZone">
<div class="drag-drop-icon">📸</div>
<div class="drag-drop-text">Arraste as imagens aqui ou clique para selecionar</div>
<div class="drag-drop-subtext">Suporta arquivos JPG, JPEG e PNG (máx. 10MB por imagem)</div>
<input type="file" id="uploadFileInput" multiple accept="image/png, image/jpeg, image/jpg" style="display: none;">
</div>
<!-- Previews das Imagens Selecionadas -->
<div class="upload-previews" id="uploadPreviewsContainer"></div>
<!-- Progresso de Envio -->
<div class="upload-progress-container" id="uploadProgressContainer" style="display: none;">
<div class="progress-bar-label">
<span id="uploadProgressText">Enviando imagens...</span>
<span id="uploadProgressPercent">0%</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="uploadProgressBarFill"></div>
</div>
</div>
<div class="form-actions" style="margin-top: 25px; border-top: 1px solid #eee; padding-top: 15px; display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="btn btn-secondary" onclick="closeSyncModal()">Cancelar</button>
<button type="submit" class="btn btn-primary" id="btnSubmitManualUpload" disabled>Enviar Imagens</button>
</div>
</form>
<p style="margin-bottom: 15px; color: #555;">Copie o token abaixo e cole no cliente desktop junto com o email e a senha para autenticar esta clínica.</p>
<div style="display: flex; gap: 10px;">
<input type="text" id="clinicTokenInput" class="form-control" readonly style="flex: 1; font-family: monospace; font-size: 14px;">
<button class="btn btn-primary" onclick="copyTokenToClipboard()">📋 Copiar</button>
</div>
<p id="tokenCopySuccess" style="color: green; margin-top: 10px; display: none;">✅ Token copiado para a área de transferência!</p>
</div>
</div>
</div>
<!-- Application Script -->
<script src="/socket.io/socket.io.js"></script>
<script src="/app.js?v=2.7"></script>
<script src="/app.js"></script>
</body>
</html>
+15 -451
View File
@@ -705,14 +705,11 @@ body {
gap: 12px;
height: 100%;
align-items: stretch;
overflow: hidden;
padding: 6px;
box-sizing: border-box;
}
/* ---- HORIZONTAL (paisagem) ---- */
.transform-grid.grid-landscape {
grid-template-columns: 2fr 1.3fr 1.3fr;
grid-template-columns: 2fr 1.3fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
/* Original: col 1, 2 linhas */
@@ -721,10 +718,10 @@ body {
.transform-grid.grid-landscape .transform-option:nth-child(2) { grid-column: 2; grid-row: 1; }
/* Flip V: col 2, linha 2 (empilhado sob Flip H) */
.transform-grid.grid-landscape .transform-option:nth-child(3) { grid-column: 2; grid-row: 2; }
/* +90°: col 3, linha 1 */
.transform-grid.grid-landscape .transform-option:nth-child(4) { grid-column: 3; grid-row: 1; }
/* -90°: col 3, linha 2 (empilhado sob +90°) */
.transform-grid.grid-landscape .transform-option:nth-child(5) { grid-column: 3; grid-row: 2; }
/* +90°: col 3, 2 linhas (lado a lado com -90°) */
.transform-grid.grid-landscape .transform-option:nth-child(4) { grid-column: 3; grid-row: 1 / span 2; }
/* -90°: col 4, 2 linhas (lado a lado com +90°) */
.transform-grid.grid-landscape .transform-option:nth-child(5) { grid-column: 4; grid-row: 1 / span 2; }
/* ---- VERTICAL (retrato) ---- */
.transform-grid.grid-portrait {
@@ -757,9 +754,10 @@ body {
grid-column: auto !important;
grid-row: auto !important;
}
.selection-mode .transform-image-wrapper {
.selection-mode .transform-option img {
height: 35vh !important;
max-height: 420px;
max-height: 400px;
object-fit: contain;
}
/* -90°: col 4, linha 2 (empilhado sob +90°) */
.transform-grid.grid-portrait .transform-option:nth-child(5) { grid-column: 4; grid-row: 2; }
@@ -788,7 +786,6 @@ body {
align-items: center;
min-height: 0;
overflow: hidden;
position: relative; /* Para posicionar o ajuste fino absoluto */
}
.transform-option:hover {
@@ -801,38 +798,13 @@ body {
background: rgba(81, 207, 102, 0.08);
}
/* Novo Wrapper para a Imagem */
.transform-image-wrapper {
position: relative;
width: 100%;
height: 20vh;
min-height: 160px;
max-height: 320px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 4px;
background: #000000;
margin-bottom: 10px;
}
.transform-image-wrapper img {
width: 100%;
height: 100%;
.transform-option img {
max-width: 100%;
flex: 1;
min-height: 0;
object-fit: contain;
border-radius: 4px;
transition: transform 0.15s ease-out; /* Transição para ajuste fino suave */
}
/* Evita cortes nas rotações de 90° e -90° causados pelo comportamento de rotação 2D do CSS */
.transform-option:nth-child(4) .transform-image-wrapper img,
.transform-option:nth-child(5) .transform-image-wrapper img {
max-width: 100% !important;
max-height: 100% !important;
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
margin-bottom: 10px;
}
.transform-name {
@@ -842,99 +814,6 @@ body {
flex-shrink: 0;
}
/* Nova Barra Inferior de Ajuste Fino para o Card Selecionado */
#fineTuneContainer {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: 20px;
border: 1px solid rgba(0, 0, 0, 0.08);
box-sizing: border-box;
margin-top: 10px;
flex-shrink: 0;
}
.fine-tune-left-btns,
.fine-tune-right-btns {
display: flex;
gap: 4px;
}
#fineTuneContainer button {
background: #ffffff;
border: 1px solid #ced4da;
border-radius: 12px;
padding: 4px 8px;
font-size: 0.75rem;
font-weight: 700;
color: #495057;
cursor: pointer;
transition: all 0.15s ease;
min-width: 32px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
#fineTuneContainer button:hover {
background: #e9ecef;
border-color: #adb5bd;
color: #212529;
}
#fineTuneContainer button:active {
transform: scale(0.95);
}
/* Centralização do Título/Legenda */
#fineTuneLabelPlaceholder {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 0 5px;
min-width: 0; /* Permite truncar se necessário */
}
#fineTuneLabelPlaceholder .transform-name {
margin-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.85rem;
}
/* Botão Reset Flutuante no Canto Superior Direito do Card */
#btnResetFineTune {
position: absolute !important;
top: 10px;
right: 10px;
background: rgba(229, 62, 62, 0.9) !important;
border: 1px solid #e53e3e !important;
color: #ffffff !important;
border-radius: 50% !important;
width: 28px !important;
height: 28px !important;
padding: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 0.7rem !important;
cursor: pointer;
z-index: 100;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
}
#btnResetFineTune:hover {
background: #c53030 !important;
border-color: #c53030 !important;
transform: scale(1.05);
}
/* ================================================================
@@ -1057,11 +936,7 @@ body {
}
.sidebar-header {
height: 70px !important;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 20px !important;
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.15);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
@@ -1129,11 +1004,6 @@ body {
}
.main-content .header {
height: 70px !important;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 20px !important;
border-radius: 0;
margin: 0;
border-bottom: 1px solid rgba(255,255,255,0.2);
@@ -1153,14 +1023,7 @@ body {
.app-container .container { display: none; }
/* Header padding */
.main-content .header .header-content {
padding: 0 !important;
width: 100%;
margin: 0;
flex-direction: row !important;
justify-content: space-between !important;
align-items: center !important;
}
.header-content { padding: 14px 20px; }
/* ---------------------------------------------------------------
IMAGES GRID — grid puro sem flex tricks
@@ -1209,302 +1072,3 @@ body {
#toggleDisabledBtn { font-size: 0; padding: 10px; }
#toggleDisabledBtn::before { content: '👁'; font-size: 1.1rem; }
}
/* ================================================================
SYNC & UPLOAD MODAL STYLES
================================================================ */
.sync-tabs {
display: flex;
border-bottom: 2px solid var(--border-color);
margin-bottom: 20px;
gap: 10px;
}
.sync-tab-btn {
padding: 12px 20px;
background: none;
border: none;
border-bottom: 3px solid transparent;
font-size: 1rem;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s ease;
}
.sync-tab-btn:hover {
color: var(--primary-color);
}
.sync-tab-btn.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.sync-tab-content {
display: none;
animation: slideDown 0.25s ease;
}
.sync-tab-content.active {
display: block;
}
/* Área de Drag & Drop */
.drag-drop-zone {
border: 3px dashed var(--border-color);
border-radius: var(--radius);
padding: 40px 20px;
text-align: center;
background: #fdfdfd;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: 20px;
}
.drag-drop-zone:hover,
.drag-drop-zone.drag-active {
border-color: var(--primary-color);
background: rgba(102, 126, 234, 0.04);
}
.drag-drop-icon {
font-size: 3rem;
margin-bottom: 12px;
}
.drag-drop-text {
font-size: 1rem;
color: var(--text-color);
font-weight: 500;
margin-bottom: 6px;
}
.drag-drop-subtext {
font-size: 0.82rem;
color: var(--text-secondary);
}
/* Preview de arquivos selecionados */
.upload-previews {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.preview-item {
position: relative;
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-remove {
position: absolute;
top: 4px;
right: 4px;
background: rgba(220, 53, 69, 0.85);
color: white;
border: none;
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.preview-remove:hover {
background: #dc3545;
}
/* Barra de Progresso */
.upload-progress-container {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
border: 1px solid var(--border-color);
}
.progress-bar-label {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 8px;
color: var(--dark-color);
}
.progress-bar-track {
background: #e2e8f0;
border-radius: 10px;
height: 8px;
overflow: hidden;
}
.progress-bar-fill {
background: linear-gradient(90deg, var(--primary-color) 0%, #764ba2 100%);
height: 100%;
width: 0%;
transition: width 0.2s ease;
}
/* Lista de dispositivos online */
.sync-device-list {
display: flex;
flex-direction: column;
gap: 12px;
margin: 20px 0;
}
.sync-device-card {
background: #f8f9fa;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
}
.sync-device-card:hover {
border-color: var(--primary-color);
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
}
.device-card-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.device-card-title {
font-weight: 700;
font-size: 0.95rem;
color: var(--dark-color);
}
.device-card-subtitle {
font-size: 0.78rem;
color: var(--text-secondary);
}
.device-card-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 700;
}
.badge-connected {
background: rgba(81, 207, 102, 0.15);
color: var(--success-color);
}
.badge-disconnected {
background: rgba(220, 53, 69, 0.1);
color: var(--danger-color);
}
/* Botão de Excluir Paciente nos cards iniciais */
.delete-patient-btn {
position: absolute;
top: 10px;
right: 10px;
background: rgba(220, 53, 69, 0.85);
color: white;
border: none;
border-radius: 50%;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 5;
transition: all 0.2s ease;
font-size: 1.05rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
}
.delete-patient-btn:hover {
background: #dc3545;
transform: scale(1.1);
}
/* ================================================================
Ajuste de Altura Uniforme para o Header da Galeria e da Sidebar (Desktop)
================================================================ */
@media (min-width: 768px) {
.sidebar-header {
height: 70px !important;
display: flex !important;
align-items: center !important;
padding: 0 20px !important;
box-sizing: border-box !important;
}
.main-content .header {
padding: 0 !important;
box-sizing: border-box !important;
}
.main-content .header .header-content {
height: 70px !important;
padding: 0 20px !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
box-sizing: border-box !important;
}
}
/* Submenu de Clientes na Sidebar */
.sidebar-submenu {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
}
.submenu-item {
display: flex;
align-items: center;
padding: 6px 15px 6px 42px; /* Alinha o texto do submenu abaixo do ícone do menu pai */
color: var(--text-color);
text-decoration: none;
font-size: 0.82rem;
font-weight: 500;
transition: all 0.2s ease;
border-left: 3px solid transparent;
cursor: pointer;
border-radius: 4px;
margin: 0 15px;
}
.submenu-item:hover {
background-color: rgba(102, 126, 234, 0.06);
color: var(--primary-color);
}
.submenu-item.active {
background-color: rgba(102, 126, 234, 0.1);
color: var(--primary-color);
border-left-color: var(--primary-color);
font-weight: 600;
}
+66 -8
View File
@@ -48,7 +48,10 @@ router.post('/login', async (req, res) => {
user: {
id: user.id,
username: user.username,
email: user.email
email: user.email,
clinic_name: user.clinic_name,
pc_name: user.pc_name,
is_admin: user.is_admin
}
});
@@ -85,7 +88,10 @@ router.get('/verify', async (req, res) => {
user: {
id: user.id,
username: user.username,
email: user.email
email: user.email,
clinic_name: user.clinic_name,
pc_name: user.pc_name,
is_admin: user.is_admin
}
});
@@ -109,13 +115,17 @@ router.post('/logout', (req, res) => {
// POST /api/auth/create-user - Criar usuário (admin only)
// ================================================================
router.post('/create-user', async (req, res) => {
router.post('/create-user', authenticateToken, async (req, res) => {
try {
const { username, email, password } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Apenas administradores podem criar usuários' });
}
const { username, email, password, clinic_name, pc_name } = req.body;
if (!username || !email || !password) {
return res.status(400).json({
error: 'Todos os campos são obrigatórios'
error: 'Username, email e senha são obrigatórios'
});
}
@@ -136,9 +146,9 @@ router.post('/create-user', async (req, res) => {
// Criar usuário
const result = await db.run(
`INSERT INTO users (username, email, password_hash, created_at)
VALUES (?, ?, ?, NOW())`,
[username, email, passwordHash]
`INSERT INTO users (username, email, password_hash, clinic_name, pc_name, created_at)
VALUES (?, ?, ?, ?, ?, NOW())`,
[username, email, passwordHash, clinic_name || null, pc_name || null]
);
res.json({
@@ -153,6 +163,54 @@ router.post('/create-user', async (req, res) => {
}
});
// ================================================================
// GERENCIAMENTO DE USUÁRIOS/CLÍNICAS (ADMIN)
// ================================================================
router.get('/users', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const users = await db.all('SELECT id, username, email, clinic_name, pc_name, is_admin, created_at FROM users ORDER BY created_at DESC');
res.json({ success: true, users });
} catch (error) {
res.status(500).json({ error: 'Erro ao buscar usuários' });
}
});
router.delete('/users/:id', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const userId = req.params.id;
if (parseInt(userId) === req.user.id) {
return res.status(400).json({ error: 'Não é possível excluir o próprio usuário admin' });
}
await db.run('DELETE FROM users WHERE id = ?', [userId]);
res.json({ success: true, message: 'Usuário excluído com sucesso' });
} catch (error) {
res.status(500).json({ error: 'Erro ao excluir usuário' });
}
});
router.post('/users/:id/token', authenticateToken, async (req, res) => {
try {
if (!req.user.is_admin) {
return res.status(403).json({ error: 'Acesso negado' });
}
const user = await db.get('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).json({ error: 'Usuário não encontrado' });
// Gera um token com duração longa para uso no desktop (ex: 3650 dias = ~10 anos)
const token = generateToken(user, '3650d');
res.json({ success: true, token });
} catch (error) {
res.status(500).json({ error: 'Erro ao gerar token' });
}
});
// ================================================================
// PUT /api/auth/update-credentials - Alterar usuário/senha
// ================================================================
+36 -154
View File
@@ -4,8 +4,6 @@ const db = require('../database');
const imageProcessor = require('../image-processor');
const path = require('path');
const fs = require('fs').promises;
const storage = require('../storage');
const sharp = require('sharp');
// ================================================================
// GET /api/images - Listar imagens
@@ -66,12 +64,12 @@ router.get('/patients', async (req, res) => {
const rows = await db.all(
`SELECT
patient_name,
MAX(doctor) AS doctor,
MAX(remark) AS remark,
MAX(client_name) AS client_name,
doctor,
remark,
client_name,
COUNT(*) AS image_count,
MAX(created_at) AS last_date,
(SELECT COALESCE(thumb_filename, filename) FROM images i2
(SELECT 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
@@ -116,107 +114,6 @@ router.get('/by-patient', async (req, res) => {
}
});
// ================================================================
// POST /api/images/upload - Fazer upload manual de imagem
// ================================================================
router.post('/upload', async (req, res) => {
try {
const { imageBase64, filename, patientName, doctor, remark } = req.body;
if (!imageBase64) {
return res.status(400).json({ error: 'imageBase64 é obrigatório' });
}
// Decodificar imagem base64
let base64Data = imageBase64;
if (base64Data.includes(';base64,')) {
base64Data = base64Data.split(';base64,')[1];
}
const imageBuffer = Buffer.from(base64Data, 'base64');
// TODO(security): Adicionado limite de 10MB para prevenir Buffer Overflow/DoS
if (imageBuffer.length > 10 * 1024 * 1024) {
return res.status(400).json({ error: 'A imagem excede o limite de tamanho permitido de 10MB.' });
}
// Validar imagem (garante integridade do arquivo através do sharp)
await imageProcessor.validateImage(imageBuffer);
// Salvar imagem
const imageGuid = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// Detectar formato
const imgMetadata = await sharp(imageBuffer).metadata();
const format = imgMetadata.format || 'png';
const ext = format === 'jpeg' ? 'jpg' : format;
const savedFilename = `${Date.now()}_${imageGuid}.${ext}`;
const clientName = 'Upload Web';
const patientVal = patientName ? patientName.trim() : 'Paciente não identificado';
await storage.saveImage(savedFilename, imageBuffer, clientName, patientVal, false);
// Gerar thumbnail WebP
let thumbFilename = null;
try {
const thumbBuffer = await sharp(imageBuffer)
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80, effort: 4 })
.toBuffer();
thumbFilename = `thumb_${Date.now()}_${imageGuid}.webp`;
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientVal, false);
console.log(`✅ Thumbnail gerado e salvo para upload manual: ${thumbFilename}`);
} catch (thumbErr) {
console.error('⚠️ Falha ao gerar thumbnail para upload manual:', thumbErr.message);
}
// Inserir no banco de dados
const formatLocalDateTime = (date) => {
const pad = (num) => String(num).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
const createdAt = formatLocalDateTime(new Date());
const result = await db.run(
`INSERT INTO images (
image_guid,
patient_name,
client_name,
original_filename,
filename,
enabled,
doctor,
remark,
thumb_filename,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
imageGuid,
patientVal,
clientName,
filename || 'upload_manual.png',
savedFilename,
1,
doctor || null,
remark || null,
thumbFilename,
createdAt
]
);
res.json({
success: true,
imageId: result.lastID,
filename: savedFilename,
message: 'Imagem enviada com sucesso!'
});
} catch (error) {
console.error('Erro no upload de imagem manual:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// GET /api/images/:id - Buscar imagem por ID
// ================================================================
@@ -267,13 +164,19 @@ router.get('/:id/transformations', async (req, res) => {
const originalId = image.original_image_id || image.id;
const originalImage = await db.get('SELECT * FROM images WHERE id = ?', [originalId]);
let originalBuffer = await storage.getImageBuffer(originalImage.filename, false);
if (!originalBuffer) {
originalBuffer = await storage.getImageBuffer(originalImage.filename, true);
}
const originalPath = path.join(__dirname, '../uploads', originalImage.filename);
let originalBuffer;
if (!originalBuffer) {
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
try {
originalBuffer = await fs.readFile(originalPath);
} catch (e) {
if (e.code === 'ENOENT') {
// Fallback for images corrupted by previous bug
const fallbackPath = path.join(__dirname, '../processed', originalImage.filename);
originalBuffer = await fs.readFile(fallbackPath);
} else {
throw e;
}
}
const transformations = [
@@ -325,13 +228,17 @@ router.post('/:id/transform', async (req, res) => {
const realOriginal = await db.get('SELECT * FROM images WHERE id = ?', [realOriginalId]);
// Processar a imagem
let originalBuffer = await storage.getImageBuffer(realOriginal.filename, false);
if (!originalBuffer) {
originalBuffer = await storage.getImageBuffer(realOriginal.filename, true);
}
if (!originalBuffer) {
return res.status(404).json({ error: 'Arquivo de imagem original não encontrado' });
const originalPath = path.join(__dirname, '../uploads', realOriginal.filename);
let originalBuffer;
try {
originalBuffer = await fs.readFile(originalPath);
} catch (e) {
if (e.code === 'ENOENT') {
const fallbackPath = path.join(__dirname, '../processed', realOriginal.filename);
originalBuffer = await fs.readFile(fallbackPath);
} else {
throw e;
}
}
let processed = await imageProcessor.rotate(originalBuffer, rotation);
@@ -340,7 +247,8 @@ router.post('/:id/transform', async (req, res) => {
// Salvar nova versão em processed
const timestamp = Date.now();
const processedFilename = `${realOriginal.image_guid}_${timestamp}.png`;
await storage.saveImage(processedFilename, processed, realOriginal.client_name, realOriginal.patient_name, true);
const processedPath = path.join(__dirname, '../processed', processedFilename);
await fs.writeFile(processedPath, processed);
// Desabilitar a imagem atual para que não fique duplicada na interface
await db.run('UPDATE images SET enabled = 0 WHERE id = ?', [req.params.id]);
@@ -431,37 +339,6 @@ router.get('/:id/download', async (req, res) => {
}
});
// ================================================================
// DELETE /api/images/by-patient - Apagar todas as imagens de um paciente
// ================================================================
router.delete('/by-patient', async (req, res) => {
try {
const patientName = req.query.name || '';
if (!patientName) {
return res.status(400).json({ error: 'Nome do paciente é obrigatório' });
}
// Buscar todas as imagens do paciente no banco de dados
const images = await db.all('SELECT filename, client_name, patient_name FROM images WHERE patient_name = ?', [patientName]);
// Apagar cada imagem do storage (Wasabi S3 + Local)
for (const img of images) {
await storage.deleteImage(img.filename, img.client_name, img.patient_name);
}
// Apagar os registros do banco de dados
await db.run('DELETE FROM images WHERE patient_name = ?', [patientName]);
res.json({
success: true,
message: `Todas as imagens do paciente "${patientName}" foram excluídas com sucesso do servidor e do Wasabi.`
});
} catch (error) {
console.error('Erro ao excluir imagens do paciente em lote:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// DELETE /api/images/:id - Apagar versão da imagem
// ================================================================
@@ -480,8 +357,13 @@ router.delete('/:id', async (req, res) => {
});
}
// Apagar arquivo do storage (Wasabi S3 + Local)
await storage.deleteImage(image.filename, image.client_name, image.patient_name);
// Apagar arquivo
const filePath = path.join(__dirname, '../processed', image.filename);
try {
await fs.unlink(filePath);
} catch (e) {
console.warn('Arquivo não encontrado para deletar:', filePath);
}
// Apagar do banco
await db.run('DELETE FROM images WHERE id = ?', [req.params.id]);
-25
View File
@@ -68,29 +68,4 @@ router.delete('/factory-reset', async (req, res) => {
}
});
// ================================================================
// GET /api/system/storage-config - Obter configurações de storage
// ================================================================
router.get('/storage-config', (req, res) => {
try {
const storage = require('../storage');
res.json(storage.getSafeConfig());
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ================================================================
// POST /api/system/storage-config - Atualizar configurações de storage
// ================================================================
router.post('/storage-config', async (req, res) => {
try {
const storage = require('../storage');
const result = await storage.updateConfig(req.body);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+24 -114
View File
@@ -5,7 +5,6 @@ const fs = require('fs').promises;
const path = require('path');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const sharp = require('sharp');
require('dotenv').config();
// Importar módulos
@@ -13,7 +12,6 @@ const imageProcessor = require('./image-processor');
const { authenticateToken, hashPassword, JWT_SECRET } = require('./auth');
const jwt = require('jsonwebtoken');
const db = require('./database');
const storage = require('./storage');
const authRoutes = require('./routes/auth');
const imageRoutes = require('./routes/images');
const patientsRouter = require('./routes/patients');
@@ -328,9 +326,9 @@ app.post('/api/install', async (req, res) => {
await db.run(
`INSERT INTO users (username, email, password_hash, created_at)
VALUES (?, ?, ?, NOW())
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash`,
ON DUPLICATE KEY UPDATE
email = VALUES(email),
password_hash = VALUES(password_hash)`,
[admin.username, admin.email, passwordHash]
);
console.log('✅ Usuário admin criado:', admin.username);
@@ -442,44 +440,8 @@ app.use('/login', express.static('auth'));
// Serve static files
app.use(express.static('public'));
app.get('/uploads/:filename', async (req, res) => {
const filename = req.params.filename;
// Enviar ETag e Cache-Control imutável
res.setHeader('ETag', `"${filename}"`);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
const clientEtag = req.headers['if-none-match'];
if (clientEtag === `"${filename}"` || clientEtag === filename || clientEtag === `W/"${filename}"`) {
return res.status(304).end();
}
// Tentar primeiro obter da pasta de uploads (imagens originais)
let buffer = await storage.getImageBuffer(filename, false);
// Se não encontrar, tentar da pasta de processed (imagens rotacionadas/transformadas)
if (!buffer) {
buffer = await storage.getImageBuffer(filename, true);
}
if (!buffer) {
return res.status(404).send('Imagem não encontrada');
}
const ext = path.extname(filename).toLowerCase();
let contentType = 'image/png';
if (ext === '.jpg' || ext === '.jpeg') {
contentType = 'image/jpeg';
} else if (ext === '.webp') {
contentType = 'image/webp';
} else if (ext === '.svg') {
contentType = 'image/svg+xml';
}
res.setHeader('Content-Type', contentType);
res.send(buffer);
});
app.use('/uploads', express.static(UPLOAD_DIR));
app.use('/uploads', express.static(PROCESSED_DIR));
// ================================================================
// MIDDLEWARE DE VERIFICAÇÃO DE INSTALAÇÃO (APENAS PARA APIs)
@@ -534,29 +496,10 @@ app.use('/api/images', imageRoutes);
app.use('/api/gtos', authenticateToken);
app.use('/api/gtos', gtosRouter);
// APIs do Sistema - requerem autenticação
app.use('/api/system', authenticateToken);
app.use('/api/system', systemRoutes);
// Endpoint para solicitar a sincronização aos dispositivos conectados
app.post('/api/system/trigger-sync', authenticateToken, (req, res) => {
try {
const windowsClients = connectedClients.filter(c => c.type === 'windows');
// Envia o evento 'sync-request' com timestamp para todos os dispositivos clientes identificados
io.emit('sync-request', { timestamp: Date.now() });
console.log(`🔄 [Sync] Sinal de sincronização enviado para ${windowsClients.length} dispositivo(s) Windows.`);
res.json({
success: true,
devicesTriggered: windowsClients.length,
message: `Sinal de sincronização enviado com sucesso para ${windowsClients.length} dispositivo(s) Windows.`
});
} catch (error) {
console.error('Erro ao solicitar sincronização:', error);
res.status(500).json({ error: error.message });
}
});
// ================================================================
// SOCKET.IO - CONEXÕES EM TEMPO REAL
// ================================================================
@@ -746,37 +689,16 @@ io.on('connection', (socket) => {
// Validar imagem
await imageProcessor.validateImage(imageBuffer);
// Detectar formato da imagem e gerar extensão adequada
const imgMetadata = await sharp(imageBuffer).metadata();
const format = imgMetadata.format || 'png';
const ext = format === 'jpeg' ? 'jpg' : format;
// Salvar imagem original
const timestamp = Date.now();
const filename = `${timestamp}_${data.metadata.guid}.${ext}`;
const filename = `${timestamp}_${data.metadata.guid}.png`;
const filePath = path.join(UPLOAD_DIR, filename);
await fs.writeFile(filePath, imageBuffer);
// Buscar nome do cliente se identificado
const clientInfo = connectedClients.find(c => c.socketId === socket.id);
const clientName = clientInfo ? clientInfo.name : 'Cliente não identificado';
const patientName = data.patientData.name || 'Paciente não identificado';
await storage.saveImage(filename, imageBuffer, clientName, patientName, false);
// Gerar e salvar thumbnail WebP
let thumbFilename = null;
try {
const thumbBuffer = await sharp(imageBuffer)
.resize({ width: 400, height: 400, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80, effort: 4 })
.toBuffer();
thumbFilename = `thumb_${timestamp}_${data.metadata.guid}.webp`;
await storage.saveImage(thumbFilename, thumbBuffer, clientName, patientName, false);
console.log(`✅ Thumbnail gerado e salvo no Wasabi: ${thumbFilename}`);
} catch (thumbErr) {
console.error('⚠️ Falha ao gerar thumbnail, prosseguindo sem ele:', thumbErr.message);
}
// Determinar data de criação
const formatLocalDateTime = (date) => {
@@ -797,9 +719,8 @@ io.on('connection', (socket) => {
enabled,
doctor,
remark,
thumb_filename,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
data.metadata.guid,
data.patientData.name || 'Paciente não identificado',
@@ -809,7 +730,6 @@ io.on('connection', (socket) => {
1,
data.patientData.doctor || null,
data.patientData.remark || null,
thumbFilename,
createdAt
]
);
@@ -841,7 +761,6 @@ io.on('connection', (socket) => {
doctor: data.patientData.doctor || null,
remark: data.patientData.remark || null,
filename: filename,
thumb_filename: thumbFilename,
created_at: createdAt
});
}
@@ -969,35 +888,26 @@ async function initDirectories() {
}
async function start() {
const fsSync = require('fs');
try {
// Inicializar diretórios
await initDirectories();
// Inicializar banco se as variáveis já existirem no ambiente
if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
// Tentar inicializar banco apenas se .env já existe
const envPath = path.join(__dirname, '.env');
const fsSync = require('fs');
if (fsSync.existsSync(envPath)) {
try {
await db.initDatabase();
console.log('✅ Banco de dados inicializado a partir do ambiente');
await storage.loadConfigFromDb();
} catch (error) {
console.log('❌ Falha ao inicializar o banco de dados:', error.message);
}
} else {
// Fallback para arquivo .env físico
const envPath = path.join(__dirname, '.env');
const fsSync = require('fs');
if (fsSync.existsSync(envPath)) {
try {
require('dotenv').config();
if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
await db.initDatabase();
console.log('✅ Banco de dados inicializado a partir do .env');
await storage.loadConfigFromDb();
}
} catch (error) {
console.log('⚠️ Banco de dados não configurado. Execute a instalação em /install');
// Recarregar .env se foi atualizado
require('dotenv').config();
// Inicializar banco de dados apenas se configurações existem
if (process.env.DB_TYPE === 'sqlite' || (process.env.DB_HOST && process.env.DB_NAME)) {
await db.initDatabase();
console.log('✅ Banco de dados inicializado');
}
} catch (error) {
console.log('⚠️ Banco de dados não configurado. Execute a instalação em /install');
}
}
+76 -88
View File
@@ -79,6 +79,11 @@ array-flatten@1.1.1:
resolved "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz"
integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
aws-ssl-profiles@^1.1.1:
version "1.1.2"
resolved "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz"
integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==
b4a@^1.6.4:
version "1.7.3"
resolved "https://registry.npmmirror.com/b4a/-/b4a-1.7.3.tgz"
@@ -371,6 +376,11 @@ delegates@^1.0.0:
resolved "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz"
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
denque@^2.1.0:
version "2.1.0"
resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz"
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
depd@~2.0.0, depd@2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz"
@@ -609,6 +619,13 @@ gauge@^3.0.0:
strip-ansi "^6.0.1"
wide-align "^1.1.2"
generate-function@^2.3.1:
version "2.3.1"
resolved "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz"
integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==
dependencies:
is-property "^1.0.2"
get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
@@ -691,6 +708,13 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
iconv-lite@^0.7.0:
version "0.7.0"
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz"
integrity sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==
dependencies:
safer-buffer ">= 2.1.2 < 3.0.0"
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz"
@@ -736,6 +760,11 @@ is-fullwidth-code-point@^3.0.0:
resolved "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-property@^1.0.2:
version "1.0.2"
resolved "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz"
integrity sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==
jsonwebtoken@^9.0.2:
version "9.0.2"
resolved "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz"
@@ -804,6 +833,21 @@ lodash.once@^4.0.0:
resolved "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz"
integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
long@^5.2.1:
version "5.3.2"
resolved "https://registry.npmmirror.com/long/-/long-5.3.2.tgz"
integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==
lru-cache@^7.14.1:
version "7.18.3"
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
lru.min@^1.0.0:
version "1.1.2"
resolved "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.2.tgz"
integrity sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==
make-dir@^3.1.0:
version "3.1.0"
resolved "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz"
@@ -915,6 +959,28 @@ ms@2.1.3:
resolved "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
mysql2@^3.6.0:
version "3.15.3"
resolved "https://registry.npmmirror.com/mysql2/-/mysql2-3.15.3.tgz"
integrity sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==
dependencies:
aws-ssl-profiles "^1.1.1"
denque "^2.1.0"
generate-function "^2.3.1"
iconv-lite "^0.7.0"
long "^5.2.1"
lru.min "^1.0.0"
named-placeholders "^1.1.3"
seq-queue "^0.0.5"
sqlstring "^2.3.2"
named-placeholders@^1.1.3:
version "1.1.3"
resolved "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.3.tgz"
integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==
dependencies:
lru-cache "^7.14.1"
napi-build-utils@^2.0.0:
version "2.0.0"
resolved "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz"
@@ -1010,84 +1076,6 @@ path-to-regexp@0.1.12:
resolved "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz"
integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==
pg-cloudflare@^1.4.0:
version "1.4.0"
resolved "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz"
integrity sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==
pg-connection-string@^2.13.0:
version "2.13.0"
resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz"
integrity sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==
pg-int8@1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz"
integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
pg-pool@^3.14.0:
version "3.14.0"
resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz"
integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==
pg-protocol@^1.14.0:
version "1.14.0"
resolved "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz"
integrity sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==
pg-types@2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz"
integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==
dependencies:
pg-int8 "1.0.1"
postgres-array "~2.0.0"
postgres-bytea "~1.0.0"
postgres-date "~1.0.4"
postgres-interval "^1.1.0"
pg@^8.11.3, pg@>=8.0:
version "8.21.0"
resolved "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz"
integrity sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==
dependencies:
pg-connection-string "^2.13.0"
pg-pool "^3.14.0"
pg-protocol "^1.14.0"
pg-types "2.2.0"
pgpass "1.0.5"
optionalDependencies:
pg-cloudflare "^1.4.0"
pgpass@1.0.5:
version "1.0.5"
resolved "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz"
integrity sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==
dependencies:
split2 "^4.1.0"
postgres-array@~2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz"
integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==
postgres-bytea@~1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz"
integrity sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==
postgres-date@~1.0.4:
version "1.0.7"
resolved "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz"
integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==
postgres-interval@^1.1.0:
version "1.2.0"
resolved "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz"
integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==
dependencies:
xtend "^4.0.0"
prebuild-install@^7.1.1:
version "7.1.3"
resolved "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz"
@@ -1180,7 +1168,7 @@ safe-buffer@^5.0.1, safe-buffer@~5.2.0, safe-buffer@5.2.1:
resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
"safer-buffer@>= 2.1.2 < 3":
"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0":
version "2.1.2"
resolved "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@@ -1214,6 +1202,11 @@ send@0.19.0:
range-parser "~1.2.1"
statuses "2.0.1"
seq-queue@^0.0.5:
version "0.0.5"
resolved "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz"
integrity sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==
serve-static@1.16.2:
version "1.16.2"
resolved "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz"
@@ -1343,10 +1336,10 @@ socket.io@^4.7.2:
socket.io-adapter "~2.5.2"
socket.io-parser "~4.2.4"
split2@^4.1.0:
version "4.2.0"
resolved "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz"
integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==
sqlstring@^2.3.2:
version "2.3.3"
resolved "https://registry.npmmirror.com/sqlstring/-/sqlstring-2.3.3.tgz"
integrity sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==
statuses@2.0.1:
version "2.0.1"
@@ -1537,11 +1530,6 @@ ws@~8.17.1:
resolved "https://registry.npmmirror.com/ws/-/ws-8.17.1.tgz"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz"
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
yallist@^4.0.0:
version "4.0.0"
resolved "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz"