'use strict'; /** * Rollback Migration Script * * This script reverts the last customer migration by reading the migration_history.json * and reversing all changes. * * Rollback Operations: * - migrate_products/crops: Restores byPuid to original parent * - If --reuse-existing-entities was used, also recreates deleted entities * - migrate_jobs: Restores byPuid to original parent * - migrate_invoices: Restores each invoice to its original byPuid * - update_parent_reference: Restores sub-account parent references * - convert_customer_to_admin: Restores kind and parent fields * - deactivate_source_customer: Restores customer to active state * * Usage: * node scripts/rollbackMigration.js * node scripts/rollbackMigration.js --env ../environment_prod.env * * Options: * --env Custom environment file path (default: ../environment.env) */ const path = require('path'); const args = process.argv.slice(2); const envArgIndex = args.indexOf('--env'); const customEnvPath = envArgIndex !== -1 && args[envArgIndex + 1] ? args[envArgIndex + 1] : null; const envPath = customEnvPath ? (path.isAbsolute(customEnvPath) ? customEnvPath : path.join(process.cwd(), customEnvPath)) : path.join(__dirname, '../environment.env'); console.log(`Loading environment from: ${envPath}`); require('dotenv').config({ path: envPath }); const debug = require('debug')('agm:rollback-migration'); const mongoose = require('mongoose'); const { DBConnection } = require('../helpers/db/connect'); const mongoEnhanced = require('../helpers/mongo_enhanced'); const fs = require('fs').promises; // Import models const models = { User: require('../model/user'), Customer: require('../model/customer'), Job: require('../model/job'), Product: require('../model/product'), Crop: require('../model/crop'), CostingItem: require('../model/costing_items'), Invoice: require('../model/invoice') }; const DEFAULT_HISTORY_FILE = path.join(__dirname, '../migration_history.json'); /** * Load the last migration from history file */ async function loadLastMigration(historyFile) { try { const content = await fs.readFile(historyFile, 'utf8'); const migrations = JSON.parse(content); if (!Array.isArray(migrations) || migrations.length === 0) { throw new Error('No migrations found in history file'); } // Get the last migration const lastMigration = migrations[migrations.length - 1]; if (!lastMigration.completedAt) { throw new Error('Last migration has not been completed yet'); } return lastMigration; } catch (error) { throw new Error(`Failed to read migration history: ${error.message}`); } } /** * Rollback the migration */ async function rollbackMigration(migrationRecord) { console.log('\n================================================================================'); console.log('ROLLBACK MIGRATION'); console.log('================================================================================\n'); console.log(`Migration Date: ${migrationRecord.timestamp}`); console.log(`Completed At: ${migrationRecord.completedAt}`); console.log(`Source Customer IDs: ${migrationRecord.sourceCustomerIds.join(', ')}`); console.log(`Target Customer ID: ${migrationRecord.targetCustomerId}`); console.log('\nReverting changes...\n'); await mongoEnhanced.enhancedRunInTransaction(async (session) => { debug('Starting rollback transaction...'); const changes = migrationRecord.details.changes; let revertedCount = 0; // Process changes in reverse order for (let i = changes.length - 1; i >= 0; i--) { const change = changes[i]; switch (change.action) { case 'deactivate_source_customer': console.log(` → Reactivating source customer: ${change.customerId}`); await models.User.updateOne( { _id: change.customerId }, { $set: { username: change.oldUsername, markedDelete: false, active: true } }, { session } ); revertedCount++; break; case 'delete_partner_system_users': console.log(` → Restoring ${change.count} deleted partner system users`); if (change.deletedUsers && change.deletedUsers.length > 0) { for (const userData of change.deletedUsers) { // Check if user already exists before trying to restore const existingUser = await models.User.findById(userData._id).session(session); if (!existingUser) { // Remove version key and other mongoose internals const { __v, ...cleanData } = userData; await models.User.create([cleanData], { session }); } else { console.log(` → Partner system user ${userData.username || userData.partnerUsername} already exists, skipping restore`); } } } revertedCount++; break; case 'update_parent_reference': console.log(` → Reverting parent reference for account: ${change.accountId}`); await models.User.updateOne( { _id: change.accountId }, { $set: { parent: change.fromParent } }, { session } ); revertedCount++; break; case 'migrate_products': console.log(` → Reverting ${change.count} products`); // Restore migrated products to original parent if (change.productIds && change.productIds.length > 0) { // Use specific product IDs to revert only the migrated products await models.Product.updateMany( { _id: { $in: change.productIds } }, { $set: { byPuid: change.oldParent } }, { session } ); } // Restore deleted products (that were replaced by destination entities) if (change.deletedProducts && change.deletedProducts.length > 0) { console.log(` → Restoring ${change.deletedProducts.length} deleted products`); for (const productData of change.deletedProducts) { // Check if product already exists before trying to restore const existingProduct = await models.Product.findById(productData._id).session(session); if (!existingProduct) { // Remove version key and other mongoose internals const { __v, ...cleanData } = productData; await models.Product.create([cleanData], { session }); } else { console.log(` → Product ${productData.name} already exists, skipping restore`); } } } revertedCount++; break; case 'migrate_crops': console.log(` → Reverting ${change.count} crops`); // Restore migrated crops to original parent if (change.cropIds && change.cropIds.length > 0) { // Use specific crop IDs to revert only the migrated crops await models.Crop.updateMany( { _id: { $in: change.cropIds } }, { $set: { byPuid: change.oldParent } }, { session } ); } // Restore deleted crops (that were replaced by destination entities) if (change.deletedCrops && change.deletedCrops.length > 0) { console.log(` → Restoring ${change.deletedCrops.length} deleted crops`); for (const cropData of change.deletedCrops) { // Check if crop already exists before trying to restore const existingCrop = await models.Crop.findById(cropData._id).session(session); if (!existingCrop) { // Remove version key and other mongoose internals const { __v, ...cleanData } = cropData; await models.Crop.create([cleanData], { session }); } else { console.log(` → Crop ${cropData.name} already exists, skipping restore`); } } } revertedCount++; break; case 'migrate_costing_items': console.log(` → Reverting ${change.count} costing items`); if (change.costingItemIds && change.costingItemIds.length > 0) { // Use specific item IDs to revert only the migrated items await models.CostingItem.updateMany( { _id: { $in: change.costingItemIds } }, { $set: { byPuid: change.oldParent } }, { session } ); } revertedCount++; break; case 'migrate_jobs': console.log(` → Reverting ${change.jobCount} jobs`); if (change.jobIds && change.jobIds.length > 0) { // Use specific job IDs to revert only the migrated jobs await models.Job.updateMany( { _id: { $in: change.jobIds } }, { $set: { byPuid: change.oldParent } }, { session } ); } revertedCount++; break; case 'migrate_invoices': console.log(` → Reverting ${change.count} invoices`); if (change.invoiceIds && change.invoiceIds.length > 0) { // Restore each invoice to its original customer (byPuid field) for (let i = 0; i < change.invoiceIds.length; i++) { const invoiceId = change.invoiceIds[i]; const oldCustomerId = change.oldCustomerIds[i]; if (oldCustomerId) { await models.Invoice.updateOne( { _id: invoiceId }, { $set: { byPuid: oldCustomerId } }, { session } ); } } } revertedCount++; break; case 'convert_customer_to_admin': console.log(` → Reverting admin conversion for customer: ${change.customerId || change.sourceCustomerId}`); const customerId = change.customerId || change.sourceCustomerId; const originalKind = change.originalKind || '1'; // Default to APP (1) if not specified const originalParent = change.originalParent || null; // Handle old format where a new admin user was created if (change.newAdminId) { // Old format: delete the admin user that was created await models.User.deleteOne( { _id: change.newAdminId }, { session } ); } // Use direct MongoDB collection update to bypass Mongoose discriminator protection // This is necessary because 'kind' is a discriminator key and can't be changed via Mongoose const updateDoc = { $set: { kind: originalKind, parent: originalParent, active: true, markedDelete: false } }; // Only add unset if there was a temp username if (change.oldUsername) { updateDoc.$unset = { username: 1 }; } await models.Customer.collection.updateOne( { _id: mongoose.Types.ObjectId(customerId) }, updateDoc, { session } ); // If there was an old username (temp), restore the original if (change.oldUsername && change.username) { await models.Customer.collection.updateOne( { _id: mongoose.Types.ObjectId(customerId) }, { $set: { username: change.username } }, { session } ); } revertedCount++; break; default: console.log(` ⚠ Unknown action: ${change.action}`); } } console.log(`\n✅ Reverted ${revertedCount} changes successfully`); debug('Rollback transaction completed successfully'); }, mongoEnhanced.DEFAULT_TRANSACTION_OPTIONS); } /** * Main execution */ async function main() { try { // Initialize database connection console.log('Connecting to database...'); const dbConn = new DBConnection('Rollback Migration'); await dbConn.connect(); console.log('✓ Database connected\n'); // Load the last migration console.log(`Loading migration history from: ${DEFAULT_HISTORY_FILE}`); const migrationRecord = await loadLastMigration(DEFAULT_HISTORY_FILE); if (migrationRecord.status !== 'completed') { throw new Error(`Last migration status is '${migrationRecord.status}', not 'completed'. Cannot rollback.`); } // Confirm with user console.log('\n⚠️ WARNING: This will revert all changes from the last migration!'); console.log('Press Ctrl+C to cancel, or wait 3 seconds to continue...\n'); await new Promise(resolve => setTimeout(resolve, 3000)); // Execute rollback await rollbackMigration(migrationRecord); console.log('\n✅ Rollback completed successfully!\n'); // Close database connection await mongoose.connection.close(); process.exit(0); } catch (error) { console.error('\n❌ Rollback failed:', error.message); debug('Error:', error); if (mongoose.connection.readyState === 1) { await mongoose.connection.close(); } process.exit(1); } } // Command-line execution if (require.main === module) { main(); } module.exports = { rollbackMigration, loadLastMigration };