Refactor database configuration and user management

- Updated .env.example to consolidate SQL Server credentials under PANEL_MSSQL_* variables.
- Removed deprecated docker-compose.postgres.yml file.
- Adjusted docker-compose.yml to utilize new SQL Server credential structure.
- Enhanced README.md with Docker build and push instructions.
- Refined database schema in schema.sql to align with new user and permission structures.
- Updated init-database.js to reflect changes in user and session table names.
- Modified user management functions in users.ts to accommodate new database schema.
- Streamlined API routes to utilize PostgreSQL for user and database management.
- Improved error handling and logging in various server routes.
This commit is contained in:
2026-04-14 14:16:11 -05:00
parent 97b67afb25
commit 0bf9c9e68d
18 changed files with 1004 additions and 748 deletions

View File

@@ -0,0 +1,335 @@
/**
* Catálogo ControlDesk en PostgreSQL (esquema a24c; DDL lo aprovisiona otra aplicación).
* Devuelve columnas con alias en español para compatibilidad con la UI existente.
*/
import { env } from '$env/dynamic/private';
import { pgPool } from './db';
function schemaName(): string {
const s = 'a24c';
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c';
return s;
}
function qNodes(): string {
const s = schemaName();
return `"${s.replace(/"/g, '""')}"."database_nodes"`;
}
function qUsers(): string {
const s = schemaName();
return `"${s.replace(/"/g, '""')}"."portal_users"`;
}
const ROW_DATABASE_NODE = `
id AS "ID",
node_subnode_key AS "NodoSubNodo",
is_active AS "Activo",
rfc AS "RFC",
legal_name AS "Nombre",
branch_name AS "Sucursal",
notification_email AS "CorreoNotificacion",
server_name AS "ServerName",
database_name AS "BDName"
`;
const ROW_PORTAL_USER = `
id AS "ID",
database_node_id AS "IDNodoSubNodo",
is_authority_client AS "ClienteAutoridad",
full_name AS "Nombre",
username AS "Usuario",
bd_shelter AS "BD_Shelter"
`;
export async function listDatabaseNodes(): Promise<any[]> {
const sql = `SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()}`;
const r = await pgPool.query(sql);
return r.rows;
}
export async function listDatabaseNodesActive(): Promise<any[]> {
const sql = `
SELECT ${ROW_DATABASE_NODE}
FROM ${qNodes()}
WHERE is_active = 1
AND database_name IS NOT NULL
AND TRIM(database_name) <> ''
ORDER BY node_subnode_key
`;
const r = await pgPool.query(sql);
return r.rows;
}
/** Nodos activos con `sql_password` para conectar a SQL Server (no exponer al cliente). */
export async function listDatabaseNodesForMssql(): Promise<any[]> {
const where = `
WHERE is_active = 1
AND database_name IS NOT NULL
AND TRIM(database_name) <> ''
ORDER BY node_subnode_key
`;
try {
const r = await pgPool.query(
`SELECT ${ROW_DATABASE_NODE}, sql_password AS "sql_password" FROM ${qNodes()} ${where}`
);
return r.rows;
} catch (e: any) {
if (e?.code === '42703') {
const r = await pgPool.query(`SELECT ${ROW_DATABASE_NODE} FROM ${qNodes()} ${where}`);
return r.rows.map((row: any) => ({ ...row, sql_password: null }));
}
throw e;
}
}
export async function listClientsCatalog(): Promise<any[]> {
const sql = `
SELECT
id AS "ID",
legal_name AS "Nombre",
node_subnode_key AS "NodoSubNodo",
notification_email AS "CorreoNotificacion",
is_active AS "Activo",
database_name AS "BDName"
FROM ${qNodes()}
`;
const r = await pgPool.query(sql);
return r.rows;
}
export async function listPortalUsers(): Promise<any[]> {
const sql = `SELECT ${ROW_PORTAL_USER} FROM ${qUsers()} ORDER BY id`;
const r = await pgPool.query(sql);
return r.rows;
}
export async function lookupNodeByNodoOrBdName(nodoName: string): Promise<any | null> {
const sql = `
SELECT
legal_name AS "Nombre",
node_subnode_key AS "NodoSubNodo",
rfc AS "RFC"
FROM ${qNodes()}
WHERE LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1::text))
OR LOWER(TRIM(database_name)) = LOWER(TRIM($1::text))
LIMIT 1
`;
const r = await pgPool.query(sql, [nodoName]);
return r.rows[0] ?? null;
}
/**
* Asocia el nombre de archivo de respaldo (sin extensión) a una fila de `database_nodes`.
* Cubre: igualdad sin mayúsculas y prefijos tipo `NODO001_full_20240414` → nodo `NODO001`.
*/
export function matchNodeRowFromBackupStem(stem: string, nodes: any[]): any | null {
const raw = String(stem ?? '').trim();
if (!raw || !nodes?.length) return null;
const key = raw.toLowerCase();
const nodoOf = (row: any) => String(row.NodoSubNodo ?? '').trim().toLowerCase();
const bdOf = (row: any) => String(row.BDName ?? '').trim().toLowerCase();
for (const row of nodes) {
const n = nodoOf(row);
const b = bdOf(row);
if (n && n === key) return row;
if (b && b === key) return row;
}
type Cand = { row: any; len: number };
const cands: Cand[] = [];
for (const row of nodes) {
const n = nodoOf(row);
const b = bdOf(row);
if (n && (key === n || key.startsWith(`${n}_`) || key.startsWith(`${n}-`))) {
cands.push({ row, len: n.length });
}
if (b && b !== n && (key === b || key.startsWith(`${b}_`) || key.startsWith(`${b}-`))) {
cands.push({ row, len: b.length });
}
}
if (!cands.length) return null;
cands.sort((a, b) => b.len - a.len);
return cands[0].row;
}
/** Datos de contacto para alertas (equivalente a la consulta previa sobre Usuarios/BasesDeDatos). */
export async function lookupAlertClientData(nodoName: string): Promise<any | null> {
const sql = `
SELECT
pu.is_authority_client AS "ClienteAutoridad",
pu.full_name AS "Nombre",
pu.username AS "Usuario",
dn.notification_email AS "CorreoNotificacion"
FROM ${qUsers()} pu
LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id
WHERE pu.username = $1
OR dn.database_name = $1
OR dn.node_subnode_key = $1
LIMIT 1
`;
const r = await pgPool.query(sql, [nodoName]);
return r.rows[0] ?? null;
}
export async function updateNodeActive(id: number, activo: boolean): Promise<void> {
await pgPool.query(`UPDATE ${qNodes()} SET is_active = $1 WHERE id = $2`, [activo ? 1 : 0, id]);
}
export async function updateNodeLegalName(id: number, nombre: string): Promise<void> {
await pgPool.query(`UPDATE ${qNodes()} SET legal_name = $1 WHERE id = $2`, [nombre, id]);
}
export async function insertDatabaseNode(row: {
nodoSubNodo: string;
rfc: string;
nombre: string;
sucursal: string;
correo: string;
serverName: string;
bdName: string;
activo: number;
}): Promise<void> {
await pgPool.query(
`
INSERT INTO ${qNodes()} (
node_subnode_key, rfc, legal_name, branch_name,
notification_email, server_name, database_name, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`,
[
row.nodoSubNodo,
row.rfc,
row.nombre,
row.sucursal,
row.correo,
row.serverName,
row.bdName,
row.activo
]
);
}
export async function updateDatabaseNode(
id: number,
row: {
nodoSubNodo: string;
rfc: string;
nombre: string;
sucursal: string;
correo: string;
serverName: string;
bdName: string;
activo: number;
}
): Promise<void> {
await pgPool.query(
`
UPDATE ${qNodes()} SET
node_subnode_key = $1,
rfc = $2,
legal_name = $3,
branch_name = $4,
notification_email = $5,
server_name = $6,
database_name = $7,
is_active = $8
WHERE id = $9
`,
[
row.nodoSubNodo,
row.rfc,
row.nombre,
row.sucursal,
row.correo,
row.serverName,
row.bdName,
row.activo,
id
]
);
}
export async function deleteDatabaseNode(id: number): Promise<void> {
await pgPool.query(`DELETE FROM ${qNodes()} WHERE id = $1`, [id]);
}
export async function insertPortalUser(row: {
databaseNodeId: number;
isAuthorityClient: number;
fullName: string;
username: string;
passwordHash: string;
bdShelter: string | null;
}): Promise<void> {
await pgPool.query(
`
INSERT INTO ${qUsers()} (
database_node_id, is_authority_client, full_name, username, password_hash, bd_shelter
) VALUES ($1, $2, $3, $4, $5, $6)
`,
[
row.databaseNodeId,
row.isAuthorityClient,
row.fullName,
row.username,
row.passwordHash,
row.bdShelter
]
);
}
export async function updatePortalUser(
id: number,
row: {
databaseNodeId: number;
isAuthorityClient: number;
fullName: string;
username: string;
bdShelter: string | null;
passwordHash?: string;
}
): Promise<void> {
if (row.passwordHash !== undefined) {
await pgPool.query(
`
UPDATE ${qUsers()} SET
database_node_id = $1,
is_authority_client = $2,
full_name = $3,
username = $4,
password_hash = $5,
bd_shelter = $6
WHERE id = $7
`,
[
row.databaseNodeId,
row.isAuthorityClient,
row.fullName,
row.username,
row.passwordHash,
row.bdShelter,
id
]
);
} else {
await pgPool.query(
`
UPDATE ${qUsers()} SET
database_node_id = $1,
is_authority_client = $2,
full_name = $3,
username = $4,
bd_shelter = $5
WHERE id = $6
`,
[row.databaseNodeId, row.isAuthorityClient, row.fullName, row.username, row.bdShelter, id]
);
}
}
export async function deletePortalUser(id: number): Promise<void> {
await pgPool.query(`DELETE FROM ${qUsers()} WHERE id = $1`, [id]);
}

View File

@@ -0,0 +1,21 @@
/**
* Tablas del panel Transmitiras en PostgreSQL (esquema a24c), alineadas con
* ~/dev/a24c/backend/api/v1/modules/dashboard (inglés).
*/
import { env } from '$env/dynamic/private';
export function dashboardSchema(): string {
const s = env.DB_CONTROLDESK_SCHEMA || 'a24c';
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(s)) return 'a24c';
return s;
}
function qTable(table: string): string {
const s = dashboardSchema().replace(/"/g, '""');
const t = table.replace(/"/g, '""');
return `"${s}"."${t}"`;
}
export const tableDashboardUsers = () => qTable('dashboard_users');
export const tableDashboardUserDbPerms = () => qTable('dashboard_user_database_permissions');
export const tableDashboardSessions = () => qTable('dashboard_sessions');

View File

@@ -1,4 +1,3 @@
import sql from 'mssql';
import pkg from 'pg';
const { Pool } = pkg;
import { env } from '$env/dynamic/private';
@@ -15,66 +14,11 @@ const pgPool = new Pool({
connectionTimeoutMillis: 5000
});
const primaryConfig: sql.config = {
user: env.DB_PRIMARY_USER,
password: env.DB_PRIMARY_PASS,
server: env.DB_PRIMARY_HOST,
database: env.DB_PRIMARY_DB,
options: {
encrypt: true,
trustServerCertificate: true
}
};
const secondaryConfig: sql.config = {
user: env.DB_SECONDARY_USER,
password: env.DB_SECONDARY_PASS,
server: env.DB_SECONDARY_HOST,
database: env.DB_SECONDARY_DB,
options: {
encrypt: true,
trustServerCertificate: true
}
};
const azureConfig: sql.config = {
user: env.DB_AZURE_USER,
password: env.DB_AZURE_PASS,
server: env.DB_AZURE_HOST,
database: env.DB_AZURE_DB,
options: {
encrypt: true,
trustServerCertificate: true
}
};
class Database {
private primaryPool: sql.ConnectionPool | null = null;
private secondaryPool: sql.ConnectionPool | null = null;
private azurePool: sql.ConnectionPool | null = null;
async getPrimary(): Promise<sql.ConnectionPool> {
if (this.primaryPool?.connected) return this.primaryPool;
this.primaryPool = await new sql.ConnectionPool(primaryConfig).connect();
return this.primaryPool;
}
async getSecondary(): Promise<sql.ConnectionPool> {
if (this.secondaryPool?.connected) return this.secondaryPool;
this.secondaryPool = await new sql.ConnectionPool(secondaryConfig).connect();
return this.secondaryPool;
}
async getAzure(): Promise<sql.ConnectionPool> {
if (this.azurePool?.connected) return this.azurePool;
this.azurePool = await new sql.ConnectionPool(azureConfig).connect();
return this.azurePool;
}
async getPostgres() {
return pgPool.connect();
}
}
export const db = new Database();
export { sql, pgPool };
export { pgPool };

View File

@@ -0,0 +1,288 @@
/**
* Conexiones SQL Server por nodo (paridad con a24c: server_name / database_name en database_nodes,
* usuario global PANEL_MSSQL_USER, contraseña por nodo sql_password o PANEL_MSSQL_PASSWORD).
*/
import sql from 'mssql';
import { env } from '$env/dynamic/private';
const MAX_POOLS = 16;
const poolMap = new Map<string, sql.ConnectionPool>();
export function adjustMssqlServerForDocker(serverName: string): string {
const docker =
String(env.PANEL_MSSQL_DOCKER || '')
.trim()
.toLowerCase() === 'true' ||
String(env.IN_DOCKER || '')
.trim()
.toLowerCase() === 'true';
if (!docker) return serverName.trim();
const parts = serverName.trim().split(',', 2);
const host = (parts[0] || '').trim().toLowerCase();
if (host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0') {
const port = parts[1]?.trim();
return port ? `host.docker.internal,${port}` : 'host.docker.internal';
}
return serverName.trim();
}
export function resolveMssqlUser(): string {
return (
String(env.PANEL_MSSQL_USER || '').trim()
);
}
/** Contraseña SQL: columna sql_password del nodo (texto plano) o variable global. */
export function resolveNodeSqlPassword(nodeSqlPassword: string | null | undefined): string {
const raw = nodeSqlPassword != null ? String(nodeSqlPassword).trim() : '';
if (raw) return raw;
return String(
env.PANEL_MSSQL_PASSWORD || ''
).trim();
}
function poolCacheKey(server: string, user: string, password: string): string {
return `${server}\t${user}\t${password}`;
}
async function evictPoolIfNeeded(): Promise<void> {
while (poolMap.size >= MAX_POOLS) {
const first = poolMap.keys().next().value as string | undefined;
if (!first) break;
const old = poolMap.get(first);
poolMap.delete(first);
try {
await old?.close();
} catch {
/* ignore */
}
}
}
/**
* Pool conectado a `master` en el servidor del nodo (permite consultar cualquier BD con nombre de tres partes).
*/
export async function getMssqlPoolMaster(serverHost: string, password: string): Promise<sql.ConnectionPool> {
const user = resolveMssqlUser();
if (!user || !password) {
throw new Error(
'Falta PANEL_MSSQL_USER / PANEL_MSSQL_PASSWORD (o sql_password en database_nodes).'
);
}
const server = adjustMssqlServerForDocker(serverHost);
const key = poolCacheKey(server, user, password);
const existing = poolMap.get(key);
if (existing?.connected) return existing;
await evictPoolIfNeeded();
const cfg: sql.config = {
user,
password,
server,
database: 'master',
options: {
encrypt: true,
trustServerCertificate: true,
connectTimeout: 30000
}
};
const pool = await new sql.ConnectionPool(cfg).connect();
poolMap.set(key, pool);
return pool;
}
export type CatalogNodeRow = {
ID: number;
ServerName: string;
BDName: string;
NodoSubNodo: string;
Nombre: string;
Activo?: number;
sql_password?: string | null;
};
/** Métricas de una base en un servidor (conexión a master). */
export async function queryDatabaseMetricsOnServer(
pool: sql.ConnectionPool,
databaseName: string
): Promise<any | null> {
const req = pool.request();
req.input('dbname', sql.VarChar(100), databaseName);
// No unir restorehistory en el mismo SELECT que agrega mf.size: cada fila de
// restorehistory duplicaría los archivos y SUM inflaría el tamaño (p. ej. cientos de TB).
const result = await req.query(`
SELECT
d.name AS visible_name,
d.name AS original_name,
CAST((
SELECT SUM(mf2.size) * 8.0 / 1024
FROM sys.master_files mf2
WHERE mf2.database_id = d.database_id
) AS DECIMAL(10,2)) AS size_mb,
CAST((
SELECT SUM(mf2.size) * 8.0 / 1024 / 1024
FROM sys.master_files mf2
WHERE mf2.database_id = d.database_id
) AS DECIMAL(10,2)) AS total_size_gb,
(
SELECT MAX(rh.restore_date)
FROM msdb.dbo.restorehistory rh
WHERE rh.destination_database_name = d.name
) AS last_restore_date,
d.create_date,
d.state_desc,
d.recovery_model_desc
FROM sys.databases d
WHERE d.name = @dbname
`);
const row = result.recordset?.[0];
return row ?? null;
}
/** Alertas: misma regla que antes (sin restore reciente o null). */
export async function queryDatabaseAlertRow(
pool: sql.ConnectionPool,
databaseName: string
): Promise<{ visible_name: string; last_restore_date: Date | null } | null> {
const req = pool.request();
req.input('dbname', sql.VarChar(100), databaseName);
const result = await req.query(`
SELECT
d.name AS visible_name,
MAX(rh.restore_date) AS last_restore_date
FROM sys.databases d
LEFT JOIN msdb.dbo.restorehistory rh ON d.name = rh.destination_database_name
WHERE d.name = @dbname
GROUP BY d.name
HAVING MAX(rh.restore_date) < DATEADD(DAY, -2, GETDATE()) OR MAX(rh.restore_date) IS NULL
`);
const row = result.recordset?.[0];
return row
? {
visible_name: String(row.visible_name),
last_restore_date: row.last_restore_date ?? null
}
: null;
}
export async function queryRestoreHistoryForDatabase(
pool: sql.ConnectionPool,
databaseName: string
): Promise<{ restore_date: Date }[]> {
const req = pool.request();
req.input('dbname', sql.VarChar(100), databaseName);
const result = await req.query(`
SELECT restore_date
FROM msdb.dbo.restorehistory
WHERE destination_database_name = @dbname
AND restore_date >= DATEADD(DAY, -120, GETDATE())
`);
return (result.recordset as any[]).map((r) => ({ restore_date: r.restore_date }));
}
function computeEffectivenessFromHistory(
restoreHistory: Record<string, { restore_date: Date }[]>
): Record<string, { month: string; effectiveness: number }[]> {
const effectivenessByDb: Record<string, { month: string; effectiveness: number }[]> = {};
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const monthsToInclude = [currentMonth, currentMonth - 1, currentMonth - 2].filter((m) => m >= 0);
for (const [dbName, history] of Object.entries(restoreHistory)) {
const monthly: {
[monthKey: string]: { daysWithRestore: Set<string>; totalDays: number };
} = {};
for (const m of monthsToInclude) {
const monthKey = `${currentYear}-${String(m + 1).padStart(2, '0')}`;
monthly[monthKey] = {
daysWithRestore: new Set<string>(),
totalDays: new Date(currentYear, m + 1, 0).getDate()
};
}
for (const h of history) {
const d = new Date(h.restore_date);
const y = d.getFullYear();
const m = d.getMonth();
if (y !== currentYear || !monthsToInclude.includes(m)) continue;
const monthKey = `${y}-${String(m + 1).padStart(2, '0')}`;
const dayKey = d.toISOString().slice(0, 10);
monthly[monthKey]?.daysWithRestore.add(dayKey);
}
effectivenessByDb[dbName] = Object.entries(monthly).map(([month, data]) => {
const eff =
data.totalDays === 0 ? 0 : (data.daysWithRestore.size / data.totalDays) * 100;
return { month, effectiveness: Number(eff.toFixed(1)) };
});
}
return effectivenessByDb;
}
export type SqlDashboardBundle = {
databaseRows: any[];
summaryMain: { total_databases: number; total_size_gb: number };
alertsData: any[];
restoreHistory: Record<string, { restore_date: Date }[]>;
effectivenessByDb: Record<string, { month: string; effectiveness: number }[]>;
};
/**
* Recorre nodos activos del catálogo y consulta SQL Server en server_name (BD = database_name).
*/
export async function loadSqlDashboardFromNodes(nodes: CatalogNodeRow[]): Promise<SqlDashboardBundle> {
const databaseRows: any[] = [];
const alertsData: any[] = [];
const restoreHistory: Record<string, { restore_date: Date }[]> = {};
let totalSizeGb = 0;
for (const node of nodes) {
const dbn = String(node.BDName || '').trim();
if (!dbn) continue;
const pwd = resolveNodeSqlPassword(node.sql_password);
try {
const pool = await getMssqlPoolMaster(String(node.ServerName || '').trim(), pwd);
const row = await queryDatabaseMetricsOnServer(pool, dbn);
if (!row) continue;
const visible = String(row.visible_name ?? dbn);
const keyLower = visible.toLowerCase();
databaseRows.push({
...row,
visible_name: visible,
original_name: row.original_name ?? visible,
NodoSubNodo: node.NodoSubNodo,
client_name: node.Nombre,
BDName: dbn,
_node_id: node.ID,
_server: adjustMssqlServerForDocker(String(node.ServerName || '').trim())
});
totalSizeGb += Number(row.total_size_gb) || 0;
const alertRow = await queryDatabaseAlertRow(pool, dbn);
if (alertRow) alertsData.push(alertRow);
const hist = await queryRestoreHistoryForDatabase(pool, dbn);
if (!restoreHistory[keyLower]) restoreHistory[keyLower] = [];
for (const h of hist) {
restoreHistory[keyLower].push(h);
}
} catch (e) {
console.error(
`SQL Server nodo id=${node.ID} server=${node.ServerName} db=${dbn}:`,
e
);
}
}
const summaryMain = {
total_databases: databaseRows.length,
total_size_gb: Math.round(totalSizeGb * 100) / 100
};
const effectivenessByDb = computeEffectivenessFromHistory(restoreHistory);
return { databaseRows, summaryMain, alertsData, restoreHistory, effectivenessByDb };
}

View File

@@ -1,15 +1,48 @@
import { pgPool } from './db';
import { hashPassword, verifyPassword, type Usuario } from './auth';
import type { PoolClient } from 'pg';
import {
tableDashboardUsers,
tableDashboardUserDbPerms
} from './dashboard-pg';
/** Mapea fila SQL (alias español opcional) al tipo UI. */
function rowToUsuario(row: Record<string, unknown>): Usuario {
return {
id: row.id as number,
username: row.username as string,
email: (row.email as string) ?? '',
nombre_completo: (row.nombre_completo ?? row.full_name ?? '') as string,
activo: (row.activo ?? row.is_active) as boolean,
es_admin: (row.es_admin ?? row.is_admin) as boolean
};
}
/** Solo para login (incluye hash). */
const SELECT_USER_LOGIN = `
id, username, email, password_hash,
full_name AS nombre_completo,
is_active AS activo,
is_admin AS es_admin
`;
const SELECT_USER_PUBLIC = `
id, username, email,
full_name AS nombre_completo,
is_active AS activo,
is_admin AS es_admin
`;
/**
* Autenticar usuario por username y password
*/
export async function authenticateUser(username: string, password: string): Promise<Usuario | null> {
const client = await pgPool.connect();
const t = tableDashboardUsers();
try {
const result = await client.query(
'SELECT id, username, email, password_hash, nombre_completo, activo, es_admin FROM usuarios WHERE username = $1 AND activo = true',
`SELECT ${SELECT_USER_LOGIN}
FROM ${t}
WHERE username = $1 AND is_active = true`,
[username]
);
@@ -24,20 +57,11 @@ export async function authenticateUser(username: string, password: string): Prom
return null;
}
// Actualizar último acceso
await client.query(
'UPDATE usuarios SET ultimo_acceso = CURRENT_TIMESTAMP WHERE id = $1',
[user.id]
);
await client.query(`UPDATE ${t} SET last_access_at = CURRENT_TIMESTAMP WHERE id = $1`, [
user.id
]);
return {
id: user.id,
username: user.username,
email: user.email,
nombre_completo: user.nombre_completo,
activo: user.activo,
es_admin: user.es_admin
};
return rowToUsuario(user);
} finally {
client.release();
}
@@ -48,9 +72,10 @@ export async function authenticateUser(username: string, password: string): Prom
*/
export async function getUserById(userId: number): Promise<Usuario | null> {
const client = await pgPool.connect();
const t = tableDashboardUsers();
try {
const result = await client.query(
'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios WHERE id = $1',
`SELECT ${SELECT_USER_PUBLIC} FROM ${t} WHERE id = $1`,
[userId]
);
@@ -58,7 +83,7 @@ export async function getUserById(userId: number): Promise<Usuario | null> {
return null;
}
return result.rows[0];
return rowToUsuario(result.rows[0]);
} finally {
client.release();
}
@@ -75,17 +100,24 @@ export async function createUser(data: {
es_admin?: boolean;
}): Promise<Usuario> {
const client = await pgPool.connect();
const t = tableDashboardUsers();
try {
const passwordHash = await hashPassword(data.password);
const result = await client.query(
`INSERT INTO usuarios (username, email, password_hash, nombre_completo, es_admin)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, nombre_completo, activo, es_admin`,
[data.username, data.email, passwordHash, data.nombre_completo || '', data.es_admin || false]
`INSERT INTO ${t} (username, email, password_hash, full_name, is_admin, is_active)
VALUES ($1, $2, $3, $4, $5, true)
RETURNING ${SELECT_USER_PUBLIC}`,
[
data.username,
data.email,
passwordHash,
data.nombre_completo || '',
data.es_admin || false
]
);
return result.rows[0];
return rowToUsuario(result.rows[0]);
} finally {
client.release();
}
@@ -105,9 +137,10 @@ export async function updateUser(
}
): Promise<Usuario | null> {
const client = await pgPool.connect();
const t = tableDashboardUsers();
try {
const updates: string[] = [];
const values: any[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (data.email !== undefined) {
@@ -116,17 +149,17 @@ export async function updateUser(
}
if (data.nombre_completo !== undefined) {
updates.push(`nombre_completo = $${paramIndex++}`);
updates.push(`full_name = $${paramIndex++}`);
values.push(data.nombre_completo);
}
if (data.activo !== undefined) {
updates.push(`activo = $${paramIndex++}`);
updates.push(`is_active = $${paramIndex++}`);
values.push(data.activo);
}
if (data.es_admin !== undefined) {
updates.push(`es_admin = $${paramIndex++}`);
updates.push(`is_admin = $${paramIndex++}`);
values.push(data.es_admin);
}
@@ -143,14 +176,14 @@ export async function updateUser(
values.push(userId);
const result = await client.query(
`UPDATE usuarios
`UPDATE ${t}
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, username, email, nombre_completo, activo, es_admin`,
RETURNING ${SELECT_USER_PUBLIC}`,
values
);
return result.rows[0] || null;
return result.rows[0] ? rowToUsuario(result.rows[0]) : null;
} finally {
client.release();
}
@@ -161,8 +194,9 @@ export async function updateUser(
*/
export async function deleteUser(userId: number): Promise<boolean> {
const client = await pgPool.connect();
const t = tableDashboardUsers();
try {
const result = await client.query('DELETE FROM usuarios WHERE id = $1', [userId]);
const result = await client.query(`DELETE FROM ${t} WHERE id = $1`, [userId]);
return result.rowCount ? result.rowCount > 0 : false;
} finally {
client.release();
@@ -174,11 +208,12 @@ export async function deleteUser(userId: number): Promise<boolean> {
*/
export async function listUsers(): Promise<Usuario[]> {
const client = await pgPool.connect();
const t = tableDashboardUsers();
try {
const result = await client.query(
'SELECT id, username, email, nombre_completo, activo, es_admin FROM usuarios ORDER BY id DESC'
`SELECT ${SELECT_USER_PUBLIC} FROM ${t} ORDER BY id DESC`
);
return result.rows;
return result.rows.map(rowToUsuario);
} finally {
client.release();
}
@@ -189,12 +224,13 @@ export async function listUsers(): Promise<Usuario[]> {
*/
export async function getUserDatabasePermissions(userId: number): Promise<string[]> {
const client = await pgPool.connect();
const t = tableDashboardUserDbPerms();
try {
const result = await client.query(
'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true',
`SELECT database_name FROM ${t} WHERE dashboard_user_id = $1 AND can_view = true`,
[userId]
);
return result.rows.map(row => row.base_datos_nombre);
return result.rows.map((row) => row.database_name as string);
} finally {
client.release();
}
@@ -213,15 +249,17 @@ export async function assignDatabaseToUser(
} = {}
): Promise<void> {
const client = await pgPool.connect();
const t = tableDashboardUserDbPerms();
try {
await client.query(
`INSERT INTO usuario_base_datos (usuario_id, base_datos_nombre, puede_ver, puede_descargar_backup, puede_restaurar)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (usuario_id, base_datos_nombre)
`INSERT INTO ${t} (
dashboard_user_id, database_name, can_view, can_download_backup, can_restore
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (dashboard_user_id, database_name)
DO UPDATE SET
puede_ver = $3,
puede_descargar_backup = $4,
puede_restaurar = $5`,
can_view = EXCLUDED.can_view,
can_download_backup = EXCLUDED.can_download_backup,
can_restore = EXCLUDED.can_restore`,
[
userId,
baseDatosNombre,
@@ -240,9 +278,10 @@ export async function assignDatabaseToUser(
*/
export async function removeDatabaseFromUser(userId: number, baseDatosNombre: string): Promise<void> {
const client = await pgPool.connect();
const t = tableDashboardUserDbPerms();
try {
await client.query(
'DELETE FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2',
`DELETE FROM ${t} WHERE dashboard_user_id = $1 AND database_name = $2`,
[userId, baseDatosNombre]
);
} finally {
@@ -255,24 +294,21 @@ export async function removeDatabaseFromUser(userId: number, baseDatosNombre: st
*/
export async function userCanViewDatabase(userId: number, baseDatosNombre: string): Promise<boolean> {
const client = await pgPool.connect();
const tu = tableDashboardUsers();
const tp = tableDashboardUserDbPerms();
try {
// Los admins pueden ver todo
const userResult = await client.query(
'SELECT es_admin FROM usuarios WHERE id = $1',
[userId]
);
const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]);
if (userResult.rows.length > 0 && userResult.rows[0].es_admin) {
if (userResult.rows.length > 0 && userResult.rows[0].is_admin) {
return true;
}
// Verificar permiso específico
const permResult = await client.query(
'SELECT puede_ver FROM usuario_base_datos WHERE usuario_id = $1 AND base_datos_nombre = $2',
`SELECT can_view FROM ${tp} WHERE dashboard_user_id = $1 AND database_name = $2`,
[userId, baseDatosNombre]
);
return permResult.rows.length > 0 && permResult.rows[0].puede_ver;
return permResult.rows.length > 0 && permResult.rows[0].can_view;
} finally {
client.release();
}
@@ -286,26 +322,25 @@ export async function filterDatabasesByUserPermissions<T extends { visible_name:
databases: T[]
): Promise<T[]> {
const client = await pgPool.connect();
const tu = tableDashboardUsers();
const tp = tableDashboardUserDbPerms();
try {
// Los admins ven todo
const userResult = await client.query(
'SELECT es_admin FROM usuarios WHERE id = $1',
[userId]
);
const userResult = await client.query(`SELECT is_admin FROM ${tu} WHERE id = $1`, [userId]);
if (userResult.rows.length > 0 && userResult.rows[0].es_admin) {
if (userResult.rows.length > 0 && userResult.rows[0].is_admin) {
return databases;
}
// Obtener bases de datos permitidas
const permResult = await client.query(
'SELECT base_datos_nombre FROM usuario_base_datos WHERE usuario_id = $1 AND puede_ver = true',
`SELECT database_name FROM ${tp} WHERE dashboard_user_id = $1 AND can_view = true`,
[userId]
);
const allowedDatabases = new Set(permResult.rows.map(row => row.base_datos_nombre.toLowerCase()));
const allowedDatabases = new Set(
permResult.rows.map((row) => String(row.database_name).toLowerCase())
);
return databases.filter(db => allowedDatabases.has(db.visible_name.toLowerCase()));
return databases.filter((db) => allowedDatabases.has(db.visible_name.toLowerCase()));
} finally {
client.release();
}