diff --git a/api-v3/package.json b/api-v3/package.json index 3d2bdf4..0550c0d 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -9,11 +9,10 @@ "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 && node dist/cli/fresh-start-reset.js --apply DHV2-FRESH-START-20260905", + "migration:run": "node dist/database/migration-cli.js run", "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": { diff --git a/api-v3/src/cli/fresh-start-reset.ts b/api-v3/src/cli/fresh-start-reset.ts deleted file mode 100644 index 6ec6a82..0000000 --- a/api-v3/src/cli/fresh-start-reset.ts +++ /dev/null @@ -1,267 +0,0 @@ -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; -}); diff --git a/scripts/deploy-github.sh b/scripts/deploy-github.sh index e31edfb..3328a4c 100644 --- a/scripts/deploy-github.sh +++ b/scripts/deploy-github.sh @@ -114,7 +114,7 @@ rollback() { echo if [ -d "$BACKUP" ]; then echo "Backup PRE disponible en: $BACKUP" - echo "database-before.dump y asset-media-before.tar.gz permiten una restauración manual integral si hiciera falta." + echo "Las migraciones son forward-only; database-before.dump queda disponible para restauración manual si hiciera falta." else echo "No fue necesario crear backup PRE: el fallo ocurrió durante el preflight del candidato, antes de tocar producción." fi @@ -207,15 +207,9 @@ install -m 600 .env "$BACKUP/.env" git rev-parse HEAD > "$BACKUP/previous.sha" printf '%s\n' "$TARGET_SHA" > "$BACKUP/target.sha" docker compose ps -a > "$BACKUP/docker-before.txt" - -echo "Respaldando volumen de fotos/importaciones..." -docker compose run --rm --no-deps --user root \ - -v "$BACKUP:/backup" \ - api sh -lc 'tar -czf /backup/asset-media-before.tar.gz -C /app/storage/asset-media .' - ( cd "$BACKUP" - sha256sum database-before.dump source-before.tar.gz asset-media-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt + sha256sum database-before.dump source-before.tar.gz .env previous.sha target.sha docker-before.txt > SHA256SUMS.txt sha256sum -c SHA256SUMS.txt ) chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true @@ -234,31 +228,11 @@ docker compose build api migrate web /dev/null | wc -l | tr -d " ")" - echo "files_before=$before" - for path in "$ROOT"/* "$ROOT"/.[!.]* "$ROOT"/..?*; do - [ -e "$path" ] || continue - rm -rf -- "$path" - done - mkdir -p "$ROOT/imports" - chown -R node:node "$ROOT" - after="$(find "$ROOT" -type f 2>/dev/null | wc -l | tr -d " ")" - echo "files_after=$after" - [ "$after" = "0" ] -' | tee "$BACKUP/media-clean.txt" -grep -Fq 'files_after=0' "$BACKUP/media-clean.txt" - PHASE="recreate" echo echo "========== RECREATE API + WEB ==========" @@ -310,7 +284,7 @@ git rev-parse HEAD > "$BACKUP/deployed.sha" printf 'API=%s\nWEB=%s\n' "$EXPECTED_API_VERSION" "$EXPECTED_WEB_VERSION" > "$BACKUP/deployed-versions.txt" ( cd "$BACKUP" - sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt media-clean.txt docker-after.txt >> SHA256SUMS.txt + sha256sum database-after.dump deployed.sha deployed-versions.txt health.json migrations.txt docker-after.txt >> SHA256SUMS.txt sha256sum -c SHA256SUMS.txt ) chmod 600 "$BACKUP"/* "$BACKUP/.env" 2>/dev/null || true