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_versions', 'finding_catalog_item_asset_types', 'finding_catalog_asset_type_profiles', 'institutional_delivery_settings', ]); 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('RESET institutional_delivery_settings emails/updated_by\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) => { // Se vacía el grafo operacional completo sin depender del orden físico // de las FK. La estructura maestra está excluida por PRESERVED_TABLES. await manager.query(`SET LOCAL session_replication_role = replica`); for (const table of tablesToClear) { await manager.query(`DELETE FROM public.${quoteIdentifier(table)}`); } // Antes de tocar cualquier tabla preservada reactivamos las FK reales. // Si apareciera una dependencia RESTRICT inesperada, PostgreSQL aborta // la transacción completa en lugar de dejar referencias huérfanas. await manager.query(`SET LOCAL session_replication_role = origin`); await manager.query(` UPDATE institutional_delivery_settings SET office_email = NULL, director_email = NULL, updated_by = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = 1 `); 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álogo base: conservados\n'); process.stdout.write('Configuración institucional editable: reiniciada\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; });