import { MigrationInterface, QueryRunner } from 'typeorm'; /** * One-time production reset requested before the first clean Android rollout. * * Keeps only structural product configuration plus the single `admin` account. * Operational/business data is removed. Recovery is intentionally performed * from the deploy PRE backup, not through a synthetic down migration. */ export class ResetProductionOperationalData1788652800000 implements MigrationInterface { name = 'ResetProductionOperationalData1788652800000'; public async up(queryRunner: QueryRunner): Promise { const adminRows: Array<{ id: string; username: string }> = await queryRunner.query(` SELECT id, username FROM users WHERE lower(trim(username)) = 'admin' ORDER BY id `); if (adminRows.length !== 1) { throw new Error( `Production reset aborted: expected exactly one username admin, found ${adminRows.length}`, ); } const adminId = adminRows[0].id; const structuralTables = [ 'roles', 'permissions', 'role_permissions', 'asset_types', 'asset_attribute_definitions', 'asset_type_parent_rules', 'finding_categories', 'finding_catalog_items', 'finding_catalog_item_asset_types', 'finding_catalog_asset_type_profiles', ]; // Snapshot structural row counts so TRUNCATE ... CASCADE can never silently // remove product configuration while still leaving operational tables empty. const structuralCounts = new Map(); for (const table of structuralTables) { const safeTable = `"${table.replace(/"/g, '""')}"`; const rows: Array<{ total: string }> = await queryRunner.query( `SELECT count(*)::text AS total FROM ${safeTable}`, ); structuralCounts.set(table, rows[0]?.total ?? '0'); } const adminRolesBefore: Array<{ total: string }> = await queryRunner.query( `SELECT count(*)::text AS total FROM user_roles WHERE user_id = $1`, [adminId], ); const adminRoleCount = adminRolesBefore[0]?.total ?? '0'; if (adminRoleCount === '0') { throw new Error('Production reset aborted: admin has no assigned role'); } // Product configuration that must survive a clean operational start. const preservedTables = new Set([ 'typeorm_migrations', 'users', 'user_roles', ...structuralTables, ]); const tableRows: Array<{ table_name: string }> = await queryRunner.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name `); const operationalTables = tableRows .map((row) => row.table_name) .filter((table) => !preservedTables.has(table)); if (operationalTables.length > 0) { const quoted = operationalTables .map((table) => `"${table.replace(/"/g, '""')}"`) .join(', '); await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`); } // Remove every user except the explicitly validated administrator. // user_roles for removed users follow their FK cascade. await queryRunner.query(`DELETE FROM users WHERE id <> $1`, [adminId]); // A reset must invalidate every prior login token, including admin's. // auth_sessions is operational and was truncated above; admin simply logs in again. await queryRunner.query( ` UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 `, [adminId], ); const finalUsers: Array<{ total: string; admins: string }> = await queryRunner.query(` SELECT count(*)::text AS total, count(*) FILTER (WHERE lower(trim(username)) = 'admin')::text AS admins FROM users `); if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') { throw new Error('Production reset verification failed: users table is not admin-only'); } const finalAdminRoles: Array<{ total: string; foreign_users: string }> = await queryRunner.query( ` SELECT count(*) FILTER (WHERE user_id = $1)::text AS total, count(*) FILTER (WHERE user_id <> $1)::text AS foreign_users FROM user_roles `, [adminId], ); if ( finalAdminRoles[0]?.total !== adminRoleCount || finalAdminRoles[0]?.foreign_users !== '0' ) { throw new Error('Production reset verification failed: admin role assignments changed'); } // Assert every structural table kept exactly the same number of rows. for (const table of structuralTables) { const safeTable = `"${table.replace(/"/g, '""')}"`; const rows: Array<{ total: string }> = await queryRunner.query( `SELECT count(*)::text AS total FROM ${safeTable}`, ); const before = structuralCounts.get(table) ?? '0'; if (rows[0]?.total !== before) { throw new Error( `Production reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`, ); } } // Assert that every operational table is empty. This makes the migration // fail atomically if a table was repopulated during the reset transaction. for (const table of operationalTables) { const safeTable = `"${table.replace(/"/g, '""')}"`; const rows: Array<{ total: string }> = await queryRunner.query( `SELECT count(*)::text AS total FROM ${safeTable}`, ); if (rows[0]?.total !== '0') { throw new Error(`Production reset verification failed: ${table} is not empty`); } } // Keep a concise server-side record in the migration log for deploy diagnostics. // eslint-disable-next-line no-console console.log( `[production-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} structural tables; cleared ${operationalTables.length} operational tables`, ); } public async down(): Promise { throw new Error( 'ResetProductionOperationalData is irreversible by migration; restore the deploy PRE database backup instead.', ); } }