'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} identifiers - Array of customer usernames or IDs * @returns {Promise} 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} 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 };