feature/asignacion-masiva-restauradores (#13)
Some checks failed
Aduanasoft/PANEL_BASES_ANEXO24/pipeline/head There was a failure building this commit

integraciones de panel de respaldos fallidos, asignacion masiva de restaurador, trackeo de baks para nuevo sistema de cloudrestore

Reviewed-on: #13
Co-authored-by: hreyes <hreyes@aduanasoft.com.mx>
Co-committed-by: hreyes <hreyes@aduanasoft.com.mx>
This commit is contained in:
2026-07-02 14:57:45 +00:00
committed by acazares
parent cb4324979b
commit 1b198953d4
31 changed files with 3956 additions and 889 deletions

View File

@@ -10,7 +10,7 @@ export function newTraceId(): string {
}
export function errorJson(
code: 400 | 401 | 403 | 404 | 409 | 422 | 500,
code: 400 | 401 | 403 | 404 | 409 | 422 | 500 | 503,
message: string,
traceId: string
) {

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { listBackupFiles, deriveSiblingFolder } from './backup-files';
let root: string;
async function writeFile(rel: string, bytes: number, mtime: Date): Promise<void> {
const full = path.join(root, rel);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, Buffer.alloc(bytes, 'x'));
await fs.utimes(full, mtime, mtime);
}
const NEW = new Date('2026-07-01T12:00:00Z');
const MID = new Date('2026-07-01T10:00:00Z');
const OLD = new Date('2026-06-30T09:00:00Z');
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'backup-files-'));
// Carpeta de fecha reciente: un ZIP simple y un multipart de 3 partes.
await writeFile('2026-07-01/NEW.ZIP', 200, NEW);
await writeFile('2026-07-01/SPLIT.zip.001', 100, MID);
await writeFile('2026-07-01/SPLIT.zip.002', 100, MID);
await writeFile('2026-07-01/SPLIT.zip.003', 50, MID);
// Carpeta de fecha anterior: un ZIP y un archivo aún más profundo (nivel 3).
await writeFile('2026-06-30/OLD.ZIP', 300, OLD);
await writeFile('2026-06-30/nested/TOODEEP.ZIP', 10, OLD);
});
afterAll(async () => {
await fs.rm(root, { recursive: true, force: true });
});
describe('listBackupFiles', () => {
it('recorre subcarpetas de fecha y devuelve relPath en formato POSIX', async () => {
const { files } = await listBackupFiles(root);
const byName = new Map(files.map((f) => [f.name, f]));
expect(byName.get('NEW.ZIP')?.relPath).toBe('2026-07-01/NEW.ZIP');
expect(byName.get('OLD.ZIP')?.relPath).toBe('2026-06-30/OLD.ZIP');
});
it('colapsa multipart en una sola entrada sumando tamaños y apuntando a la parte .001', async () => {
const { files } = await listBackupFiles(root);
const split = files.find((f) => f.name === 'SPLIT.zip');
expect(split).toBeDefined();
expect(split?.parts).toBe(3);
expect(split?.sizeBytes).toBe(250);
expect(split?.relPath).toBe('2026-07-01/SPLIT.zip.001');
});
it('ordena por fecha de modificación descendente (más reciente primero)', async () => {
const { files } = await listBackupFiles(root);
const order = files.map((f) => f.name);
// NEW (12:00) > SPLIT (10:00) > OLD (09:00). TOODEEP excluido por profundidad.
expect(order).toEqual(['NEW.ZIP', 'SPLIT.zip', 'OLD.ZIP']);
});
it('respeta maxDepth: excluye archivos más profundos que la subcarpeta de fecha', async () => {
const shallow = await listBackupFiles(root, { maxDepth: 2 });
expect(shallow.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(false);
const deep = await listBackupFiles(root, { maxDepth: 3 });
expect(deep.files.some((f) => f.name === 'TOODEEP.ZIP')).toBe(true);
});
it('recorta con maxFiles conservando la carpeta de fecha más reciente y marca truncated', async () => {
const { files, truncated } = await listBackupFiles(root, { maxFiles: 1 });
expect(truncated).toBe(true);
expect(files.length).toBeGreaterThanOrEqual(1);
expect(files.every((f) => f.relPath.startsWith('2026-07-01/'))).toBe(true);
});
it('no recorta cuando el tope es holgado', async () => {
const { truncated } = await listBackupFiles(root, { maxFiles: 1000 });
expect(truncated).toBe(false);
});
it('lanza si la carpeta raíz no existe', async () => {
await expect(listBackupFiles(path.join(root, 'no-existe'))).rejects.toThrow();
});
});
describe('deriveSiblingFolder', () => {
it('deriva la hermana en rutas Windows (backslash)', () => {
expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Procesados')).toBe(
'D:\\CloudRestore\\Procesados'
);
expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada', 'Fallados')).toBe(
'D:\\CloudRestore\\Fallados'
);
});
it('deriva la hermana en rutas POSIX', () => {
expect(deriveSiblingFolder('/mnt/x/Entrada', 'Procesados')).toBe('/mnt/x/Procesados');
});
it('tolera separador final', () => {
expect(deriveSiblingFolder('D:\\CloudRestore\\Entrada\\', 'Procesados')).toBe(
'D:\\CloudRestore\\Procesados'
);
expect(deriveSiblingFolder('/mnt/x/Entrada/', 'Fallados')).toBe('/mnt/x/Fallados');
});
it('funciona con rutas en la raíz del disco y UNC', () => {
expect(deriveSiblingFolder('D:\\sftp', 'Procesados')).toBe('D:\\Procesados');
expect(deriveSiblingFolder('\\\\srv\\share\\Entrada', 'Procesados')).toBe(
'\\\\srv\\share\\Procesados'
);
});
it('sin separador devuelve el nombre hermano como fallback', () => {
expect(deriveSiblingFolder('Entrada', 'Procesados')).toBe('Procesados');
expect(deriveSiblingFolder('', 'Procesados')).toBe('Procesados');
});
});

Binary file not shown.

View File

@@ -38,6 +38,11 @@ function qCloudRestoreStatus(): string {
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';
}
@@ -59,6 +64,51 @@ async function ensureCloudRestoreStatusTable(): Promise<void> {
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)`
);
}
/** 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). */
@@ -91,7 +141,13 @@ async function ensureRestoreTargetsSchema(): Promise<void> {
`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)`
`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);
}
@@ -492,6 +548,12 @@ export interface RestoreTarget {
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í. */
@@ -507,11 +569,18 @@ export interface RestoreTargetInput {
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
ssh_host, ssh_port, ssh_username, remote_inbox_path, notes,
os, ram_gb, disk_gb, location, size_category
`;
async function queryRestoreTargets(): Promise<RestoreTarget[]> {
@@ -666,12 +735,16 @@ export async function createRestoreTarget(input: RestoreTargetInput): Promise<nu
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, encryptSecret(input.sql_password)
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');
@@ -690,12 +763,15 @@ export async function updateRestoreTarget(id: number, input: RestoreTargetInput)
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', 'updated_at = now()'
'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.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) {
@@ -717,6 +793,119 @@ 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 de su IP); los nodos
* que estaban en este restaurador y ya NO vienen marcados quedan sin asignar (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 a este restaurador (server_name = IP del target si existe).
await client.query(
`
UPDATE ${qNodes()}
SET restore_target_id = $1,
server_name = COALESCE((SELECT server_ip FROM ${qRestoreTargets()} WHERE id = $1), server_name)
WHERE id = ANY($2::int[])
`,
[targetId, ids]
);
// 2) Quitar de este restaurador los que quedaron desmarcados (NULL); no toca otros targets.
await client.query(
`
UPDATE ${qNodes()}
SET restore_target_id = 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 de la IP del restaurador si no es NULL).
* Pares con targetId NULL dejan el nodo sin asignar (conservando server_name). 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
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).
// ============================================================================
@@ -725,6 +914,7 @@ export interface CloudRestoreStatus {
id: number;
instance_key: string;
input_folder: string;
processed_folder: string | null;
host_name: string | null;
app_version: string | null;
reported_at: Date;
@@ -736,7 +926,7 @@ export async function listCloudRestoreStatuses(): Promise<CloudRestoreStatus[]>
await ensureCloudRestoreStatusTable();
const r = await pgPool.query(
`
SELECT id, instance_key, input_folder, host_name, app_version, reported_at
SELECT id, instance_key, input_folder, processed_folder, host_name, app_version, reported_at
FROM ${qCloudRestoreStatus()}
ORDER BY instance_key
`
@@ -757,6 +947,7 @@ export async function getCloudRestoreStatus(): Promise<CloudRestoreStatus | 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;
instanceKey?: string;
@@ -766,15 +957,16 @@ export async function upsertCloudRestoreStatus(row: {
await pgPool.query(
`
INSERT INTO ${qCloudRestoreStatus()} (
instance_key, input_folder, host_name, app_version, reported_at
) VALUES ($1, $2, $3, $4, now())
instance_key, input_folder, processed_folder, host_name, app_version, reported_at
) VALUES ($1, $2, $3, $4, $5, 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,
reported_at = now()
`,
[key, row.inputFolder, row.hostName, row.appVersion]
[key, row.inputFolder, row.processedFolder ?? null, row.hostName, row.appVersion]
);
}
@@ -786,12 +978,16 @@ export async function insertRestoreJobLog(row: {
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
) VALUES ($1, $2, $3, $4, $5, $6)
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,
@@ -799,7 +995,274 @@ export async function insertRestoreJobLog(row: {
row.dbName,
row.status,
row.durationMs,
row.errorMessage
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;
}
}
/** 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
};
}

View File

@@ -0,0 +1,66 @@
/**
* Pruebas de los builders HTML de correo (réplica 1:1 del legacy) y del formateo de fecha.
* Importan las funciones REALES: se valida estructura legacy + escape de campos de texto.
*/
import { describe, it, expect } from 'vitest';
import { buildBackupAlertHtml, buildBackupResolvedHtml, formatFechaEs } from './email-service';
const baseRow = {
visible_name: 'CLIENTE_DB',
clientName: 'ACME S.A.',
last_restore_date: '2026-06-28T17:26:15.290000',
daysWithout: 3
};
describe('buildBackupAlertHtml — 1:1 legacy (overdue)', () => {
it('incluye logo, título, link SCAIIWeb y pie TransmitirAS', () => {
const html = buildBackupAlertHtml([baseRow]);
expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png');
expect(html).toContain('Notificación de Sincronización');
expect(html).toContain('no se ha sincronizado correctamente en las últimas 24 horas');
expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb');
expect(html).toContain('&copy; 2024 TransmitirAS');
expect(html).toContain('ACME S.A.'); // cliente
expect(html).toContain('CLIENTE_DB'); // base de datos
expect(html).toContain('28 de junio de 2026, 17:26'); // fecha formateada
});
it('escapa visible_name y clientName (XSS)', () => {
const html = buildBackupAlertHtml([
{ visible_name: '<script>alert(1)</script>', clientName: '<b>x</b>', last_restore_date: null, daysWithout: null }
]);
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;');
expect(html).not.toContain('<b>x</b>');
expect(html).toContain('No disponible'); // last_restore null
});
});
describe('buildBackupResolvedHtml — misma plantilla, mensaje positivo', () => {
it('incluye título restablecido, logo, SCAIIWeb y pie', () => {
const html = buildBackupResolvedHtml([baseRow]);
expect(html).toContain('Sincronización Restablecida');
expect(html).toContain('volvió a sincronizarse correctamente');
expect(html).toContain('https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png');
expect(html).toContain('https://a24.aduanasoft.com/SCAIIWeb');
expect(html).toContain('&copy; 2024 TransmitirAS');
});
it('escapa clientName', () => {
const html = buildBackupResolvedHtml([
{ visible_name: 'DB', clientName: '<img src=x onerror=1>', last_restore_date: null, daysWithout: null }
]);
expect(html).not.toContain('<img src=x onerror=1>');
expect(html).toContain('&lt;img');
});
});
describe('formatFechaEs', () => {
it('formatea fecha ISO (con microsegundos) al estilo legacy', () => {
expect(formatFechaEs('2026-06-28T17:26:15.290000')).toBe('28 de junio de 2026, 17:26');
});
it('null/invalid -> No disponible', () => {
expect(formatFechaEs(null)).toBe('No disponible');
expect(formatFechaEs('no-fecha')).toBe('No disponible');
});
});

View File

@@ -65,95 +65,91 @@ export async function sendSmtpEmail(opts: SendEmailOptions): Promise<void> {
});
}
/** Genera HTML de alerta de respaldo compatible con Gmail/Outlook (tablas, sin flex). */
export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string {
const rows = alerts
.map((a) => {
const badgeColor =
a.daysWithout === null
? '#6b7280'
: a.daysWithout >= 7
? '#b91c1c'
: a.daysWithout >= 3
? '#b45309'
: '#047857';
const badgeBg =
a.daysWithout === null
? '#f3f4f6'
: a.daysWithout >= 7
? '#fef2f2'
: a.daysWithout >= 3
? '#fffbeb'
: '#ecfdf5';
const diasLabel =
a.daysWithout === null ? 'Sin datos' : `${a.daysWithout} día${a.daysWithout !== 1 ? 's' : ''}`;
const ultimaRest = a.last_restore_date
? new Date(a.last_restore_date).toLocaleString('es-MX')
: 'Nunca';
return `
<tr style="border-bottom:1px solid #e2e8f0;">
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#1e293b;">${escHtml(a.visible_name)}</td>
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${escHtml(a.clientName ?? 'N/D')}</td>
<td style="padding:8px 12px;font-family:sans-serif;font-size:12px;color:#475569;">${ultimaRest}</td>
<td style="padding:8px 12px;text-align:center;">
<span style="display:inline-block;padding:2px 8px;border-radius:9999px;font-family:sans-serif;font-size:11px;font-weight:600;color:${badgeColor};background:${badgeBg};border:1px solid ${badgeColor}30;">
${diasLabel}
</span>
</td>
</tr>`;
})
.join('');
// ============================================================================
// Plantillas de correo — réplica 1:1 del legacy index.php (logo, azul, narrativa
// por cliente, link SCAIIWeb, pie © TransmitirAS).
// ============================================================================
const LOGO_URL = 'https://aduanasoft.com/wp-content/uploads/2023/12/web50@3x-8.png';
const SCAIIWEB_H4 =
`<h4 style="color:#007bff;text-align:center;">` +
`Consulta la última sincronización de datos fácilmente desde ` +
`<a href="https://a24.aduanasoft.com/SCAIIWeb" style="color:#007bff;text-decoration:none;">SCAIIWeb</a>. ` +
`Inicia sesión y encontrarás esta información en la esquina inferior derecha de la pantalla.</h4>`;
const FOOTER =
`<div style="background-color:#007bff;color:#fff;text-align:center;padding:10px;">` +
`<small>&copy; 2024 TransmitirAS. Todos los derechos reservados.</small></div>`;
/** Envuelve el contenido en el mismo cascarón del legacy (Arial, logo centrado, pie). */
function renderSyncEmail(inner: string): string {
return `<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f8fafc;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;padding:24px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1);">
<!-- Encabezado -->
<tr>
<td style="background:#b91c1c;padding:20px 24px;">
<p style="margin:0;font-family:sans-serif;font-size:18px;font-weight:700;color:#ffffff;">
Alerta de respaldo &mdash; Aduanasoft
</p>
<p style="margin:4px 0 0;font-family:sans-serif;font-size:12px;color:#fecaca;">
${alerts.length} base${alerts.length !== 1 ? 's' : ''} de datos sin restaurar recientemente
</p>
</td>
</tr>
<!-- Tabla de alertas -->
<tr>
<td style="padding:20px 24px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;">
<thead>
<tr style="background:#f1f5f9;">
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Base de datos</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Cliente</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:left;text-transform:uppercase;">Última restauración</th>
<th style="padding:8px 12px;font-family:sans-serif;font-size:11px;font-weight:600;color:#64748b;text-align:center;text-transform:uppercase;">Días sin sync</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</td>
</tr>
<!-- Pie -->
<tr>
<td style="padding:12px 24px 20px;border-top:1px solid #f1f5f9;">
<p style="margin:0;font-family:sans-serif;font-size:11px;color:#94a3b8;">
Este mensaje fue generado automáticamente por el Panel de Control de Bases de Datos Aduanasoft.
Por favor no responda a este correo.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
<body style="margin:0;padding:0;background:#ffffff;">
<div style="font-family:Arial,sans-serif;line-height:1.5;color:#333;">
<div style="text-align:center;margin-bottom:20px;">
<img src="${LOGO_URL}" alt="Logo" style="max-width:150px;">
</div>
${inner}
${FOOTER}
</div>
</body>
</html>`;
}
/** Fecha en español "28 de junio de 2026, 17:26" (o "No disponible"), como el strftime del legacy. */
export function formatFechaEs(iso: string | null | undefined): string {
if (!iso) return 'No disponible';
// Normaliza microsegundos (a24c manda isoformat con 6 dígitos) a milisegundos.
const cleaned = String(iso).replace(/(\.\d{3})\d+/, '$1');
const d = new Date(cleaned);
if (isNaN(d.getTime())) return 'No disponible';
const meses = [
'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'
];
const dd = String(d.getDate()).padStart(2, '0');
const mm = meses[d.getMonth()];
const yyyy = d.getFullYear();
const HH = String(d.getHours()).padStart(2, '0');
const MM = String(d.getMinutes()).padStart(2, '0');
return `${dd} de ${mm} de ${yyyy}, ${HH}:${MM}`;
}
/** Correo de alerta de sincronización (kind=overdue), 1:1 con el legacy index.php. */
export function buildBackupAlertHtml(alerts: BackupAlertRow[]): string {
const a = alerts[0];
const nombre = escHtml(a?.clientName ?? a?.visible_name ?? '');
const bd = escHtml(a?.visible_name ?? '');
const fecha = escHtml(formatFechaEs(a?.last_restore_date));
const inner = `
<h2 style="color:#007bff;text-align:center;">Notificación de Sincronización</h2>
<p>Estimado <strong>${nombre}</strong>,</p>
<p>Detectamos que la base de datos <strong>${bd}</strong> no se ha sincronizado correctamente en las últimas 24 horas.</p>
<p>La última restauración registrada fue: <strong>${fecha}</strong>.</p>
${SCAIIWEB_H4}
<p>Por favor, recuerde nunca cerrar la aplicación ni apagar su equipo. Revise la conexión e intente realizar una sincronización manual desde el botón Backup manual, o contacte al soporte técnico si es necesario.</p>`;
return renderSyncEmail(inner);
}
/** Correo de "sincronización restablecida" (kind=resolved): misma plantilla legacy, mensaje positivo. */
export function buildBackupResolvedHtml(alerts: BackupAlertRow[]): string {
const a = alerts[0];
const nombre = escHtml(a?.clientName ?? a?.visible_name ?? '');
const bd = escHtml(a?.visible_name ?? '');
const fecha = escHtml(formatFechaEs(a?.last_restore_date));
const inner = `
<h2 style="color:#28a745;text-align:center;">Sincronización Restablecida</h2>
<p>Estimado <strong>${nombre}</strong>,</p>
<p>La base de datos <strong>${bd}</strong> volvió a sincronizarse correctamente.</p>
<p>La última restauración registrada fue: <strong>${fecha}</strong>.</p>
${SCAIIWEB_H4}
<p>No se requiere ninguna acción de su parte. Gracias por mantener su equipo y la aplicación en funcionamiento.</p>`;
return renderSyncEmail(inner);
}
export interface BackupAlertRow {
visible_name: string;
clientName: string | null;

View File

@@ -0,0 +1,103 @@
/**
* Descarga de respaldos por restaurador, con ruta RELATIVA y SIN rutas fijas.
*
* La base de cada restaurador se DERIVA al vuelo de su Entrada reportada
* (`cloudrestore_status.input_folder`): hermana `Procesados`/`Fallados` (layout por defecto del
* install). El cliente solo envía `target` + `kind` + `relPath`; el servidor arma `base + relPath`
* y lo sirve leyendo la carpeta por filesystem (local o montada como share). CloudRestoreAS no
* reporta ni configura nada nuevo.
*/
import fs from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { deriveSiblingFolder } from './backup-files';
import { getRestoreTargetForDownload, type RestoreTargetDownload } from './controldesk-pg';
export type BackupKind = 'procesados' | 'fallados';
export class BackupDownloadError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'BackupDownloadError';
}
}
function baseSeparator(p: string): '\\' | '/' {
return p.includes('\\') ? '\\' : '/';
}
/** Carpeta base (procesados/fallados) del restaurador, SIEMPRE derivada de su Entrada. */
function resolveBaseFolder(t: RestoreTargetDownload, kind: BackupKind): string | null {
const input = t.input_folder;
if (!input) return null;
return deriveSiblingFolder(input, kind === 'procesados' ? 'Procesados' : 'Fallados');
}
/** Valida la ruta relativa: sin '..', sin absoluto, sin componentes vacíos. */
export function sanitizeRelPath(relPath: string): string | null {
const rel = String(relPath ?? '')
.replace(/\\/g, '/')
.trim();
if (!rel) return null;
if (rel.startsWith('/') || /^[a-zA-Z]:/.test(rel)) return null; // absoluto
const parts = rel.split('/');
if (parts.some((p) => p === '' || p === '.' || p === '..')) return null;
return parts.join('/');
}
function localJoin(base: string, rel: string): string {
const sep = baseSeparator(base);
const b = base.replace(/[\\/]+$/, '');
const relNative = rel.split('/').join(sep);
return `${b}${sep}${relNative}`;
}
export interface BackupDownload {
body: ReadableStream<Uint8Array>;
size: number | null;
filename: string;
cleanup: () => void;
}
/**
* Abre el stream de descarga de un respaldo desde la carpeta de procesados/fallados del
* restaurador (por filesystem). Lanza BackupDownloadError con el código HTTP apropiado.
*/
export async function openBackupDownload(
targetId: number,
kind: BackupKind,
relPathRaw: string
): Promise<BackupDownload> {
const rel = sanitizeRelPath(relPathRaw);
if (!rel) throw new BackupDownloadError(400, 'Ruta de archivo inválida');
const target = await getRestoreTargetForDownload(targetId);
if (!target) throw new BackupDownloadError(404, 'Restaurador no encontrado');
const base = resolveBaseFolder(target, kind);
if (!base) throw new BackupDownloadError(409, 'El restaurador aún no reportó su carpeta de entrada');
const filename = rel.split('/').pop() as string;
const localPath = localJoin(base, rel);
let st;
try {
st = await fs.stat(localPath);
} catch {
throw new BackupDownloadError(404, 'Archivo de respaldo no encontrado o carpeta inaccesible');
}
if (!st.isFile()) {
throw new BackupDownloadError(404, 'La ruta indicada no es un archivo');
}
const node = createReadStream(localPath, { highWaterMark: 1024 * 1024 });
return {
body: Readable.toWeb(node) as unknown as ReadableStream<Uint8Array>,
size: st.size,
filename,
cleanup: () => node.destroy()
};
}

View File

@@ -0,0 +1,131 @@
/**
* Escaneo del inventario de restauraciones LEYENDO las carpetas que usa CloudRestoreAS.
*
* Por cada restaurador toma la Entrada (`input_folder`) que ya reporta en `cloudrestore_status`,
* DERIVA sus hermanas `Procesados`/`Fallados` (deriveSiblingFolder) y las LEE por filesystem
* (listBackupFiles). Mapea cada archivo a su nodo (matchNodeRowFromBackupStem) y arma:
* - restaurados (de Procesados) → además hace upsert en `node_last_restore` (cache durable que
* preserva el "último respaldo por nodo" aunque se reasigne el restaurador o una carpeta quede
* temporalmente ilegible).
* - fallidos (de Fallados) → enriquecidos con `error_message` de `restore_job_logs` por filename.
*
* No requiere que CloudRestoreAS reporte nada nuevo: solo usa el `input_folder` ya reportado.
*/
import path from 'node:path';
import { deriveSiblingFolder, listBackupFiles } from './backup-files';
import {
listRestoreTargets,
listCloudRestoreStatuses,
listNodesForAssignment,
matchNodeRowFromBackupStem,
upsertNodeLastRestore,
listNodeLastRestore,
listFailedRestoreJobLogs,
type NodeLastRestoreRow
} from './controldesk-pg';
export interface FailedItem {
id: string; // clave estable para la UI: `${restore_target_id}:${rel_path}`
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;
size_bytes: number;
error_message: string | null;
restored_at: Date;
}
export interface RestoreInventory {
restored: NodeLastRestoreRow[];
failed: FailedItem[];
}
const SCAN_OPTS = { maxDepth: 3, maxFiles: 5000 } as const;
/** Escanea las carpetas de todos los restauradores y devuelve restaurados + fallidos. */
export async function scanRestoreInventory(): Promise<RestoreInventory> {
const [targets, statuses, nodes] = await Promise.all([
listRestoreTargets(),
listCloudRestoreStatuses(),
listNodesForAssignment()
]);
// instance_key (case-insensitive) → input_folder reportado.
const inputByInstance = new Map<string, string>();
for (const s of statuses) {
if (s.input_folder) inputByInstance.set(s.instance_key.trim().toLowerCase(), s.input_folder);
}
const failed: FailedItem[] = [];
for (const t of targets) {
const input = inputByInstance.get(String(t.name).trim().toLowerCase());
if (!input) continue; // este restaurador aún no reportó su Entrada
// Procesados → upsert del último respaldo por nodo.
try {
const procesados = deriveSiblingFolder(input, 'Procesados');
const { files } = await listBackupFiles(procesados, SCAN_OPTS);
for (const f of files) {
const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes);
if (match && match.ID != null) {
await upsertNodeLastRestore({
databaseNodeId: Number(match.ID),
restoreTargetId: t.id,
dbName: (match.BDName as string) ?? null,
nodeKey: (match.NodoSubNodo as string) ?? null,
filename: f.name,
relPath: f.relPath,
sizeBytes: f.sizeBytes,
restoredAt: new Date(f.mtimeMs)
});
}
}
} catch {
/* Carpeta Procesados ilegible: se conserva lo cacheado en node_last_restore. */
}
// Fallados → lista viva (con enriquecimiento de error más abajo).
try {
const fallados = deriveSiblingFolder(input, 'Fallados');
const { files } = await listBackupFiles(fallados, SCAN_OPTS);
for (const f of files) {
const match = matchNodeRowFromBackupStem(path.parse(f.name).name, nodes);
failed.push({
id: `${t.id}:${f.relPath}`,
restore_target_id: t.id,
server_name: t.name,
node_key: (match?.NodoSubNodo as string) ?? null,
client_name: (match?.Nombre as string) ?? null,
db_name: (match?.BDName as string) ?? null,
filename: f.name,
rel_path: f.relPath,
size_bytes: f.sizeBytes,
error_message: null,
restored_at: new Date(f.mtimeMs)
});
}
} catch {
/* Carpeta Fallados ilegible: se omite este restaurador en fallidos. */
}
}
// Enriquecer fallidos con el error reportado (restore_job_logs) por filename.
try {
const logs = await listFailedRestoreJobLogs(500);
const errByFile = new Map<string, string | null>();
for (const l of logs) errByFile.set(l.filename.trim().toLowerCase(), l.error_message);
for (const f of failed) {
f.error_message = errByFile.get(f.filename.trim().toLowerCase()) ?? null;
}
} catch {
/* sin bitácora: los fallidos se listan sin mensaje de error */
}
// Restaurados = estado durable por nodo (recién refrescado por el escaneo).
const restored = await listNodeLastRestore();
return { restored, failed };
}