369 lines
13 KiB
JavaScript
369 lines
13 KiB
JavaScript
'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 };
|