agmission/Development/server/scripts/migrateCustomerData.js

1455 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* Customer Data Migration Script
*
* This script migrates data from multiple source customer master accounts to a single destination account.
* It updates sub-account parent references rather than moving accounts to avoid username conflicts.
*
* Features:
* - Support multiple source customer IDs or usernames
* - Converts source customers to admin accounts under destination (default behavior)
* - Detailed preview with conflict detection
* - Automatic abort on conflicts using mongo_enhanced.js transaction utilities
* - Store and append migration results to JSON file for history tracking
* - Updates sub-accounts' parent references (no username conflicts)
* - Preserves all references in other collections (JobAssign, JobLog, etc.)
*
* Usage:
* # Preview migration (shows all changes, doesn't make changes)
* node scripts/migrateCustomerData.js --preview --sources acme_aviation,regional_ag --destination consolidated_ag
*
* # With debug output
* DEBUG=agm:migrate-customer-data node scripts/migrateCustomerData.js --preview --sources acme_aviation --destination consolidated_ag
*
* # Execute migration (source customers converted to admin by default)
* node scripts/migrateCustomerData.js --sources acme_aviation,regional_ag --destination consolidated_ag
*
* # Deactivate source customers instead of converting to admin
* node scripts/migrateCustomerData.js --sources acme_aviation --destination consolidated_ag --deactivate-source
*
* Options:
* --preview Show detailed migration plan without executing
* --sources Comma-separated list of source customer IDs or usernames
* --destination Destination customer ID or username
* --convert-to-admin Convert source customers to admin (kind='2') - DEFAULT
* --deactivate-source Deactivate source customers instead of converting to admin
* --reuse-existing-entities Reuse destination's products/crops with matching names
* --skip-invoices Don't migrate invoice references
* --output-file Custom output file path (default: ./migration_history.json)
* --env Custom environment file path (default: ../environment.env)
* --help Show this help message
*
* Example:
* node scripts/migrateCustomerData.js --preview --sources acme_aviation,regional_ag --destination consolidated_ag
* node scripts/migrateCustomerData.js --env ../environment_prod.env --sources acme_aviation --destination consolidated_ag
*/
// Load environment variables from environment.env file (or custom path)
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:migrate-customer-data');
const env = require('../helpers/env');
const mongoose = require('mongoose');
const { DBConnection } = require('../helpers/db/connect');
const mongoEnhanced = require('../helpers/mongo_enhanced');
const fs = require('fs').promises;
const { UserTypes } = require('../helpers/constants');
// Models
const models = {
User: require('../model/user'),
Customer: require('../model/customer'),
Client: require('../model/client'),
Pilot: require('../model/pilot'),
Vehicle: require('../model/vehicle'),
Job: require('../model/job'),
JobLog: require('../model/job_log'),
JobAssign: require('../model/job_assign'),
App: require('../model/application'),
AppFile: require('../model/application_file'),
AppDetail: require('../model/application_detail'),
Product: require('../model/product'),
Crop: require('../model/crop'),
Invoice: require('../model/invoice'),
CostingItem: require('../model/costing_items')
};
// Default output file for migration history
const DEFAULT_OUTPUT_FILE = path.join(__dirname, 'migration_history.json');
/**
* Resolve customer identifiers (username or ID) to customer IDs
* @param {Array<string>} identifiers - Array of customer usernames or IDs
* @returns {Promise<Object>} Object with resolved IDs and any errors
*/
async function resolveCustomerIdentifiers(identifiers) {
const resolved = [];
const errors = [];
for (const identifier of identifiers) {
// Check if it's a valid MongoDB ObjectId format
const isObjectId = /^[0-9a-fA-F]{24}$/.test(identifier);
let customer;
if (isObjectId) {
// Try to find by ID
customer = await models.Customer.findById(identifier).lean();
if (!customer) {
errors.push(`Customer ID not found: ${identifier}`);
}
} else {
// Try to find by username (applicator account, kind='1')
customer = await models.Customer.findOne({
username: identifier,
kind: UserTypes.APP
}).lean();
if (!customer) {
errors.push(`Customer username not found: ${identifier}`);
}
}
if (customer) {
resolved.push({
identifier,
id: customer._id,
username: customer.username,
isUsername: !isObjectId
});
}
}
return { resolved, errors };
}
/**
* Main migration function
* @param {Array<string>} sourceCustomerIdentifiers - Array of source customer IDs or usernames to migrate from
* @param {string} targetCustomerIdentifier - Target customer ID or username to migrate to
* @param {Object} options - Migration options
* @param {boolean} options.preview - Only show what would be migrated
* @param {boolean} options.convertToAdmin - Convert source customers to admin accounts under destination (default: true)
* @param {boolean} options.updateInvoices - Update invoice references
* @param {string} options.outputFile - Path to output JSON file for history
*/
async function migrateCustomerData(sourceCustomerIdentifiers, targetCustomerIdentifier, options = {}) {
const {
preview = false,
convertToAdmin = true, // Default to true
updateInvoices = true,
reuseExistingEntities = false, // New option: reuse products/crops with matching names
outputFile = DEFAULT_OUTPUT_FILE
} = options;
// Resolve identifiers to IDs
debug('Resolving customer identifiers...');
const sourceResolution = await resolveCustomerIdentifiers(sourceCustomerIdentifiers);
if (sourceResolution.errors.length > 0) {
throw new Error(`Failed to resolve source customers:\n ${sourceResolution.errors.join('\n ')}`);
}
const targetResolution = await resolveCustomerIdentifiers([targetCustomerIdentifier]);
if (targetResolution.errors.length > 0) {
throw new Error(`Failed to resolve target customer:\n ${targetResolution.errors.join('\n ')}`);
}
const sourceCustomerIds = sourceResolution.resolved.map(r => r.id);
const targetCustomerId = targetResolution.resolved[0].id;
// Log what was resolved
debug('Resolved customers:');
sourceResolution.resolved.forEach(r => {
debug(` Source: ${r.identifier} ${r.isUsername ? '(username)' : '(ID)'} -> ${r.id} (${r.username})`);
});
const targetInfo = targetResolution.resolved[0];
debug(` Target: ${targetInfo.identifier} ${targetInfo.isUsername ? '(username)' : '(ID)'} -> ${targetInfo.id} (${targetInfo.username})`);
debug(`Starting migration from [${sourceCustomerIds.join(', ')}] to ${targetCustomerId}`);
debug(`Options: preview=${preview}, convertToAdmin=${convertToAdmin}`);
const migrationRecord = {
timestamp: new Date().toISOString(),
sourceCustomerIds,
targetCustomerId,
sourceIdentifiers: sourceCustomerIdentifiers,
targetIdentifier: targetCustomerIdentifier,
options,
preview,
status: 'started',
stats: {
sourceCustomers: {},
totalJobs: 0,
totalClients: 0,
totalPilots: 0,
totalVehicles: 0,
totalOfficers: 0,
totalInspectors: 0,
totalProducts: 0,
totalCrops: 0,
totalApplications: 0,
totalInvoices: 0,
skipped: [],
conflicts: [],
errors: []
},
details: {
sources: [],
conflicts: [],
changes: []
}
};
try {
// Step 1: Validate all customers exist and gather preview data
debug('Step 1: Validating customers and gathering data...');
const validationResult = await validateAndGatherData(
sourceCustomerIds,
targetCustomerId,
migrationRecord
);
if (!validationResult.valid) {
migrationRecord.status = 'validation_failed';
migrationRecord.error = validationResult.error;
await saveMigrationHistory(migrationRecord, outputFile);
throw new Error(validationResult.error);
}
const { sourceCustomers, targetCustomer } = validationResult;
// Step 2: Detect conflicts
debug('Step 2: Detecting conflicts...');
const conflicts = await detectConflicts(
sourceCustomers,
targetCustomer,
migrationRecord
);
migrationRecord.details.conflicts = conflicts;
migrationRecord.stats.conflicts = conflicts;
// If there are conflicts, abort (e.g., circular references)
if (conflicts.length > 0) {
migrationRecord.status = 'aborted_due_to_conflicts';
await saveMigrationHistory(migrationRecord, outputFile);
console.log('\n⚠ MIGRATION ABORTED - CONFLICTS DETECTED\n');
console.log('The following conflicts were found:\n');
displayConflicts(conflicts);
console.log('\nPlease resolve these conflicts before proceeding\n');
throw new Error('Migration aborted due to conflicts.');
}
// Step 2.5: Analyze entity duplication if reuse option is enabled
if (reuseExistingEntities) {
debug('Analyzing entity duplication...');
await analyzeEntityDuplication(
sourceCustomers,
targetCustomer,
migrationRecord
);
}
// Step 3: Display preview
console.log('\n' + '='.repeat(80));
console.log('MIGRATION PREVIEW');
console.log('='.repeat(80) + '\n');
displayMigrationPreview(migrationRecord, sourceCustomers, targetCustomer, convertToAdmin, reuseExistingEntities);
// If preview mode, save and exit
if (preview) {
migrationRecord.status = 'preview_completed';
await saveMigrationHistory(migrationRecord, outputFile);
console.log('Preview mode - no changes made\n');
console.log(`Preview saved to: ${outputFile}\n`);
return migrationRecord;
}
// Step 4: Execute migration in transaction
console.log('\n' + '='.repeat(80));
console.log('EXECUTING MIGRATION');
console.log('='.repeat(80) + '\n');
await executeMigration(
sourceCustomers,
targetCustomer,
conflicts,
convertToAdmin,
updateInvoices,
reuseExistingEntities,
migrationRecord
);
migrationRecord.status = 'completed';
migrationRecord.completedAt = new Date().toISOString();
console.log('\n✅ Migration completed successfully!\n');
displayMigrationStats(migrationRecord.stats);
} catch (error) {
migrationRecord.status = 'failed';
migrationRecord.error = error.message;
migrationRecord.stack = error.stack;
migrationRecord.stats.errors.push({
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
// Clear changes since transaction was rolled back
if (migrationRecord.details && migrationRecord.details.changes) {
migrationRecord.details.changesRolledBack = migrationRecord.details.changes;
migrationRecord.details.changes = [];
migrationRecord.rollbackReason = 'Transaction aborted due to error';
}
debug('Migration failed:', error);
throw error;
} finally {
// Always save migration history
await saveMigrationHistory(migrationRecord, outputFile);
console.log(`\nMigration record saved to: ${outputFile}\n`);
}
return migrationRecord;
}
/**
* Validate customers and gather data for preview
*/
async function validateAndGatherData(sourceCustomerIds, targetCustomerId, migrationRecord) {
// Validate all source customers exist
const sourceCustomers = await models.Customer.find({
_id: { $in: sourceCustomerIds }
}).lean();
if (sourceCustomers.length !== sourceCustomerIds.length) {
const foundIds = sourceCustomers.map(c => c._id.toString());
const missingIds = sourceCustomerIds.filter(id => !foundIds.includes(id));
return {
valid: false,
error: `Source customer(s) not found: ${missingIds.join(', ')}`
};
}
// Check if any source customers are already deleted
const deletedSources = sourceCustomers.filter(c => c.markedDelete);
if (deletedSources.length > 0) {
return {
valid: false,
error: `Cannot migrate from deleted customer(s): ${deletedSources.map(c => c._id).join(', ')}`
};
}
// Validate target customer exists
const targetCustomer = await models.Customer.findById(targetCustomerId).lean();
if (!targetCustomer) {
return {
valid: false,
error: `Target customer not found: ${targetCustomerId}`
};
}
if (targetCustomer.markedDelete) {
return {
valid: false,
error: `Cannot migrate to deleted customer: ${targetCustomerId}`
};
}
// Gather statistics for each source
for (const sourceCustomer of sourceCustomers) {
const stats = await gatherCustomerStats(sourceCustomer._id);
migrationRecord.stats.sourceCustomers[sourceCustomer._id] = stats;
migrationRecord.details.sources.push({
customerId: sourceCustomer._id,
username: sourceCustomer.username,
email: sourceCustomer.email,
country: sourceCustomer.country,
stats
});
// Accumulate totals
migrationRecord.stats.totalJobs += stats.jobs;
migrationRecord.stats.totalClients += stats.clients;
migrationRecord.stats.totalPilots += stats.pilots;
migrationRecord.stats.totalVehicles += stats.vehicles;
migrationRecord.stats.totalOfficers = (migrationRecord.stats.totalOfficers || 0) + stats.officers;
migrationRecord.stats.totalInspectors = (migrationRecord.stats.totalInspectors || 0) + stats.inspectors;
migrationRecord.stats.totalProducts += stats.products;
migrationRecord.stats.totalCrops += stats.crops;
migrationRecord.stats.totalApplications += stats.applications;
migrationRecord.stats.totalInvoices += stats.invoices;
}
return {
valid: true,
sourceCustomers,
targetCustomer
};
}
/**
* Gather statistics for a customer
*/
async function gatherCustomerStats(customerId) {
const [
clients,
pilots,
vehicles,
officers,
inspectors,
partnerSystemUsers,
jobs,
products,
crops
] = await Promise.all([
models.Client.find({ parent: customerId }).lean(),
models.Pilot.find({ parent: customerId }).lean(),
models.Vehicle.find({ parent: customerId }).lean(),
models.User.find({ parent: customerId, kind: UserTypes.OFFICER }).lean(),
models.User.find({ parent: customerId, kind: UserTypes.INSPECTOR }).lean(),
models.User.find({ parent: customerId, kind: UserTypes.PARTNER_SYSTEM_USER }).lean(),
models.Job.find({ byPuid: customerId }).lean(),
models.Product.find({ byPuid: customerId }).lean(),
models.Crop.find({ byPuid: customerId }).lean()
]);
// Count applications for these jobs
const jobIds = jobs.map(j => j._id);
const applications = jobIds.length > 0
? await models.App.countDocuments({ jobId: { $in: jobIds } })
: 0;
// Count invoices referencing these jobs
const invoices = jobIds.length > 0
? await models.Invoice.countDocuments({ 'jobs.job': { $in: jobIds } })
: 0;
return {
clients: clients.length,
clientList: clients.map(c => ({ _id: c._id, username: c.username, email: c.email })),
pilots: pilots.length,
pilotList: pilots.map(p => ({ _id: p._id, username: p.username, email: p.email })),
vehicles: vehicles.length,
vehicleList: vehicles.map(v => ({ _id: v._id, username: v.username, model: v.model })),
officers: officers.length,
officerList: officers.map(o => ({ _id: o._id, username: o.username, email: o.email })),
inspectors: inspectors.length,
inspectorList: inspectors.map(i => ({ _id: i._id, username: i.username, email: i.email })),
partnerSystemUsers: partnerSystemUsers.length,
partnerSystemUserList: partnerSystemUsers.map(p => ({ _id: p._id, username: p.username, partnerUsername: p.partnerUsername })),
jobs: jobs.length,
products: products.length,
productList: products.map(p => ({ _id: p._id, name: p.name })),
crops: crops.length,
cropList: crops.map(c => ({ _id: c._id, name: c.name })),
applications,
invoices
};
}
/**
* Detect conflicts between source customers and target customer
* NOTE: With the new approach of just updating .parent references,
* username conflicts are no longer an issue since usernames remain unique globally.
* This function now just validates that we're not creating circular references.
*/
async function detectConflicts(sourceCustomers, targetCustomer, migrationRecord) {
const conflicts = [];
// Check if any source customer is the same as target (would create circular reference)
for (const sourceCustomer of sourceCustomers) {
if (sourceCustomer._id.toString() === targetCustomer._id.toString()) {
conflicts.push({
type: 'circular_reference',
message: `Cannot migrate customer ${sourceCustomer.username} to itself`,
sourceCustomerId: sourceCustomer._id,
targetCustomerId: targetCustomer._id
});
}
}
// Note: Username conflicts are no longer checked because sub-accounts just get their
// parent reference updated - they keep their original usernames which are globally unique
return conflicts;
}
/**
* Analyze entity duplication when --reuse-existing-entities is enabled
* This helps users understand what will be reused vs migrated during preview
*/
async function analyzeEntityDuplication(sourceCustomers, targetCustomer, migrationRecord) {
// Get destination customer's products and crops
const destProducts = await models.Product.find({ byPuid: targetCustomer._id }).lean();
const destCrops = await models.Crop.find({ byPuid: targetCustomer._id }).lean();
// Create lookup maps by name (case-insensitive, trimmed)
const destProductsByName = {};
const destCropsByName = {};
destProducts.forEach(p => {
if (p.name) {
destProductsByName[p.name.toLowerCase().trim()] = p;
}
});
destCrops.forEach(c => {
if (c.name) {
destCropsByName[c.name.toLowerCase().trim()] = c;
}
});
// Analyze each source customer
for (const sourceCustomer of sourceCustomers) {
const sourceProducts = await models.Product.find({ byPuid: sourceCustomer._id }).lean();
const sourceCrops = await models.Crop.find({ byPuid: sourceCustomer._id }).lean();
const productMatches = [];
const productNoMatches = [];
const cropMatches = [];
const cropNoMatches = [];
// Check products for duplicates
sourceProducts.forEach(p => {
const productName = p.name ? p.name.toLowerCase().trim() : '';
if (productName && destProductsByName[productName]) {
productMatches.push({
sourceId: p._id,
sourceName: p.name,
destId: destProductsByName[productName]._id,
destName: destProductsByName[productName].name
});
} else {
productNoMatches.push({
id: p._id,
name: p.name
});
}
});
// Check crops for duplicates
sourceCrops.forEach(c => {
const cropName = c.name ? c.name.toLowerCase().trim() : '';
if (cropName && destCropsByName[cropName]) {
cropMatches.push({
sourceId: c._id,
sourceName: c.name,
destId: destCropsByName[cropName]._id,
destName: destCropsByName[cropName].name
});
} else {
cropNoMatches.push({
id: c._id,
name: c.name
});
}
});
// Store analysis in migration record
if (!migrationRecord.details.entityAnalysis) {
migrationRecord.details.entityAnalysis = {};
}
migrationRecord.details.entityAnalysis[sourceCustomer._id] = {
products: {
total: sourceProducts.length,
willReuse: productMatches.length,
willMigrate: productNoMatches.length,
matches: productMatches,
noMatches: productNoMatches
},
crops: {
total: sourceCrops.length,
willReuse: cropMatches.length,
willMigrate: cropNoMatches.length,
matches: cropMatches,
noMatches: cropNoMatches
}
};
}
debug('Entity duplication analysis completed');
}
/**
* Execute the migration in a transaction
*/
async function executeMigration(
sourceCustomers,
targetCustomer,
conflicts,
convertToAdmin,
updateInvoices,
reuseExistingEntities,
migrationRecord
) {
// Use enhanced transaction from mongo_enhanced.js
await mongoEnhanced.enhancedRunInTransaction(async (session) => {
debug('Starting migration transaction...');
// Process each source customer
for (const sourceCustomer of sourceCustomers) {
console.log(`\nMigrating data from customer: ${sourceCustomer.username} (${sourceCustomer._id})`);
// 1. Delete partner system users and backup for rollback
await deletePartnerSystemUsers(sourceCustomer._id, session, migrationRecord);
// 2. Migrate or convert source customer account if requested
if (convertToAdmin) {
await convertCustomerToAdmin(sourceCustomer._id, targetCustomer._id, session, migrationRecord);
}
// 3. Update sub-accounts' parent reference (no merging needed - usernames are globally unique)
await updateSubAccountsParent(
sourceCustomer._id,
targetCustomer._id,
session,
migrationRecord
);
// 3. Migrate entities (products, crops)
await migrateEntities(sourceCustomer._id, targetCustomer._id, session, reuseExistingEntities, migrationRecord);
// 4. Migrate jobs and related data
const migratedJobIds = await migrateJobs(sourceCustomer._id, targetCustomer._id, session, migrationRecord);
// 5. Migrate billing data
if (updateInvoices && migratedJobIds && migratedJobIds.length > 0) {
await migrateBillingData(migratedJobIds, targetCustomer._id, session, migrationRecord);
}
// 6. Mark source customer as inactive (unless converted to admin)
if (!convertToAdmin) {
await deactivateSourceCustomer(sourceCustomer, session, migrationRecord);
}
console.log(`✓ Completed migration for customer: ${sourceCustomer.username}`);
}
debug('Migration transaction completed successfully');
}, mongoEnhanced.DEFAULT_TRANSACTION_OPTIONS);
}
/**
* Convert source customer to admin account under target customer
* Instead of creating a new user, we convert the existing customer account to admin type
* This preserves all references in other collections (JobAssign, JobLog, etc.)
*/
async function convertCustomerToAdmin(sourceCustomerId, targetCustomerId, session, migrationRecord) {
debug(`Converting customer ${sourceCustomerId} to admin under ${targetCustomerId}`);
const sourceCustomer = await models.Customer.findById(sourceCustomerId).session(session).lean();
if (!sourceCustomer) {
throw new Error(`Source customer ${sourceCustomerId} not found`);
}
// Store original values for rollback
const originalKind = sourceCustomer.kind;
const originalParent = sourceCustomer.parent;
// Use direct MongoDB update to bypass Mongoose discriminator protection
// This changes the customer account from APP (kind='1') to APP_ADM (kind='2')
const result = await models.Customer.collection.updateOne(
{ _id: sourceCustomer._id },
{
$set: {
kind: UserTypes.APP_ADM, // Change from APP (1) to APP_ADM (2)
parent: targetCustomerId,
active: true // Keep it active
}
},
{ session }
);
if (result.matchedCount === 0) {
throw new Error(`Failed to update customer ${sourceCustomerId}`);
}
migrationRecord.stats.skipped.push({
type: 'customer_converted_to_admin',
customerId: sourceCustomerId,
username: sourceCustomer.username,
originalKind: originalKind,
newKind: UserTypes.APP_ADM
});
migrationRecord.details.changes.push({
action: 'convert_customer_to_admin',
customerId: sourceCustomerId,
username: sourceCustomer.username,
originalKind: originalKind,
originalParent: originalParent,
newKind: UserTypes.APP_ADM,
newParent: targetCustomerId
});
console.log(` → Converted customer to admin: ${sourceCustomer.username} (kind: ${originalKind}${UserTypes.APP_ADM})`);
}
/**
* Update sub-accounts' parent reference to point to the destination customer
* Since usernames are globally unique, no conflicts occur - we just update the parent field
*/
/**
* Delete partner system users and backup for rollback
* Partner system users (kind='21') should not be migrated to the destination customer
*/
async function deletePartnerSystemUsers(sourceId, session, migrationRecord) {
debug('Deleting partner system users...');
// Get all partner system users from source customer
const partnerSystemUsers = await models.User.find({
parent: sourceId,
kind: UserTypes.PARTNER_SYSTEM_USER
}).session(session);
if (partnerSystemUsers.length === 0) {
debug('No partner system users to delete');
return;
}
const deletedUsers = [];
for (const user of partnerSystemUsers) {
console.log(` → Deleting partner system user: ${user.username || user.partnerUsername}`);
// Backup full user data for rollback
const userData = user.toObject();
delete userData.__v; // Remove version key
deletedUsers.push(userData);
// Delete the user
await models.User.deleteOne({ _id: user._id }, { session });
}
console.log(` → Deleted ${deletedUsers.length} partner system users`);
// Track deletion in migration record for rollback
if (deletedUsers.length > 0) {
migrationRecord.details.changes.push({
action: 'delete_partner_system_users',
sourceCustomerId: sourceId,
count: deletedUsers.length,
deletedUsers: deletedUsers // Full backup for restoration
});
}
debug(`Deleted ${deletedUsers.length} partner system users`);
}
/**
* Update sub-accounts' parent references to point to target customer
*/
async function updateSubAccountsParent(sourceId, targetId, session, migrationRecord) {
debug('Updating sub-accounts parent references...');
// Get all sub-accounts from source
const subAccounts = await models.User.find({
parent: sourceId,
kind: { $in: [UserTypes.CLIENT, UserTypes.PILOT, UserTypes.DEVICE, UserTypes.OFFICER, UserTypes.INSPECTOR] }
}).session(session);
let clientCount = 0;
let pilotCount = 0;
let vehicleCount = 0;
let officerCount = 0;
let inspectorCount = 0;
for (const account of subAccounts) {
// Get display name - handle potential corrupt data
let displayName = 'unnamed';
if (account.username && typeof account.username === 'string') {
displayName = account.username;
} else if (account.model && typeof account.model === 'string') {
displayName = account.model;
} else if (account.email && typeof account.email === 'string') {
displayName = account.email;
}
console.log(` → Updating ${getKindName(account.kind)} parent: ${displayName}`);
// Simply update the parent reference
account.parent = targetId;
await account.save({ session });
// Track counts
if (account.kind === UserTypes.CLIENT) clientCount++;
else if (account.kind === UserTypes.PILOT) pilotCount++;
else if (account.kind === UserTypes.DEVICE) vehicleCount++;
else if (account.kind === UserTypes.OFFICER) officerCount++;
else if (account.kind === UserTypes.INSPECTOR) inspectorCount++;
migrationRecord.details.changes.push({
action: 'update_parent_reference',
kind: account.kind,
username: account.username,
name: account.name || account?.contact || null,
accountId: account._id,
fromParent: sourceId,
toParent: targetId
});
}
console.log(` → Updated ${clientCount} clients, ${pilotCount} pilots, ${vehicleCount} vehicles, ${officerCount} officers, ${inspectorCount} inspectors`);
debug(`Updated parent references for ${subAccounts.length} sub-accounts`);
}
/**
* Helper to get kind name for display
*/
function getKindName(kind) {
// Convert to string for comparison since UserTypes are strings
const kindStr = typeof kind === 'string' ? kind : String(kind);
switch (kindStr) {
case '3': return 'Client'; // UserTypes.CLIENT
case '5': return 'Pilot'; // UserTypes.PILOT
case '9': return 'Vehicle'; // UserTypes.DEVICE
case '4': return 'Officer'; // UserTypes.OFFICER
case '6': return 'Inspector'; // UserTypes.INSPECTOR
case '21': return 'PartnerSystemUser'; // UserTypes.PARTNER_SYSTEM_USER
default: return 'Account';
}
}/**
* Migrate entities (products, crops)
*/
async function migrateEntities(sourceId, targetId, session, reuseExistingEntities, migrationRecord) {
debug('Migrating entities...');
// Migrate products - track each product ID
const sourceProducts = await models.Product.find({ byPuid: sourceId }).session(session);
const productIds = [];
const productIdMap = {}; // Map from source product ID to destination product ID
const deletedProducts = []; // Store deleted products for rollback
let reusedProductCount = 0;
let migratedProductCount = 0;
if (reuseExistingEntities) {
// Get existing products in destination
const destProducts = await models.Product.find({ byPuid: targetId }).session(session).lean();
const destProductsByName = {};
destProducts.forEach(p => {
if (p.name) {
destProductsByName[p.name.toLowerCase().trim()] = p;
}
});
for (const product of sourceProducts) {
const productName = product.name ? product.name.toLowerCase().trim() : '';
const existingProduct = destProductsByName[productName];
if (existingProduct && productName) {
// Reuse existing product - track the mapping
productIdMap[product._id.toString()] = existingProduct._id.toString();
reusedProductCount++;
// Store the product data before deleting (for rollback)
deletedProducts.push(product.toObject());
// Delete the source product since we're using the destination's
await models.Product.deleteOne({ _id: product._id }).session(session);
} else {
// Migrate product to destination
productIds.push(product._id.toString());
productIdMap[product._id.toString()] = product._id.toString();
product.byPuid = targetId;
await product.save({ session });
migratedProductCount++;
}
}
console.log(` → Migrated ${migratedProductCount} products, reused ${reusedProductCount} existing`);
} else {
// Original behavior: migrate all products
for (const product of sourceProducts) {
productIds.push(product._id.toString());
productIdMap[product._id.toString()] = product._id.toString();
product.byPuid = targetId;
await product.save({ session });
}
migratedProductCount = sourceProducts.length;
console.log(` → Migrated ${migratedProductCount} products`);
}
if (sourceProducts.length > 0) {
migrationRecord.details.changes.push({
action: 'migrate_products',
count: migratedProductCount,
reusedCount: reusedProductCount,
productIds: productIds,
productIdMap: reuseExistingEntities ? productIdMap : undefined,
deletedProducts: deletedProducts.length > 0 ? deletedProducts : undefined,
oldParent: sourceId,
newParent: targetId
});
}
// Migrate crops - track each crop ID
const sourceCrops = await models.Crop.find({ byPuid: sourceId }).session(session);
const cropIds = [];
const cropIdMap = {}; // Map from source crop ID to destination crop ID
const deletedCrops = []; // Store deleted crops for rollback
let reusedCropCount = 0;
let migratedCropCount = 0;
if (reuseExistingEntities) {
// Get existing crops in destination
const destCrops = await models.Crop.find({ byPuid: targetId }).session(session).lean();
const destCropsByName = {};
destCrops.forEach(c => {
if (c.name) {
destCropsByName[c.name.toLowerCase().trim()] = c;
}
});
for (const crop of sourceCrops) {
const cropName = crop.name ? crop.name.toLowerCase().trim() : '';
const existingCrop = destCropsByName[cropName];
if (existingCrop && cropName) {
// Reuse existing crop - track the mapping
cropIdMap[crop._id.toString()] = existingCrop._id.toString();
reusedCropCount++;
// Store the crop data before deleting (for rollback)
deletedCrops.push(crop.toObject());
// Delete the source crop since we're using the destination's
await models.Crop.deleteOne({ _id: crop._id }).session(session);
} else {
// Migrate crop to destination
cropIds.push(crop._id.toString());
cropIdMap[crop._id.toString()] = crop._id.toString();
crop.byPuid = targetId;
await crop.save({ session });
migratedCropCount++;
}
}
console.log(` → Migrated ${migratedCropCount} crops, reused ${reusedCropCount} existing`);
} else {
// Original behavior: migrate all crops
for (const crop of sourceCrops) {
cropIds.push(crop._id.toString());
cropIdMap[crop._id.toString()] = crop._id.toString();
crop.byPuid = targetId;
await crop.save({ session });
}
migratedCropCount = sourceCrops.length;
console.log(` → Migrated ${migratedCropCount} crops`);
}
if (sourceCrops.length > 0) {
migrationRecord.details.changes.push({
action: 'migrate_crops',
count: migratedCropCount,
reusedCount: reusedCropCount,
cropIds: cropIds,
cropIdMap: reuseExistingEntities ? cropIdMap : undefined,
deletedCrops: deletedCrops.length > 0 ? deletedCrops : undefined,
oldParent: sourceId,
newParent: targetId
});
}
// Migrate costing items - track each item ID
const costingItems = await models.CostingItem.find({ byPuid: sourceId }).session(session);
const costingItemIds = [];
for (const item of costingItems) {
costingItemIds.push(item._id.toString());
item.byPuid = targetId;
await item.save({ session });
}
if (costingItems.length > 0) {
console.log(` → Migrated ${costingItems.length} costing items`);
migrationRecord.details.changes.push({
action: 'migrate_costing_items',
count: costingItems.length,
costingItemIds: costingItemIds,
oldParent: sourceId,
newParent: targetId
});
}
debug(`Migrated products: ${migratedProductCount}, crops: ${migratedCropCount}`);
}
/**
* Migrate jobs and all related data
*/
async function migrateJobs(sourceId, targetId, session, migrationRecord) {
debug('Migrating jobs and applications...');
// Get all jobs from source customer
const jobs = await models.Job.find({ byPuid: sourceId }).session(session);
console.log(` → Migrating ${jobs.length} jobs...`);
const jobIds = [];
let appCount = 0;
for (const job of jobs) {
jobIds.push(job._id.toString());
// Update job byPuid
job.byPuid = targetId;
await job.save({ session });
// Count applications for this job (no need to update as they reference jobId)
const applications = await models.App.countDocuments({ jobId: job._id }).session(session);
appCount += applications;
}
console.log(` → Migrated ${jobs.length} jobs with ${appCount} applications`);
if (jobs.length > 0) {
migrationRecord.details.changes.push({
action: 'migrate_jobs',
jobCount: jobs.length,
jobIds: jobIds,
applicationCount: appCount,
oldParent: sourceId,
newParent: targetId
});
}
debug(`Migrated ${jobs.length} jobs with ${appCount} applications`);
// Return the job IDs for use in invoice migration
return jobIds;
}
/**
* Migrate billing and invoice data
*/
async function migrateBillingData(jobIds, targetId, session, migrationRecord) {
debug('Migrating billing data...');
if (!jobIds || jobIds.length === 0) {
debug('No jobs found, skipping invoice migration');
return;
}
// Find and update invoices that reference these jobs - track each invoice
const invoices = await models.Invoice.find({
'jobs.job': { $in: jobIds }
}).session(session);
const invoiceIds = [];
const oldCustomerIds = [];
for (const invoice of invoices) {
invoiceIds.push(invoice._id.toString());
oldCustomerIds.push(invoice.byPuid ? invoice.byPuid.toString() : null);
invoice.byPuid = targetId;
await invoice.save({ session });
}
console.log(` → Migrated ${invoices.length} invoices`);
if (invoices.length > 0) {
migrationRecord.details.changes.push({
action: 'migrate_invoices',
count: invoices.length,
invoiceIds: invoiceIds,
oldCustomerIds: oldCustomerIds,
newParent: targetId
});
}
debug(`Migrated ${invoices.length} invoices`);
}
/**
* Deactivate source customer after migration
*/
async function deactivateSourceCustomer(sourceCustomer, session, migrationRecord) {
debug(`Deactivating source customer ${sourceCustomer._id}`);
const customer = await models.Customer.findById(sourceCustomer._id).session(session);
if (customer) {
customer.active = false;
customer.markedDelete = true;
customer.username = customer.username + '#migrated_' + Date.now();
await customer.save({ session });
migrationRecord.details.changes.push({
action: 'deactivate_source_customer',
customerId: sourceCustomer._id,
oldUsername: sourceCustomer.username,
newUsername: customer.username
});
console.log(` → Deactivated source customer: ${sourceCustomer.username}`);
}
}
/**
* Save migration history to JSON file
*/
async function saveMigrationHistory(migrationRecord, outputFile) {
try {
let history = [];
// Try to read existing history
try {
const existingData = await fs.readFile(outputFile, 'utf8');
history = JSON.parse(existingData);
if (!Array.isArray(history)) {
history = [history];
}
} catch (error) {
// File doesn't exist or is invalid, start fresh
debug('Starting new migration history file');
}
// Append new record
history.push(migrationRecord);
// Write back to file
await fs.writeFile(outputFile, JSON.stringify(history, null, 2), 'utf8');
debug(`Migration history saved to ${outputFile}`);
} catch (error) {
debug('Error saving migration history:', error);
// Don't throw - this is a logging operation
}
}
/**
* Display migration preview
*/
function displayMigrationPreview(migrationRecord, sourceCustomers, targetCustomer, convertToAdmin, reuseExistingEntities) {
console.log('Source Customers:');
sourceCustomers.forEach(source => {
const stats = migrationRecord.stats.sourceCustomers[source._id];
console.log(`${source.username} (${source._id})`);
console.log(` Email: ${source.email || 'N/A'}`);
console.log(` Country: ${source.country}`);
console.log(` Clients: ${stats.clients}`);
console.log(` Pilots: ${stats.pilots}`);
console.log(` Vehicles: ${stats.vehicles}`);
console.log(` Officers: ${stats.officers}`);
console.log(` Inspectors: ${stats.inspectors}`);
if (stats.partnerSystemUsers > 0) {
console.log(` Partner System Users: ${stats.partnerSystemUsers} (will be deleted)`);
}
console.log(` Jobs: ${stats.jobs}`);
console.log(` Products: ${stats.products}`);
console.log(` Crops: ${stats.crops}`);
console.log(` Applications: ${stats.applications}`);
console.log(` Invoices: ${stats.invoices}`);
// Show entity reuse analysis if enabled
if (reuseExistingEntities && migrationRecord.details.entityAnalysis) {
const analysis = migrationRecord.details.entityAnalysis[source._id];
if (analysis) {
if (analysis.products.willReuse > 0 || analysis.crops.willReuse > 0) {
console.log(` ⚠️ Entity Reuse:`);
if (analysis.products.willReuse > 0) {
console.log(` - Products: ${analysis.products.willMigrate} will migrate, ${analysis.products.willReuse} will reuse existing`);
}
if (analysis.crops.willReuse > 0) {
console.log(` - Crops: ${analysis.crops.willMigrate} will migrate, ${analysis.crops.willReuse} will reuse existing`);
}
}
}
}
if (convertToAdmin) {
console.log(` ⚠️ Will be converted to ADMIN account under destination`);
} else {
console.log(` ⚠️ Will be DEACTIVATED after migration`);
}
console.log('');
});
console.log('Destination Customer:');
console.log(`${targetCustomer.username} (${targetCustomer._id})`);
console.log(` Email: ${targetCustomer.email || 'N/A'}`);
console.log(` Country: ${targetCustomer.country}`);
console.log('');
console.log('Migration Summary:');
console.log(` Total Clients: ${migrationRecord.stats.totalClients}`);
console.log(` Total Pilots: ${migrationRecord.stats.totalPilots}`);
console.log(` Total Vehicles: ${migrationRecord.stats.totalVehicles}`);
console.log(` Total Officers: ${migrationRecord.stats.totalOfficers || 0}`);
console.log(` Total Inspectors: ${migrationRecord.stats.totalInspectors || 0}`);
console.log(` Total Jobs: ${migrationRecord.stats.totalJobs}`);
console.log(` Total Products: ${migrationRecord.stats.totalProducts}`);
console.log(` Total Crops: ${migrationRecord.stats.totalCrops}`);
console.log(` Total Applications: ${migrationRecord.stats.totalApplications}`);
console.log(` Total Invoices: ${migrationRecord.stats.totalInvoices}`);
// Show detailed entity reuse summary if enabled
if (reuseExistingEntities && migrationRecord.details.entityAnalysis) {
console.log('');
console.log('Entity Reuse Summary (--reuse-existing-entities enabled):');
let totalProductMatches = 0;
let totalCropMatches = 0;
const allProductMatches = [];
const allCropMatches = [];
Object.entries(migrationRecord.details.entityAnalysis).forEach(([customerId, analysis]) => {
totalProductMatches += analysis.products.willReuse;
totalCropMatches += analysis.crops.willReuse;
if (analysis.products.matches.length > 0) {
allProductMatches.push(...analysis.products.matches.map(m => ({
...m,
customerId
})));
}
if (analysis.crops.matches.length > 0) {
allCropMatches.push(...analysis.crops.matches.map(m => ({
...m,
customerId
})));
}
});
if (totalProductMatches > 0) {
console.log(` Products with matching names: ${totalProductMatches} will be reused (source deleted)`);
allProductMatches.forEach(match => {
console.log(` - "${match.sourceName}" → will use destination's "${match.destName}"`);
});
}
if (totalCropMatches > 0) {
console.log(` Crops with matching names: ${totalCropMatches} will be reused (source deleted)`);
allCropMatches.forEach(match => {
console.log(` - "${match.sourceName}" → will use destination's "${match.destName}"`);
});
}
if (totalProductMatches === 0 && totalCropMatches === 0) {
console.log(` No duplicate products or crops found - all will be migrated`);
}
}
console.log('');
}
/**
* Display conflicts
*/
function displayConflicts(conflicts) {
conflicts.forEach((conflict, idx) => {
console.log(` ${idx + 1}. ${conflict.type}: ${conflict.message}`);
});
}
/**
* Display migration statistics
*/
function displayMigrationStats(stats) {
console.log('Migration Statistics:');
console.log(` Clients: ${stats.totalClients}`);
console.log(` Pilots: ${stats.totalPilots}`);
console.log(` Vehicles: ${stats.totalVehicles}`);
console.log(` Officers: ${stats.totalOfficers || 0}`);
console.log(` Inspectors: ${stats.totalInspectors || 0}`);
console.log(` Jobs: ${stats.totalJobs}`);
console.log(` Products: ${stats.totalProducts}`);
console.log(` Crops: ${stats.totalCrops}`);
console.log(` Applications: ${stats.totalApplications}`);
console.log(` Invoices: ${stats.totalInvoices}`);
if (stats.skipped.length > 0) {
console.log(`\nSkipped/Merged: ${stats.skipped.length} items`);
}
if (stats.errors.length > 0) {
console.log(`\nErrors: ${stats.errors.length}`);
}
}
/**
* Parse command line arguments
*/
function parseArguments() {
const args = process.argv.slice(2);
const options = {
preview: false,
sources: [],
destination: null,
convertToAdmin: true, // Default to true
updateInvoices: true,
reuseExistingEntities: false, // Default to false
outputFile: DEFAULT_OUTPUT_FILE,
help: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case '--help':
case '-h':
options.help = true;
break;
case '--preview':
options.preview = true;
break;
case '--sources':
if (i + 1 < args.length) {
options.sources = args[++i].split(',').map(s => s.trim()).filter(s => s);
}
break;
case '--destination':
if (i + 1 < args.length) {
options.destination = args[++i].trim();
}
break;
case '--convert-to-admin':
options.convertToAdmin = true;
break;
case '--no-convert-to-admin':
case '--deactivate-source':
options.convertToAdmin = false;
break;
case '--skip-invoices':
options.updateInvoices = false;
break;
case '--reuse-existing-entities':
options.reuseExistingEntities = true;
break;
case '--output-file':
if (i + 1 < args.length) {
options.outputFile = args[++i].trim();
}
break;
case '--env':
// Environment file already processed at startup, just skip the value
if (i + 1 < args.length) {
i++; // Skip the env file path argument
}
break;
default:
console.error(`Unknown option: ${arg}`);
options.help = true;
}
}
return options;
}
/**
* Display help message
*/
function displayHelp() {
console.log(`
Customer Data Migration Script
==============================
Usage:
node scripts/migrateCustomerData.js [options]
Options:
--preview Show detailed migration plan without executing
--sources Comma-separated list of source customer IDs or usernames (required)
--destination Destination customer ID or username (required)
--convert-to-admin Convert source customers to admin (kind='2') - DEFAULT BEHAVIOR
--deactivate-source Deactivate source customers instead of converting to admin
--reuse-existing-entities Reuse destination's products/crops with matching names (avoids duplicates)
--skip-invoices Don't migrate invoice references
--output-file Custom output file path (default: ./migration_history.json)
--env Custom environment file path (default: ../environment.env)
--help, -h Show this help message
Examples:
# Preview migration using usernames (source becomes admin by default)
node scripts/migrateCustomerData.js \\
--preview \\
--sources acme_aviation,regional_ag \\
--destination consolidated_ag
# Execute migration (source customers converted to admin by default)
node scripts/migrateCustomerData.js \\
--sources acme_aviation \\
--destination consolidated_ag
# Execute migration and deactivate source instead of converting to admin
node scripts/migrateCustomerData.js \\
--sources acme_aviation \\
--destination consolidated_ag \\
--deactivate-source
# Preview migration using IDs
node scripts/migrateCustomerData.js \\
--preview \\
--sources 507f1f77bcf86cd799439011,507f1f77bcf86cd799439012 \\
--destination 507f191e810c19729de860ea
Note: Sub-accounts (clients, pilots, vehicles, officers, inspectors) keep their usernames
and simply get their parent reference updated to point to the destination customer.
Source customers are converted to admin accounts (kind='2') by default to preserve
all references in other collections (JobAssign, JobLog, etc.).
You can use either customer usernames or MongoDB ObjectIds (or mix them).
`);
}
// Command-line execution
if (require.main === module) {
const options = parseArguments();
if (options.help) {
displayHelp();
process.exit(0);
}
if (!options.sources || options.sources.length === 0) {
console.error('Error: --sources is required');
displayHelp();
process.exit(1);
}
if (!options.destination) {
console.error('Error: --destination is required');
displayHelp();
process.exit(1);
}
console.log('\n' + '='.repeat(80));
console.log('CUSTOMER DATA MIGRATION');
console.log('='.repeat(80) + '\n');
const dbConn = new DBConnection('Customer Data Migration');
dbConn.initialize({
setupExitHandlers: false,
onReady: async () => {
try {
await migrateCustomerData(options.sources, options.destination, options);
process.exit(0);
} catch (error) {
console.error('\n❌ Migration failed:', error.message);
debug('Full error:', error);
process.exit(1);
}
}
});
}
module.exports = { migrateCustomerData };