Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit
Reviewed-on: #19 Co-authored-by: hreyes <hreyes@aduanasoft.com.mx> Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
1552 lines
56 KiB
TypeScript
1552 lines
56 KiB
TypeScript
/**
|
|
* 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 path from 'node:path';
|
|
import { env } from '$env/dynamic/private';
|
|
import { pgPool } from './db';
|
|
import { encryptSecret, decryptSecret } from './crypto';
|
|
|
|
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"`;
|
|
}
|
|
|
|
function qRestoreTargets(): string {
|
|
const s = schemaName();
|
|
return `"${s.replace(/"/g, '""')}"."restore_targets"`;
|
|
}
|
|
|
|
function qRestoreJobLogs(): string {
|
|
const s = schemaName();
|
|
return `"${s.replace(/"/g, '""')}"."restore_job_logs"`;
|
|
}
|
|
|
|
function qCloudRestoreStatus(): string {
|
|
const s = schemaName();
|
|
return `"${s.replace(/"/g, '""')}"."cloudrestore_status"`;
|
|
}
|
|
|
|
function qNodeLastRestore(): string {
|
|
const s = schemaName();
|
|
return `"${s.replace(/"/g, '""')}"."node_last_restore"`;
|
|
}
|
|
|
|
function isPgUndefinedTable(err: unknown): boolean {
|
|
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42P01';
|
|
}
|
|
|
|
function isPgUndefinedColumn(err: unknown): boolean {
|
|
return typeof err === 'object' && err !== null && (err as { code?: string }).code === '42703';
|
|
}
|
|
|
|
/** Crea cloudrestore_status si la BD existía antes de la migración 002 (idempotente). */
|
|
async function ensureCloudRestoreStatusTable(): Promise<void> {
|
|
await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c');
|
|
await pgPool.query(`
|
|
CREATE TABLE IF NOT EXISTS ${qCloudRestoreStatus()} (
|
|
id SERIAL PRIMARY KEY,
|
|
instance_key VARCHAR(120) NOT NULL UNIQUE DEFAULT 'default',
|
|
input_folder VARCHAR(500) NOT NULL,
|
|
host_name VARCHAR(255),
|
|
app_version VARCHAR(50),
|
|
reported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
// Carpeta de procesados reportada por el restaurador (opcional). Si no la reporta, el
|
|
// panel la deriva como hermana de input_folder (deriveSiblingFolder).
|
|
await pgPool.query(
|
|
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS processed_folder VARCHAR(500)`
|
|
);
|
|
// Plataforma y arquitectura del build instalado (migración e2f3a4b5c6d7 de a24c). Son la
|
|
// fuente autoritativa para decidir qué artefacto de CRAS le toca a este servidor; un
|
|
// agente anterior a 1.1.0 no las reporta y quedan NULL.
|
|
await pgPool.query(
|
|
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS platform VARCHAR(20)`
|
|
);
|
|
await pgPool.query(
|
|
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS arch VARCHAR(20)`
|
|
);
|
|
// Ruta donde vive el ejecutable (APP_DIR del agente). Es un espacio de rutas distinto de
|
|
// input_folder: ahí vive config/.env con las rutas de trabajo que el operador personalizó.
|
|
await pgPool.query(
|
|
`ALTER TABLE ${qCloudRestoreStatus()} ADD COLUMN IF NOT EXISTS install_path VARCHAR(500)`
|
|
);
|
|
}
|
|
|
|
/** Añade columnas de tamaño/ruta relativa a restore_job_logs si faltan (idempotente). */
|
|
async function ensureRestoreJobLogColumns(): Promise<void> {
|
|
for (const stmt of [
|
|
`ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS size_bytes BIGINT`,
|
|
`ALTER TABLE ${qRestoreJobLogs()} ADD COLUMN IF NOT EXISTS rel_path VARCHAR(600)`
|
|
]) {
|
|
try {
|
|
await pgPool.query(stmt);
|
|
} catch (e) {
|
|
if (!isPgUndefinedTable(e)) throw e; // la tabla la crea a24c; si no existe aún, se ignora
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Estado "último respaldo por nodo": una fila por nodo, con el restaurador donde vive el
|
|
* archivo. Llaveada por nodo (NO por la asignación actual) para que el tracking del último
|
|
* respaldo persista aunque el nodo se reasigne a otro restaurador. Idempotente.
|
|
*/
|
|
async function ensureNodeLastRestoreTable(): Promise<void> {
|
|
await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c');
|
|
await pgPool.query(`
|
|
CREATE TABLE IF NOT EXISTS ${qNodeLastRestore()} (
|
|
database_node_id INTEGER PRIMARY KEY,
|
|
restore_target_id INTEGER,
|
|
db_name VARCHAR(255),
|
|
node_key VARCHAR(255),
|
|
filename VARCHAR(500) NOT NULL,
|
|
rel_path VARCHAR(600),
|
|
size_bytes BIGINT,
|
|
restored_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await pgPool.query(
|
|
`CREATE INDEX IF NOT EXISTS idx_a24c_node_last_restore_target
|
|
ON ${qNodeLastRestore()} (restore_target_id)`
|
|
);
|
|
}
|
|
|
|
/** Crea restore_targets y filas Alfa/Omega/Gamma si faltan (migración 001, idempotente). */
|
|
async function ensureRestoreTargetsSchema(): Promise<void> {
|
|
await pgPool.query('CREATE SCHEMA IF NOT EXISTS a24c');
|
|
await pgPool.query(`
|
|
CREATE TABLE IF NOT EXISTS ${qRestoreTargets()} (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(120) NOT NULL UNIQUE,
|
|
server_ip VARCHAR(255),
|
|
sql_username VARCHAR(128),
|
|
sql_password_encrypted TEXT,
|
|
data_folder VARCHAR(500),
|
|
ssh_host VARCHAR(255),
|
|
ssh_port INTEGER DEFAULT 22,
|
|
ssh_username VARCHAR(128),
|
|
ssh_password_encrypted TEXT,
|
|
remote_inbox_path VARCHAR(500),
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`);
|
|
await pgPool.query(`
|
|
INSERT INTO ${qRestoreTargets()} (name) VALUES ('Alfa'), ('Omega'), ('Gamma')
|
|
ON CONFLICT (name) DO NOTHING
|
|
`);
|
|
for (const stmt of [
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_host VARCHAR(255)`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_port INTEGER DEFAULT 22`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_username VARCHAR(128)`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ssh_password_encrypted TEXT`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS remote_inbox_path VARCHAR(500)`,
|
|
// Características de hardware opcionales (para distribuir bases por capacidad).
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS os VARCHAR(50)`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS ram_gb INTEGER`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS disk_gb INTEGER`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS location VARCHAR(255)`,
|
|
`ALTER TABLE ${qRestoreTargets()} ADD COLUMN IF NOT EXISTS size_category VARCHAR(20)`
|
|
]) {
|
|
await pgPool.query(stmt);
|
|
}
|
|
try {
|
|
await pgPool.query(`
|
|
ALTER TABLE ${qNodes()}
|
|
ADD COLUMN IF NOT EXISTS restore_target_id INTEGER
|
|
REFERENCES ${qRestoreTargets()} (id) ON DELETE SET NULL
|
|
`);
|
|
} catch {
|
|
/* database_nodes puede no existir aún en algunos entornos */
|
|
}
|
|
}
|
|
|
|
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",
|
|
restore_target_id AS "RestoreTargetId",
|
|
anexo24c_aviso_fecha AS "Anexo24CAvisoFecha",
|
|
anexo24c_cambio_sistema AS "Anexo24CCambioSistema"
|
|
`;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Usuarios del portal (cliente/autoridad) con los datos de su nodo, para el
|
|
* reporte de asesores. NO incluye contraseñas: `portal_users.password_hash`
|
|
* es un hash bcrypt irreversible y nunca se expone.
|
|
*/
|
|
export async function listPortalUsersWithNode(): Promise<any[]> {
|
|
const sql = `
|
|
SELECT
|
|
pu.id AS "ID",
|
|
pu.is_authority_client AS "ClienteAutoridad",
|
|
pu.full_name AS "Nombre",
|
|
pu.username AS "Usuario",
|
|
pu.bd_shelter AS "BD_Shelter",
|
|
dn.node_subnode_key AS "NodoSubNodo",
|
|
dn.legal_name AS "Cliente",
|
|
dn.rfc AS "RFC",
|
|
dn.database_name AS "BDName",
|
|
dn.is_active AS "NodoActivo"
|
|
FROM ${qUsers()} pu
|
|
LEFT JOIN ${qNodes()} dn ON pu.database_node_id = dn.id
|
|
ORDER BY dn.node_subnode_key, pu.is_authority_client, pu.username
|
|
`;
|
|
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;
|
|
* - el primer segmento separado por punto como token de nodo
|
|
* (ej. `08037natm001.KNOWNWORLD.000` → nodo `08037NATM001`; el sufijo
|
|
* `.KNOWNWORLD` = nombre lógico de la BD origen y `.000` = secuencia se ignoran);
|
|
* - prefijos con separadores `.`, `_` o `-` (ej. `NODO001_full_20240414` → `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();
|
|
|
|
// Candidatos para igualdad exacta: el stem completo y su primer segmento (token de nodo).
|
|
const head = key.split('.')[0];
|
|
const exactKeys = head && head !== key ? [key, head] : [key];
|
|
|
|
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);
|
|
for (const k of exactKeys) {
|
|
if (n && n === k) return row;
|
|
if (b && b === k) return row;
|
|
}
|
|
}
|
|
|
|
// Coincidencia por prefijo: el nodo/BD seguido de un separador (`.`, `_` o `-`).
|
|
const startsWithToken = (token: string) =>
|
|
key.startsWith(`${token}.`) || key.startsWith(`${token}_`) || key.startsWith(`${token}-`);
|
|
|
|
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 || startsWithToken(n))) {
|
|
cands.push({ row, len: n.length });
|
|
}
|
|
if (b && b !== n && (key === b || startsWithToken(b))) {
|
|
cands.push({ row, len: b.length });
|
|
}
|
|
}
|
|
if (!cands.length) return null;
|
|
// Ganar el match más específico (token más largo) para no confundir nodos con prefijo común.
|
|
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> {
|
|
// El "Cliente" de la alerta es el nombre de la tabla de bases de datos
|
|
// (database_nodes.legal_name), NO el full_name del usuario del portal. Se resuelve el nodo por
|
|
// database_name o node_subnode_key; así también funciona para bases sin usuario asociado.
|
|
const sql = `
|
|
SELECT
|
|
dn.legal_name AS "Nombre",
|
|
dn.notification_email AS "CorreoNotificacion",
|
|
dn.node_subnode_key AS "NodoSubNodo"
|
|
FROM ${qNodes()} dn
|
|
WHERE LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text))
|
|
OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text))
|
|
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;
|
|
restoreTargetId?: number | null;
|
|
anexo24CAvisoFecha?: string | null;
|
|
anexo24CCambioSistema?: boolean;
|
|
}): Promise<void> {
|
|
// server_name y sql_password se derivan del servidor de restauración asignado (su IP y su
|
|
// credencial SQL cifrada); si el target aún no tiene IP, se conserva el serverName recibido.
|
|
await pgPool.query(
|
|
`
|
|
INSERT INTO ${qNodes()} (
|
|
node_subnode_key, rfc, legal_name, branch_name,
|
|
notification_email, server_name, database_name, is_active, restore_target_id,
|
|
sql_password, anexo24c_aviso_fecha, anexo24c_cambio_sistema
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6),
|
|
$7, $8, $9,
|
|
(SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9),
|
|
$10, $11
|
|
)
|
|
`,
|
|
[
|
|
row.nodoSubNodo,
|
|
row.rfc,
|
|
row.nombre,
|
|
row.sucursal,
|
|
row.correo,
|
|
row.serverName,
|
|
row.bdName,
|
|
row.activo,
|
|
row.restoreTargetId ?? null,
|
|
row.anexo24CAvisoFecha ?? null,
|
|
row.anexo24CCambioSistema ?? false
|
|
]
|
|
);
|
|
}
|
|
|
|
export async function updateDatabaseNode(
|
|
id: number,
|
|
row: {
|
|
nodoSubNodo: string;
|
|
rfc: string;
|
|
nombre: string;
|
|
sucursal: string;
|
|
correo: string;
|
|
serverName: string;
|
|
bdName: string;
|
|
activo: number;
|
|
restoreTargetId?: number | null;
|
|
anexo24CAvisoFecha?: string | null;
|
|
anexo24CCambioSistema?: boolean;
|
|
}
|
|
): 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 = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $9), $6),
|
|
database_name = $7,
|
|
is_active = $8,
|
|
restore_target_id = $9,
|
|
sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $9),
|
|
anexo24c_aviso_fecha = $10,
|
|
anexo24c_cambio_sistema = $11
|
|
WHERE id = $12
|
|
`,
|
|
[
|
|
row.nodoSubNodo,
|
|
row.rfc,
|
|
row.nombre,
|
|
row.sucursal,
|
|
row.correo,
|
|
row.serverName,
|
|
row.bdName,
|
|
row.activo,
|
|
row.restoreTargetId ?? null,
|
|
row.anexo24CAvisoFecha ?? null,
|
|
row.anexo24CCambioSistema ?? false,
|
|
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]);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Servidores de restauración (restore_targets) — integración CloudRestoreAS.
|
|
// La contraseña SQL se cifra con AES-256-GCM antes de persistir y solo se
|
|
// descifra al entregarla al servicio CloudRestoreAS por el endpoint con token.
|
|
// ============================================================================
|
|
|
|
/** Servidor de restauración sin las contraseñas (seguro para la UI). */
|
|
export interface RestoreTarget {
|
|
id: number;
|
|
name: string;
|
|
server_ip: string;
|
|
sql_username: string;
|
|
data_folder: string;
|
|
ssh_host: string;
|
|
ssh_port: number;
|
|
ssh_username: string;
|
|
remote_inbox_path: string;
|
|
notes: string | null;
|
|
// Características de hardware opcionales (NULL = sin capturar).
|
|
os: string | null;
|
|
ram_gb: number | null;
|
|
disk_gb: number | null;
|
|
location: string | null;
|
|
size_category: string | null;
|
|
}
|
|
|
|
/** Datos de alta/edición. Las contraseñas en texto plano; se cifran aquí. */
|
|
export interface RestoreTargetInput {
|
|
name: string;
|
|
server_ip: string;
|
|
sql_username: string;
|
|
sql_password?: string; // opcional en edición: si se omite, no se cambia
|
|
data_folder: string;
|
|
ssh_host: string;
|
|
ssh_port: number;
|
|
ssh_username: string;
|
|
ssh_password?: string; // opcional en edición: si se omite, no se cambia
|
|
remote_inbox_path: string;
|
|
notes?: string | null;
|
|
// Características de hardware opcionales.
|
|
os?: string | null;
|
|
ram_gb?: number | null;
|
|
disk_gb?: number | null;
|
|
location?: string | null;
|
|
size_category?: string | null;
|
|
}
|
|
|
|
const ROW_RESTORE_TARGET = `
|
|
id, name, server_ip, sql_username, data_folder,
|
|
ssh_host, ssh_port, ssh_username, remote_inbox_path, notes,
|
|
os, ram_gb, disk_gb, location, size_category
|
|
`;
|
|
|
|
async function queryRestoreTargets(): Promise<RestoreTarget[]> {
|
|
const r = await pgPool.query(
|
|
`SELECT ${ROW_RESTORE_TARGET} FROM ${qRestoreTargets()} ORDER BY name`
|
|
);
|
|
return r.rows as RestoreTarget[];
|
|
}
|
|
|
|
/** Lista los servidores de restauración sin exponer la contraseña (alimenta los radios). */
|
|
export async function listRestoreTargets(): Promise<RestoreTarget[]> {
|
|
try {
|
|
return await queryRestoreTargets();
|
|
} catch (e) {
|
|
if (!isPgUndefinedTable(e) && !isPgUndefinedColumn(e)) throw e;
|
|
await ensureRestoreTargetsSchema();
|
|
return await queryRestoreTargets();
|
|
}
|
|
}
|
|
|
|
export async function getRestoreTargetById(id: number): Promise<RestoreTarget | null> {
|
|
const r = await pgPool.query(
|
|
`SELECT ${ROW_RESTORE_TARGET} FROM ${qRestoreTargets()} WHERE id = $1`,
|
|
[id]
|
|
);
|
|
return (r.rows[0] as RestoreTarget) ?? null;
|
|
}
|
|
|
|
export interface RestoreTargetSsh {
|
|
id: number;
|
|
name: string;
|
|
ssh_host: string;
|
|
ssh_port: number;
|
|
ssh_username: string;
|
|
ssh_password: string;
|
|
os: string | null;
|
|
}
|
|
|
|
/**
|
|
* Credenciales SSH de un servidor de restauración, descifradas.
|
|
*
|
|
* Uso exclusivo del instalador remoto de CRAS (cras-install.ts). Devuelve null si el
|
|
* servidor no existe o si le falta algún dato de conexión: es preferible fallar con un
|
|
* mensaje claro antes de tocar nada, que intentar conectar con credenciales incompletas.
|
|
*/
|
|
export async function getRestoreTargetSsh(id: number): Promise<RestoreTargetSsh | null> {
|
|
await ensureRestoreTargetsSchema();
|
|
const r = await pgPool.query(
|
|
`SELECT id, name, ssh_host, ssh_port, ssh_username, ssh_password_encrypted, os
|
|
FROM ${qRestoreTargets()} WHERE id = $1`,
|
|
[id]
|
|
);
|
|
const row = r.rows[0];
|
|
if (!row) return null;
|
|
|
|
const host = (row.ssh_host ?? '').trim();
|
|
const username = (row.ssh_username ?? '').trim();
|
|
if (!host || !username || !row.ssh_password_encrypted) return null;
|
|
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
ssh_host: host,
|
|
ssh_port: Number(row.ssh_port) > 0 ? Number(row.ssh_port) : 22,
|
|
ssh_username: username,
|
|
ssh_password: decryptSecret(row.ssh_password_encrypted),
|
|
os: row.os ?? null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Servidor de restauración por id con la contraseña SQL descifrada. Uso exclusivo del servidor
|
|
* (conectar a SQL Server para depurar bases duplicadas); NUNCA se expone al cliente.
|
|
*/
|
|
export async function getRestoreTargetWithPasswordById(
|
|
id: number
|
|
): Promise<(RestoreTarget & { sql_password: string }) | null> {
|
|
const r = await pgPool.query(
|
|
`SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1`,
|
|
[id]
|
|
);
|
|
const row = r.rows[0];
|
|
if (!row) return null;
|
|
const { sql_password_encrypted, ...rest } = row;
|
|
const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '';
|
|
return { ...(rest as RestoreTarget), sql_password };
|
|
}
|
|
|
|
/**
|
|
* Servidor de restauración por id con AMBAS contraseñas descifradas (SQL para BACKUP/RESTORE, SSH
|
|
* para SFTP). Uso exclusivo del servidor (mover bases duplicadas); NUNCA se expone al cliente.
|
|
*/
|
|
export async function getRestoreTargetWithSecretsById(
|
|
id: number
|
|
): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> {
|
|
const r = await pgPool.query(
|
|
`SELECT ${ROW_RESTORE_TARGET}, sql_password_encrypted, ssh_password_encrypted
|
|
FROM ${qRestoreTargets()} WHERE id = $1`,
|
|
[id]
|
|
);
|
|
const row = r.rows[0];
|
|
if (!row) return null;
|
|
const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row;
|
|
return {
|
|
...(rest as RestoreTarget),
|
|
sql_password: sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '',
|
|
ssh_password: ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : ''
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Devuelve el servidor de restauración ASIGNADO a una base de datos, con la contraseña
|
|
* descifrada. La base se identifica por database_name o node_subnode_key (lo que CloudRestoreAS
|
|
* resuelve del nombre de archivo). Uso exclusivo del endpoint servicio-a-servicio.
|
|
* Retorna null si la base no existe o no tiene servidor asignado.
|
|
*/
|
|
export async function getRestoreTargetForDatabase(
|
|
dbNameOrNodo: string,
|
|
instanceName?: string | null
|
|
): Promise<(RestoreTarget & { sql_password: string; ssh_password: string }) | null> {
|
|
const instanceFilter = (instanceName ?? '').trim();
|
|
const r = await pgPool.query(
|
|
`SELECT rt.id, rt.name, rt.server_ip, rt.sql_username, rt.data_folder,
|
|
rt.ssh_host, rt.ssh_port, rt.ssh_username, rt.remote_inbox_path, rt.notes,
|
|
rt.sql_password_encrypted, rt.ssh_password_encrypted
|
|
FROM ${qNodes()} dn
|
|
JOIN ${qRestoreTargets()} rt ON rt.id = dn.restore_target_id
|
|
WHERE (LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text))
|
|
OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text)))
|
|
AND (
|
|
$2::text = ''
|
|
OR LOWER(TRIM(rt.name)) = LOWER(TRIM($2::text))
|
|
)
|
|
LIMIT 1`,
|
|
[dbNameOrNodo, instanceFilter]
|
|
);
|
|
const row = r.rows[0];
|
|
if (!row) return null;
|
|
const { sql_password_encrypted, ssh_password_encrypted, ...rest } = row;
|
|
// Si el servidor aún no tiene contraseñas configuradas se devuelven vacías:
|
|
// el cliente (panel_client) las rechaza por campo requerido y difiere el job.
|
|
const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '';
|
|
const ssh_password = ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : '';
|
|
return { ...(rest as RestoreTarget), sql_password, ssh_password };
|
|
}
|
|
|
|
export type RouteAction = 'restore_local' | 'forward';
|
|
|
|
export type RestoreTargetWithRoute = RestoreTarget & {
|
|
sql_password: string;
|
|
ssh_password: string;
|
|
input_folder: string | null;
|
|
};
|
|
|
|
/**
|
|
* Servidor asignado a una base, con contraseñas descifradas y carpeta de entrada
|
|
* reportada por CloudRestoreAS (cloudrestore_status), si existe.
|
|
*/
|
|
export async function getRestoreTargetWithInputFolderForDatabase(
|
|
dbNameOrNodo: string
|
|
): Promise<RestoreTargetWithRoute | null> {
|
|
const r = await pgPool.query(
|
|
`SELECT rt.id, rt.name, rt.server_ip, rt.sql_username, rt.data_folder,
|
|
rt.ssh_host, rt.ssh_port, rt.ssh_username, rt.remote_inbox_path, rt.notes,
|
|
rt.sql_password_encrypted, rt.ssh_password_encrypted,
|
|
cs.input_folder
|
|
FROM ${qNodes()} dn
|
|
JOIN ${qRestoreTargets()} rt ON rt.id = dn.restore_target_id
|
|
LEFT JOIN ${qCloudRestoreStatus()} cs
|
|
ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name))
|
|
WHERE (LOWER(TRIM(dn.database_name)) = LOWER(TRIM($1::text))
|
|
OR LOWER(TRIM(dn.node_subnode_key)) = LOWER(TRIM($1::text)))
|
|
LIMIT 1`,
|
|
[dbNameOrNodo]
|
|
);
|
|
const row = r.rows[0];
|
|
if (!row) return null;
|
|
const { sql_password_encrypted, ssh_password_encrypted, input_folder, ...rest } = row;
|
|
const sql_password = sql_password_encrypted ? decryptSecret(sql_password_encrypted) : '';
|
|
const ssh_password = ssh_password_encrypted ? decryptSecret(ssh_password_encrypted) : '';
|
|
const folder =
|
|
input_folder && String(input_folder).trim() ? String(input_folder).trim() : null;
|
|
return { ...(rest as RestoreTarget), sql_password, ssh_password, input_folder: folder };
|
|
}
|
|
|
|
export interface ResolveRouteResult {
|
|
action: RouteAction;
|
|
db_name: string;
|
|
node_key: string;
|
|
target: RestoreTargetWithRoute;
|
|
}
|
|
|
|
/**
|
|
* Resuelve nodo/destino desde el nombre del archivo ZIP y determina si el CRA debe
|
|
* restaurar localmente o reenviar el ZIP por SFTP.
|
|
*/
|
|
export async function resolveRouteForFilename(
|
|
filename: string,
|
|
instanceName?: string | null
|
|
): Promise<ResolveRouteResult | null> {
|
|
const stem = path.parse(filename).name;
|
|
const nodes = await listDatabaseNodesActive();
|
|
const matched = matchNodeRowFromBackupStem(stem, nodes);
|
|
if (!matched) return null;
|
|
|
|
const dbName = String(matched.BDName ?? matched.NodoSubNodo ?? '').trim();
|
|
if (!dbName) return null;
|
|
|
|
const target = await getRestoreTargetWithInputFolderForDatabase(dbName);
|
|
if (!target) return null;
|
|
|
|
const instance = (instanceName ?? '').trim();
|
|
let action: RouteAction;
|
|
if (!instance) {
|
|
action = 'forward';
|
|
} else if (target.name.trim().toLowerCase() === instance.toLowerCase()) {
|
|
action = 'restore_local';
|
|
} else {
|
|
action = 'forward';
|
|
}
|
|
|
|
return {
|
|
action,
|
|
db_name: dbName,
|
|
node_key: String(matched.NodoSubNodo ?? stem).trim(),
|
|
target
|
|
};
|
|
}
|
|
|
|
export async function createRestoreTarget(input: RestoreTargetInput): Promise<number> {
|
|
if (!input.sql_password) {
|
|
throw new Error('La contraseña SQL es obligatoria al crear un servidor.');
|
|
}
|
|
const cols = [
|
|
'name', 'server_ip', 'sql_username', 'data_folder',
|
|
'ssh_host', 'ssh_port', 'ssh_username', 'remote_inbox_path', 'notes',
|
|
'os', 'ram_gb', 'disk_gb', 'location', 'size_category',
|
|
'sql_password_encrypted'
|
|
];
|
|
const params: unknown[] = [
|
|
input.name, input.server_ip, input.sql_username, input.data_folder,
|
|
input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path,
|
|
input.notes ?? null,
|
|
input.os ?? null, input.ram_gb ?? null, input.disk_gb ?? null,
|
|
input.location ?? null, input.size_category ?? null,
|
|
encryptSecret(input.sql_password)
|
|
];
|
|
if (input.ssh_password) {
|
|
cols.push('ssh_password_encrypted');
|
|
params.push(encryptSecret(input.ssh_password));
|
|
}
|
|
const placeholders = params.map((_, i) => `$${i + 1}`).join(', ');
|
|
const r = await pgPool.query(
|
|
`INSERT INTO ${qRestoreTargets()} (${cols.join(', ')}) VALUES (${placeholders}) RETURNING id`,
|
|
params
|
|
);
|
|
return r.rows[0].id as number;
|
|
}
|
|
|
|
/** Actualiza un servidor. Cada contraseña (SQL/SSH) solo se reescribe si viene definida. */
|
|
export async function updateRestoreTarget(id: number, input: RestoreTargetInput): Promise<void> {
|
|
const sets = [
|
|
'name = $1', 'server_ip = $2', 'sql_username = $3', 'data_folder = $4',
|
|
'ssh_host = $5', 'ssh_port = $6', 'ssh_username = $7', 'remote_inbox_path = $8',
|
|
'notes = $9', 'os = $10', 'ram_gb = $11', 'disk_gb = $12', 'location = $13',
|
|
'size_category = $14', 'updated_at = now()'
|
|
];
|
|
const params: unknown[] = [
|
|
input.name, input.server_ip, input.sql_username, input.data_folder,
|
|
input.ssh_host, input.ssh_port, input.ssh_username, input.remote_inbox_path,
|
|
input.notes ?? null,
|
|
input.os ?? null, input.ram_gb ?? null, input.disk_gb ?? null,
|
|
input.location ?? null, input.size_category ?? null
|
|
];
|
|
let p = params.length;
|
|
if (input.sql_password) {
|
|
params.push(encryptSecret(input.sql_password));
|
|
sets.push(`sql_password_encrypted = $${++p}`);
|
|
}
|
|
if (input.ssh_password) {
|
|
params.push(encryptSecret(input.ssh_password));
|
|
sets.push(`ssh_password_encrypted = $${++p}`);
|
|
}
|
|
params.push(id);
|
|
await pgPool.query(
|
|
`UPDATE ${qRestoreTargets()} SET ${sets.join(', ')} WHERE id = $${params.length}`,
|
|
params
|
|
);
|
|
}
|
|
|
|
export async function deleteRestoreTarget(id: number): Promise<void> {
|
|
await pgPool.query(`DELETE FROM ${qRestoreTargets()} WHERE id = $1`, [id]);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Asignación masiva de nodos a servidores de restauración.
|
|
// ============================================================================
|
|
|
|
/** Nodo con su asignación de restaurador, para el checklist/distribución (sin secretos). */
|
|
export interface AssignmentNode {
|
|
ID: number;
|
|
NodoSubNodo: string;
|
|
Nombre: string;
|
|
BDName: string | null;
|
|
ServerName: string | null;
|
|
Activo: number;
|
|
RestoreTargetId: number | null;
|
|
}
|
|
|
|
/** Todos los nodos con su asignación actual (base del checklist y de la distribución). */
|
|
export async function listNodesForAssignment(): Promise<AssignmentNode[]> {
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT
|
|
id AS "ID",
|
|
node_subnode_key AS "NodoSubNodo",
|
|
legal_name AS "Nombre",
|
|
database_name AS "BDName",
|
|
server_name AS "ServerName",
|
|
is_active AS "Activo",
|
|
restore_target_id AS "RestoreTargetId"
|
|
FROM ${qNodes()}
|
|
ORDER BY node_subnode_key
|
|
`
|
|
);
|
|
return r.rows as AssignmentNode[];
|
|
}
|
|
|
|
/**
|
|
* Guarda el checklist manual de un restaurador: los `checkedNodeIds` quedan asignados a
|
|
* `targetId` (reasignando desde donde estuvieran y derivando server_name y sql_password del
|
|
* target); los nodos que estaban en este restaurador y ya NO vienen marcados quedan sin asignar
|
|
* (restore_target_id y sql_password en NULL). Los nodos de OTROS restauradores no marcados no se
|
|
* tocan. Transacción con prepared statements.
|
|
*/
|
|
export async function assignNodesToRestoreTarget(
|
|
targetId: number,
|
|
checkedNodeIds: number[]
|
|
): Promise<void> {
|
|
const ids = Array.from(new Set(checkedNodeIds.filter((n) => Number.isInteger(n) && n > 0)));
|
|
const client = await pgPool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
// 1) Asignar/reasignar los marcados (server_name = IP del target; sql_password = su credencial cifrada).
|
|
await client.query(
|
|
`
|
|
UPDATE ${qNodes()}
|
|
SET restore_target_id = $1,
|
|
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name),
|
|
sql_password = (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $1)
|
|
WHERE id = ANY($2::int[])
|
|
`,
|
|
[targetId, ids]
|
|
);
|
|
// 2) Quitar de este restaurador los desmarcados (restore_target_id y sql_password a NULL); no toca otros targets.
|
|
await client.query(
|
|
`
|
|
UPDATE ${qNodes()}
|
|
SET restore_target_id = NULL,
|
|
sql_password = NULL
|
|
WHERE restore_target_id = $1
|
|
AND NOT (id = ANY($2::int[]))
|
|
`,
|
|
[targetId, ids]
|
|
);
|
|
await client.query('COMMIT');
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aplica un conjunto de asignaciones (distribución global o automática): cada par fija el
|
|
* restore_target_id del nodo (y deriva server_name y sql_password del restaurador si no es NULL).
|
|
* Pares con targetId NULL dejan el nodo sin asignar (conservando server_name, sql_password a NULL).
|
|
* Transacción.
|
|
*/
|
|
export async function applyNodeAssignments(
|
|
pairs: { nodeId: number; targetId: number | null }[]
|
|
): Promise<void> {
|
|
const clean = pairs.filter((p) => Number.isInteger(p.nodeId) && p.nodeId > 0);
|
|
if (clean.length === 0) return;
|
|
const client = await pgPool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
for (const { nodeId, targetId } of clean) {
|
|
await client.query(
|
|
`
|
|
UPDATE ${qNodes()}
|
|
SET restore_target_id = $2,
|
|
server_name = CASE
|
|
WHEN $2::int IS NULL THEN server_name
|
|
ELSE COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $2), server_name)
|
|
END,
|
|
sql_password = CASE
|
|
WHEN $2::int IS NULL THEN NULL
|
|
ELSE (SELECT sql_password_encrypted FROM ${qRestoreTargets()} WHERE id = $2)
|
|
END
|
|
WHERE id = $1
|
|
`,
|
|
[nodeId, targetId]
|
|
);
|
|
}
|
|
await client.query('COMMIT');
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Estado de CloudRestoreAS (carpeta de entrada reportada por el servicio).
|
|
// ============================================================================
|
|
|
|
export interface CloudRestoreStatus {
|
|
id: number;
|
|
instance_key: string;
|
|
input_folder: string;
|
|
processed_folder: string | null;
|
|
host_name: string | null;
|
|
app_version: string | null;
|
|
platform: string | null;
|
|
arch: string | null;
|
|
install_path: string | null;
|
|
reported_at: Date;
|
|
}
|
|
|
|
/** Estados reportados por cada instancia CloudRestoreAS (Alfa/Omega/Gamma). */
|
|
export async function listCloudRestoreStatuses(): Promise<CloudRestoreStatus[]> {
|
|
try {
|
|
await ensureCloudRestoreStatusTable();
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version,
|
|
platform, arch, install_path, reported_at
|
|
FROM ${qCloudRestoreStatus()}
|
|
ORDER BY instance_key
|
|
`
|
|
);
|
|
return r.rows as CloudRestoreStatus[];
|
|
} catch (e) {
|
|
if (isPgUndefinedTable(e)) return [];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** @deprecated Usar listCloudRestoreStatuses. Mantiene compatibilidad con instancia default. */
|
|
export async function getCloudRestoreStatus(): Promise<CloudRestoreStatus | null> {
|
|
const rows = await listCloudRestoreStatuses();
|
|
return rows.find((r) => r.instance_key === 'default') ?? rows[0] ?? null;
|
|
}
|
|
|
|
/** UPSERT del estado reportado por CloudRestoreAS (solo vía API servicio). */
|
|
export async function upsertCloudRestoreStatus(row: {
|
|
inputFolder: string;
|
|
processedFolder?: string | null;
|
|
hostName: string | null;
|
|
appVersion: string | null;
|
|
platform?: string | null;
|
|
arch?: string | null;
|
|
installPath?: string | null;
|
|
instanceKey?: string;
|
|
}): Promise<void> {
|
|
await ensureCloudRestoreStatusTable();
|
|
const key = row.instanceKey ?? 'default';
|
|
await pgPool.query(
|
|
`
|
|
INSERT INTO ${qCloudRestoreStatus()} (
|
|
instance_key, input_folder, processed_folder, host_name, app_version,
|
|
platform, arch, install_path, reported_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
|
ON CONFLICT (instance_key) DO UPDATE SET
|
|
input_folder = EXCLUDED.input_folder,
|
|
processed_folder = EXCLUDED.processed_folder,
|
|
host_name = EXCLUDED.host_name,
|
|
app_version = EXCLUDED.app_version,
|
|
-- COALESCE: un agente anterior a 1.1.0 no reporta platform/arch, y no debe
|
|
-- borrar lo que ya se sabía de ese servidor por un reporte incompleto.
|
|
platform = COALESCE(EXCLUDED.platform, ${qCloudRestoreStatus()}.platform),
|
|
arch = COALESCE(EXCLUDED.arch, ${qCloudRestoreStatus()}.arch),
|
|
install_path = COALESCE(EXCLUDED.install_path, ${qCloudRestoreStatus()}.install_path),
|
|
reported_at = now()
|
|
`,
|
|
[
|
|
key,
|
|
row.inputFolder,
|
|
row.processedFolder ?? null,
|
|
row.hostName,
|
|
row.appVersion,
|
|
row.platform ?? null,
|
|
row.arch ?? null,
|
|
row.installPath ?? null
|
|
]
|
|
);
|
|
}
|
|
|
|
/** Inserta un registro de bitácora reportado por CloudRestoreAS. */
|
|
export async function insertRestoreJobLog(row: {
|
|
filename: string;
|
|
restoreTargetId: number | null;
|
|
dbName: string | null;
|
|
status: 'completed' | 'failed' | 'forwarded';
|
|
durationMs: number | null;
|
|
errorMessage: string | null;
|
|
sizeBytes?: number | null;
|
|
relPath?: string | null;
|
|
}): Promise<void> {
|
|
await ensureRestoreJobLogColumns();
|
|
await pgPool.query(
|
|
`
|
|
INSERT INTO ${qRestoreJobLogs()} (
|
|
filename, restore_target_id, db_name, status, duration_ms, error_message,
|
|
size_bytes, rel_path
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
`,
|
|
[
|
|
row.filename,
|
|
row.restoreTargetId,
|
|
row.dbName,
|
|
row.status,
|
|
row.durationMs,
|
|
row.errorMessage,
|
|
row.sizeBytes ?? null,
|
|
row.relPath ?? null
|
|
]
|
|
);
|
|
}
|
|
|
|
/** Resumen de bitácora por servidor: conteos por status (últimos 30 días) y última exitosa. */
|
|
export interface RestoreJobLogSummary {
|
|
restore_target_id: number;
|
|
completed: number;
|
|
failed: number;
|
|
forwarded: number;
|
|
last_completed_at: Date | null;
|
|
}
|
|
|
|
export async function listRestoreJobLogSummaries(): Promise<RestoreJobLogSummary[]> {
|
|
try {
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT
|
|
restore_target_id,
|
|
COUNT(*) FILTER (
|
|
WHERE status = 'completed' AND restored_at >= now() - INTERVAL '30 days'
|
|
)::int AS completed,
|
|
COUNT(*) FILTER (
|
|
WHERE status = 'failed' AND restored_at >= now() - INTERVAL '30 days'
|
|
)::int AS failed,
|
|
COUNT(*) FILTER (
|
|
WHERE status = 'forwarded' AND restored_at >= now() - INTERVAL '30 days'
|
|
)::int AS forwarded,
|
|
MAX(restored_at) FILTER (WHERE status = 'completed') AS last_completed_at
|
|
FROM ${qRestoreJobLogs()}
|
|
WHERE restore_target_id IS NOT NULL
|
|
GROUP BY restore_target_id
|
|
`
|
|
);
|
|
return r.rows as RestoreJobLogSummary[];
|
|
} catch (e) {
|
|
if (isPgUndefinedTable(e)) return [];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** Últimas restauraciones de un servidor (para el modal de bitácora del panel). */
|
|
export interface RestoreJobLogRow {
|
|
id: number;
|
|
filename: string;
|
|
db_name: string | null;
|
|
status: string;
|
|
duration_ms: number | null;
|
|
error_message: string | null;
|
|
restored_at: Date;
|
|
}
|
|
|
|
export async function listRecentRestoreJobLogs(
|
|
targetId: number,
|
|
limit = 20
|
|
): Promise<RestoreJobLogRow[]> {
|
|
const capped = Math.min(Math.max(1, Math.trunc(limit)), 100);
|
|
try {
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT id, filename, db_name, status, duration_ms, error_message, restored_at
|
|
FROM ${qRestoreJobLogs()}
|
|
WHERE restore_target_id = $1
|
|
ORDER BY restored_at DESC
|
|
LIMIT $2
|
|
`,
|
|
[targetId, capped]
|
|
);
|
|
return r.rows as RestoreJobLogRow[];
|
|
} catch (e) {
|
|
if (isPgUndefinedTable(e)) return [];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Inventario "último respaldo por nodo" (node_last_restore) y restores fallidos.
|
|
// ============================================================================
|
|
|
|
export interface NodeLastRestoreRow {
|
|
database_node_id: number;
|
|
restore_target_id: number | null;
|
|
server_name: string | null; // restaurador (Alfa/Omega/Gamma) donde vive el archivo
|
|
node_key: string | null; // NodoSubNodo
|
|
client_name: string | null; // legal_name
|
|
db_name: string | null;
|
|
filename: string;
|
|
rel_path: string | null;
|
|
size_bytes: number | null;
|
|
restored_at: Date;
|
|
}
|
|
|
|
/** Último respaldo restaurado por nodo. Sobrevive a la reasignación del restaurador. */
|
|
export async function listNodeLastRestore(): Promise<NodeLastRestoreRow[]> {
|
|
try {
|
|
await ensureNodeLastRestoreTable();
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT
|
|
nlr.database_node_id,
|
|
nlr.restore_target_id,
|
|
rt.name AS server_name,
|
|
COALESCE(nlr.node_key, dn.node_subnode_key) AS node_key,
|
|
dn.legal_name AS client_name,
|
|
nlr.db_name,
|
|
nlr.filename,
|
|
nlr.rel_path,
|
|
nlr.size_bytes,
|
|
nlr.restored_at
|
|
FROM ${qNodeLastRestore()} nlr
|
|
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = nlr.restore_target_id
|
|
LEFT JOIN ${qNodes()} dn ON dn.id = nlr.database_node_id
|
|
ORDER BY nlr.restored_at DESC
|
|
`
|
|
);
|
|
return r.rows as NodeLastRestoreRow[];
|
|
} catch (e) {
|
|
if (isPgUndefinedTable(e)) return [];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* UPSERT del último respaldo de un nodo. Solo actualiza si el nuevo `restored_at` es igual o
|
|
* más reciente, para no retroceder el tracking. Llaveada por nodo → persiste ante reasignación.
|
|
*/
|
|
export async function upsertNodeLastRestore(row: {
|
|
databaseNodeId: number;
|
|
restoreTargetId: number | null;
|
|
dbName: string | null;
|
|
nodeKey: string | null;
|
|
filename: string;
|
|
relPath: string | null;
|
|
sizeBytes: number | null;
|
|
restoredAt?: Date | null;
|
|
}): Promise<void> {
|
|
await ensureNodeLastRestoreTable();
|
|
await pgPool.query(
|
|
`
|
|
INSERT INTO ${qNodeLastRestore()} AS nlr (
|
|
database_node_id, restore_target_id, db_name, node_key,
|
|
filename, rel_path, size_bytes, restored_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, now()), now())
|
|
ON CONFLICT (database_node_id) DO UPDATE SET
|
|
restore_target_id = EXCLUDED.restore_target_id,
|
|
db_name = EXCLUDED.db_name,
|
|
node_key = EXCLUDED.node_key,
|
|
filename = EXCLUDED.filename,
|
|
rel_path = EXCLUDED.rel_path,
|
|
size_bytes = EXCLUDED.size_bytes,
|
|
restored_at = EXCLUDED.restored_at,
|
|
updated_at = now()
|
|
WHERE EXCLUDED.restored_at >= nlr.restored_at
|
|
`,
|
|
[
|
|
row.databaseNodeId,
|
|
row.restoreTargetId,
|
|
row.dbName,
|
|
row.nodeKey,
|
|
row.filename,
|
|
row.relPath,
|
|
row.sizeBytes,
|
|
row.restoredAt ?? null
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resuelve el database_node_id de un respaldo por db_name o por el nombre de archivo.
|
|
* Reutiliza matchNodeRowFromBackupStem para el fallback por stem del filename.
|
|
*/
|
|
export async function findNodeIdForBackup(
|
|
filename: string,
|
|
dbName: string | null
|
|
): Promise<{ id: number; nodeKey: string | null } | null> {
|
|
if (dbName) {
|
|
const r = await pgPool.query(
|
|
`SELECT id, node_subnode_key FROM ${qNodes()}
|
|
WHERE LOWER(TRIM(database_name)) = LOWER(TRIM($1))
|
|
OR LOWER(TRIM(node_subnode_key)) = LOWER(TRIM($1))
|
|
LIMIT 1`,
|
|
[dbName]
|
|
);
|
|
if (r.rows.length) {
|
|
return { id: Number(r.rows[0].id), nodeKey: r.rows[0].node_subnode_key ?? null };
|
|
}
|
|
}
|
|
const stem = path.parse(filename).name;
|
|
const nodes = await pgPool.query(
|
|
`SELECT id AS "ID", node_subnode_key AS "NodoSubNodo", database_name AS "BDName"
|
|
FROM ${qNodes()}`
|
|
);
|
|
const match = matchNodeRowFromBackupStem(stem, nodes.rows);
|
|
if (match && match.ID != null) {
|
|
return { id: Number(match.ID), nodeKey: (match.NodoSubNodo as string) ?? null };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Restores fallidos (todos los servidores) para el panel de fallidos. */
|
|
export interface FailedRestoreRow {
|
|
id: number;
|
|
restore_target_id: number | null;
|
|
server_name: string | null;
|
|
filename: string;
|
|
db_name: string | null;
|
|
error_message: string | null;
|
|
rel_path: string | null;
|
|
size_bytes: number | null;
|
|
restored_at: Date;
|
|
}
|
|
|
|
export async function listFailedRestoreJobLogs(limit = 100): Promise<FailedRestoreRow[]> {
|
|
const capped = Math.min(Math.max(1, Math.trunc(limit)), 500);
|
|
try {
|
|
await ensureRestoreJobLogColumns();
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT
|
|
jl.id, jl.restore_target_id, rt.name AS server_name,
|
|
jl.filename, jl.db_name, jl.error_message, jl.rel_path, jl.size_bytes,
|
|
jl.restored_at
|
|
FROM ${qRestoreJobLogs()} jl
|
|
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
|
|
WHERE jl.status = 'failed'
|
|
ORDER BY jl.restored_at DESC
|
|
LIMIT $1
|
|
`,
|
|
[capped]
|
|
);
|
|
return r.rows as FailedRestoreRow[];
|
|
} catch (e) {
|
|
if (isPgUndefinedTable(e)) return [];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** Restores completados/reenviados (todos los servidores) para el panel de restaurados. */
|
|
export interface RestoredRestoreRow {
|
|
id: number;
|
|
restore_target_id: number | null;
|
|
server_name: string | null;
|
|
node_key: string | null;
|
|
client_name: string | null;
|
|
db_name: string | null;
|
|
filename: string;
|
|
rel_path: string | null;
|
|
size_bytes: number | null;
|
|
restored_at: Date;
|
|
}
|
|
|
|
/**
|
|
* Restores exitosos (`completed`/`forwarded`) desde restore_job_logs — lo que reporta
|
|
* CloudRestoreAS vía POST /api/restore/job-result. Resuelve node_key/client_name por nombre
|
|
* de base contra database_nodes (LATERAL … LIMIT 1 para no duplicar la fila del log si dos
|
|
* nodos comparten database_name). Cuando no hay match, ambos quedan null y la UI cae a db_name.
|
|
*/
|
|
export async function listRestoredRestoreJobLogs(limit = 200): Promise<RestoredRestoreRow[]> {
|
|
const capped = Math.min(Math.max(1, Math.trunc(limit)), 500);
|
|
try {
|
|
await ensureRestoreJobLogColumns();
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT
|
|
jl.id, jl.restore_target_id, rt.name AS server_name,
|
|
dn.node_subnode_key AS node_key, dn.legal_name AS client_name,
|
|
jl.filename, jl.db_name, jl.rel_path, jl.size_bytes, jl.restored_at
|
|
FROM ${qRestoreJobLogs()} jl
|
|
LEFT JOIN ${qRestoreTargets()} rt ON rt.id = jl.restore_target_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT n.node_subnode_key, n.legal_name
|
|
FROM ${qNodes()} n
|
|
WHERE LOWER(TRIM(n.database_name)) = LOWER(TRIM(jl.db_name))
|
|
LIMIT 1
|
|
) dn ON true
|
|
WHERE jl.status IN ('completed', 'forwarded')
|
|
ORDER BY jl.restored_at DESC
|
|
LIMIT $1
|
|
`,
|
|
[capped]
|
|
);
|
|
return r.rows as RestoredRestoreRow[];
|
|
} catch (e) {
|
|
if (isPgUndefinedTable(e)) return [];
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** Resultado de un borrado de bitácora: lo pedido, lo elegible y lo que realmente se fue. */
|
|
export interface DeleteRestoreLogsResult {
|
|
/** IDs válidos recibidos (tras sanear). */
|
|
requested: number;
|
|
/** De esos, cuántos existían Y eran `failed` — el COUNT previo al DELETE. */
|
|
eligible: number;
|
|
/** Filas efectivamente borradas. Debe coincidir con `eligible`. */
|
|
deleted: number;
|
|
/** Cuántos se ignoraron por no ser fallidos (existen pero son completed/forwarded). */
|
|
skippedNotFailed: number;
|
|
}
|
|
|
|
/**
|
|
* BORRA registros de la bitácora de forma definitiva. No hay deshacer.
|
|
*
|
|
* Solo borra filas con `status = 'failed'`, y esa restricción vive aquí y no solo en la UI a
|
|
* propósito. Dos razones:
|
|
*
|
|
* 1. "Respaldos Restaurados" es el registro de auditoría de lo que sí se restauró, y de ahí
|
|
* sale `MAX(restored_at) FILTER (WHERE status='completed')` en listRestoreJobLogSummaries,
|
|
* o sea la "última restauración exitosa" que /servidores-restauracion usa como señal de
|
|
* salud. Borrar ahí no limpiaría ruido: haría retroceder un indicador.
|
|
* 2. Acota el daño de un ID equivocado en el POST a filas que de todos modos son residuo.
|
|
*
|
|
* Consecuencia aceptada y documentada: el contador de FALLAS de los últimos 30 días en
|
|
* /servidores-restauracion sí baja, porque cuenta esta misma tabla. Es el efecto buscado.
|
|
*
|
|
* Recibe IDs explícitos y no un filtro: lo borrado es exactamente lo que el operador vio en
|
|
* pantalla, sin duplicar la lógica del buscador del cliente en el servidor.
|
|
*
|
|
* Va en transacción con COUNT previo (estándar de Aduanasoft para destructivas): así se puede
|
|
* reportar por qué `deleted` no coincide con lo pedido —IDs ya borrados por otro operador, o
|
|
* filas que no son fallidas— en lugar de devolver un número sin explicación.
|
|
*/
|
|
export async function deleteRestoreJobLogs(ids: number[]): Promise<DeleteRestoreLogsResult> {
|
|
const clean = sanitizeIds(ids);
|
|
const empty: DeleteRestoreLogsResult = {
|
|
requested: 0,
|
|
eligible: 0,
|
|
deleted: 0,
|
|
skippedNotFailed: 0
|
|
};
|
|
if (clean.length === 0) return empty;
|
|
|
|
const client = await pgPool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// COUNT previo dentro de la misma transacción: cuenta lo elegible y, aparte, lo que
|
|
// existe pero NO es fallido, para distinguir "ya no estaba" de "no se permite".
|
|
const pre = await client.query(
|
|
`SELECT
|
|
count(*) FILTER (WHERE status = 'failed') AS eligible,
|
|
count(*) FILTER (WHERE status <> 'failed') AS not_failed
|
|
FROM ${qRestoreJobLogs()}
|
|
WHERE id = ANY($1::int[])`,
|
|
[clean]
|
|
);
|
|
const eligible = Number(pre.rows[0]?.eligible ?? 0);
|
|
const skippedNotFailed = Number(pre.rows[0]?.not_failed ?? 0);
|
|
|
|
const del = await client.query(
|
|
`DELETE FROM ${qRestoreJobLogs()}
|
|
WHERE id = ANY($1::int[]) AND status = 'failed'`,
|
|
[clean]
|
|
);
|
|
|
|
await client.query('COMMIT');
|
|
return {
|
|
requested: clean.length,
|
|
eligible,
|
|
deleted: del.rowCount ?? 0,
|
|
skippedNotFailed
|
|
};
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
if (isPgUndefinedTable(e)) return empty; // la tabla la crea a24c
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/** Enteros positivos únicos. Mismo criterio que assignNodesToRestoreTarget. */
|
|
function sanitizeIds(ids: number[]): number[] {
|
|
return Array.from(
|
|
new Set((ids ?? []).filter((n) => Number.isInteger(n) && n > 0))
|
|
);
|
|
}
|
|
|
|
/** Carpetas de un restaurador para descarga por filesystem (input/processed reportados). */
|
|
export interface RestoreTargetDownload {
|
|
id: number;
|
|
name: string;
|
|
input_folder: string | null;
|
|
processed_folder: string | null;
|
|
}
|
|
|
|
export async function getRestoreTargetForDownload(
|
|
targetId: number
|
|
): Promise<RestoreTargetDownload | null> {
|
|
await ensureRestoreTargetsSchema();
|
|
await ensureCloudRestoreStatusTable();
|
|
const r = await pgPool.query(
|
|
`
|
|
SELECT rt.id, rt.name, cs.input_folder, cs.processed_folder
|
|
FROM ${qRestoreTargets()} rt
|
|
LEFT JOIN ${qCloudRestoreStatus()} cs
|
|
ON LOWER(TRIM(cs.instance_key)) = LOWER(TRIM(rt.name))
|
|
WHERE rt.id = $1
|
|
`,
|
|
[targetId]
|
|
);
|
|
if (!r.rows.length) return null;
|
|
const row = r.rows[0];
|
|
return {
|
|
id: Number(row.id),
|
|
name: row.name,
|
|
input_folder: row.input_folder ?? null,
|
|
processed_folder: row.processed_folder ?? null
|
|
};
|
|
}
|