From d2c79aeddc3e9aa819fa04f4d6bc85d5c787bed8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 19:37:54 -0300 Subject: [PATCH 1/3] maintenance: agregar fresh-start protegido en modo preview --- api-v3/src/cli/fresh-start-reset.ts | 247 ++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 api-v3/src/cli/fresh-start-reset.ts diff --git a/api-v3/src/cli/fresh-start-reset.ts b/api-v3/src/cli/fresh-start-reset.ts new file mode 100644 index 0000000..8791617 --- /dev/null +++ b/api-v3/src/cli/fresh-start-reset.ts @@ -0,0 +1,247 @@ +import 'reflect-metadata'; + +import { promises as fs } from 'node:fs'; +import { migrationDataSource } from '../database/data-source'; + +const APPLY_TOKEN = 'DHV2-FRESH-START-20260905'; + +const PRESERVED_TABLES = new Set([ + 'typeorm_migrations', + 'spatial_ref_sys', + 'users', + 'user_roles', + 'roles', + 'role_permissions', + 'permissions', + 'asset_types', + 'asset_type_parent_rules', + 'asset_attribute_definitions', + 'finding_categories', + 'finding_catalog_items', + 'finding_catalog_item_asset_types', + 'finding_catalog_asset_type_profiles', +]); + +type AdminRow = { + id: string; + username: string; + email: string | null; + status: string; +}; + +type TableRow = { table_name: string }; +type CountRow = { count: string | number }; + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +function requestedMode(): 'preview' | 'apply' { + const args = process.argv.slice(2); + if (args.length === 1 && args[0] === '--preview') return 'preview'; + if (args.length === 2 && args[0] === '--apply' && args[1] === APPLY_TOKEN) { + return 'apply'; + } + throw new Error( + `Uso inválido. Permitido: --preview o --apply ${APPLY_TOKEN}`, + ); +} + +async function rowCount(table: string): Promise { + const result = (await migrationDataSource.query( + `SELECT COUNT(*)::bigint AS count FROM public.${quoteIdentifier(table)}`, + )) as CountRow[]; + return Number(result[0]?.count ?? 0); +} + +async function clearMediaStorage(): Promise { + const root = process.env.ASSET_MEDIA_ROOT; + if (!root) { + process.stdout.write('MEDIA: ASSET_MEDIA_ROOT no configurado; no se tocaron archivos.\n'); + return; + } + + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + for (const entry of entries) { + await fs.rm(`${root}/${entry.name}`, { recursive: true, force: true }); + } + await fs.mkdir(`${root}/imports`, { recursive: true }); + process.stdout.write(`MEDIA: almacenamiento limpiado en ${root}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stdout.write(`MEDIA WARNING: no se pudo limpiar completamente ${root}: ${message}\n`); + } +} + +async function main(): Promise { + const mode = requestedMode(); + await migrationDataSource.initialize(); + + try { + await migrationDataSource.query( + `SELECT pg_advisory_lock(hashtext('dhv2-fresh-start-reset'))`, + ); + + const admins = (await migrationDataSource.query(` + SELECT DISTINCT user_row.id, user_row.username, user_row.email, user_row.status + FROM users user_row + INNER JOIN user_roles user_role ON user_role.user_id = user_row.id + INNER JOIN roles role ON role.id = user_role.role_id + WHERE role.code = 'admin' + ORDER BY user_row.username + `)) as AdminRow[]; + + if (admins.length !== 1) { + throw new Error( + `Se esperaba exactamente 1 usuario con rol admin y se encontraron ${admins.length}. Limpieza abortada.`, + ); + } + + const admin = admins[0]; + const tables = (await migrationDataSource.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name + `)) as TableRow[]; + + const existingTables = tables.map((row) => row.table_name); + const tablesToClear = existingTables.filter( + (table) => !PRESERVED_TABLES.has(table), + ); + const tablesToPreserve = existingTables.filter((table) => + PRESERVED_TABLES.has(table), + ); + + process.stdout.write('\n============================================================\n'); + process.stdout.write(` DH V2 · FRESH START · ${mode.toUpperCase()}\n`); + process.stdout.write('============================================================\n'); + process.stdout.write( + `ADMIN PRESERVADO: ${admin.username} · ${admin.email ?? 'sin email'} · ${admin.id}\n`, + ); + + process.stdout.write('\n========== TABLAS ESTRUCTURALES CONSERVADAS ==========\n'); + for (const table of tablesToPreserve) { + process.stdout.write(`KEEP ${table.padEnd(46)} ${await rowCount(table)}\n`); + } + + process.stdout.write('\n========== DATOS A ELIMINAR ==========\n'); + let rowsToDelete = 0; + for (const table of tablesToClear) { + const count = await rowCount(table); + rowsToDelete += count; + process.stdout.write(`CLEAR ${table.padEnd(46)} ${count}\n`); + } + + const userCount = await rowCount('users'); + const nonAdminUsers = Math.max(0, userCount - 1); + process.stdout.write(`CLEAR ${'users (excepto admin)'.padEnd(46)} ${nonAdminUsers}\n`); + rowsToDelete += nonAdminUsers; + process.stdout.write(`TOTAL FILAS OPERATIVAS/USUARIOS A RETIRAR: ${rowsToDelete}\n`); + + if (mode === 'preview') { + process.stdout.write('\nPREVIEW_OK: no se modificó ningún dato.\n'); + return; + } + + await migrationDataSource.transaction(async (manager) => { + await manager.query(`SET LOCAL session_replication_role = replica`); + + for (const table of tablesToClear) { + await manager.query(`DELETE FROM public.${quoteIdentifier(table)}`); + } + + await manager.query( + `DELETE FROM user_roles WHERE user_id <> $1::uuid`, + [admin.id], + ); + await manager.query( + ` + DELETE FROM user_roles user_role + USING roles role + WHERE user_role.user_id = $1::uuid + AND role.id = user_role.role_id + AND role.code <> 'admin' + `, + [admin.id], + ); + await manager.query(`DELETE FROM users WHERE id <> $1::uuid`, [admin.id]); + await manager.query( + ` + UPDATE users + SET status = 'ACTIVE', + failed_login_attempts = 0, + locked_until = NULL, + created_by = CASE WHEN created_by = $1::uuid THEN created_by ELSE NULL END, + updated_by = $1::uuid, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1::uuid + `, + [admin.id], + ); + await manager.query( + ` + UPDATE user_roles user_role + SET assigned_by = $1::uuid + FROM roles role + WHERE user_role.user_id = $1::uuid + AND role.id = user_role.role_id + AND role.code = 'admin' + `, + [admin.id], + ); + }); + + const usersAfter = await rowCount('users'); + if (usersAfter !== 1) { + throw new Error(`Verificación falló: users=${usersAfter}, esperado=1`); + } + + const adminAfter = (await migrationDataSource.query( + ` + SELECT COUNT(DISTINCT user_row.id)::integer AS count + FROM users user_row + INNER JOIN user_roles user_role ON user_role.user_id = user_row.id + INNER JOIN roles role ON role.id = user_role.role_id + WHERE role.code = 'admin' + `, + )) as CountRow[]; + if (Number(adminAfter[0]?.count ?? 0) !== 1) { + throw new Error('Verificación falló: el administrador no quedó correctamente asignado'); + } + + for (const table of tablesToClear) { + const count = await rowCount(table); + if (count !== 0) { + throw new Error(`Verificación falló: ${table} conserva ${count} fila(s)`); + } + } + + await clearMediaStorage(); + + process.stdout.write('\n========== VERIFICACIÓN FRESH START ==========\n'); + process.stdout.write(`Usuarios: 1 (${admin.username})\n`); + process.stdout.write('Datos operativos/importados: 0\n'); + process.stdout.write('Roles/permisos/tipos/catálogos estructurales: conservados\n'); + process.stdout.write('FRESH_START_OK\n'); + } finally { + if (migrationDataSource.isInitialized) { + try { + await migrationDataSource.query( + `SELECT pg_advisory_unlock(hashtext('dhv2-fresh-start-reset'))`, + ); + } catch { + // La conexión se destruirá de todas formas. + } + await migrationDataSource.destroy(); + } + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`FRESH_START_ERROR: ${message}\n`); + process.exitCode = 1; +}); From 2f0d0d5ffc0a860a77c669c8ce7ee53d87023c69 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 19:38:08 -0300 Subject: [PATCH 2/3] maintenance: exponer comando fresh-start --- api-v3/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/api-v3/package.json b/api-v3/package.json index 0550c0d..e4085d5 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -13,6 +13,7 @@ "migration:show": "node dist/database/migration-cli.js show", "migration:revert": "node dist/database/migration-cli.js revert", "bootstrap:admin": "node dist/cli/bootstrap-admin.js", + "maintenance:fresh-start": "node dist/cli/fresh-start-reset.js", "dev:seed:mendoza-demo": "node dist/cli/dev-seed-mendoza-demo.js" }, "dependencies": { From 95f34e2f08261ace1cb981be75391475722fa040 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 19:39:01 -0300 Subject: [PATCH 3/3] maintenance: enlazar preview al paso de migraciones --- api-v3/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-v3/package.json b/api-v3/package.json index e4085d5..03d56d6 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -9,7 +9,7 @@ "start:dev": "nest start --watch", "typecheck": "tsc --noEmit", "test": "tsc -p tsconfig.test.json --noEmit && node --import tsx --test test/**/*.test.ts", - "migration:run": "node dist/database/migration-cli.js run", + "migration:run": "node dist/database/migration-cli.js run && node dist/cli/fresh-start-reset.js --preview", "migration:show": "node dist/database/migration-cli.js show", "migration:revert": "node dist/database/migration-cli.js revert", "bootstrap:admin": "node dist/cli/bootstrap-admin.js",