const ProgressService = require("../services/progress"); class Logger { constructor(progressService = null) { this.progressService = progressService || new ProgressService(); this.colors = { reset: "\x1b[0m", bright: "\x1b[1m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", cyan: "\x1b[36m", }; } /** * Formats a timestamp for console display * @param {Date} date - The date to format * @returns {string} Formatted timestamp string */ formatTimestamp(date = new Date()) { return date .toISOString() .replace("T", " ") .replace(/\.\d{3}Z$/, ""); } /** * Formats a console message with color and timestamp * @param {string} level - Log level (INFO, WARN, ERROR) * @param {string} message - Message to log * @param {string} color - ANSI color code * @returns {string} Formatted message */ formatConsoleMessage(level, message, color) { const timestamp = this.formatTimestamp(); return `${color}[${timestamp}] ${level}:${this.colors.reset} ${message}`; } /** * Logs an info message to console * @param {string} message - Message to log * @returns {Promise} */ async info(message) { const formattedMessage = this.formatConsoleMessage( "INFO", message, this.colors.cyan ); console.log(formattedMessage); } /** * Logs a warning message to console * @param {string} message - Message to log * @returns {Promise} */ async warning(message) { const formattedMessage = this.formatConsoleMessage( "WARN", message, this.colors.yellow ); console.warn(formattedMessage); } /** * Logs an error message to console * @param {string} message - Message to log * @returns {Promise} */ async error(message) { const formattedMessage = this.formatConsoleMessage( "ERROR", message, this.colors.red ); console.error(formattedMessage); } /** * Logs operation start with configuration details (Requirement 3.1) * @param {Object} config - Configuration object * @param {Object} schedulingContext - Optional scheduling context * @returns {Promise} */ async logOperationStart(config, schedulingContext = null) { if (schedulingContext && schedulingContext.isScheduled) { await this.info( `Starting scheduled price update operation with configuration:` ); await this.info( ` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}` ); await this.info( ` Original Schedule Input: ${schedulingContext.originalInput}` ); } else { await this.info(`Starting price update operation with configuration:`); } await this.info(` Target Tag: ${config.targetTag}`); await this.info(` Price Adjustment: ${config.priceAdjustmentPercentage}%`); await this.info(` Shop Domain: ${config.shopDomain}`); // Also log to progress file with scheduling context await this.progressService.logOperationStart(config, schedulingContext); } /** * Logs rollback operation start with configuration details (Requirements 3.1, 7.1, 8.3) * @param {Object} config - Configuration object * @param {Object} schedulingContext - Optional scheduling context * @returns {Promise} */ async logRollbackStart(config, schedulingContext = null) { if (schedulingContext && schedulingContext.isScheduled) { await this.info( `Starting scheduled price rollback operation with configuration:` ); await this.info( ` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}` ); await this.info( ` Original Schedule Input: ${schedulingContext.originalInput}` ); } else { await this.info(`Starting price rollback operation with configuration:`); } await this.info(` Target Tag: ${config.targetTag}`); await this.info(` Operation Mode: rollback`); await this.info(` Shop Domain: ${config.shopDomain}`); // Also log to progress file with rollback-specific format and scheduling context try { await this.progressService.logRollbackStart(config, schedulingContext); } catch (error) { // Progress logging should not block main operations console.warn(`Warning: Failed to log to progress file: ${error.message}`); } } /** * Logs product count information (Requirement 3.2) * @param {number} count - Number of matching products found * @returns {Promise} */ async logProductCount(count) { const message = `Found ${count} product${ count !== 1 ? "s" : "" } matching the specified tag`; await this.info(message); } /** * Logs individual product update details (Requirement 3.3) * @param {Object} entry - Product update entry * @param {string} entry.productTitle - Product title * @param {string} entry.productId - Product ID * @param {string} entry.variantId - Variant ID * @param {number} entry.oldPrice - Original price * @param {number} entry.newPrice - Updated price * @returns {Promise} */ async logProductUpdate(entry) { const compareAtInfo = entry.compareAtPrice ? ` (Compare At: $${entry.compareAtPrice})` : ""; const message = `${this.colors.green}✅${this.colors.reset} Updated "${entry.productTitle}" - Price: $${entry.oldPrice} → $${entry.newPrice}${compareAtInfo}`; console.log(message); // Also log to progress file await this.progressService.logProductUpdate(entry); } /** * Logs successful rollback operations (Requirements 3.3, 7.2, 8.3) * @param {Object} entry - Rollback update entry * @param {string} entry.productTitle - Product title * @param {string} entry.productId - Product ID * @param {string} entry.variantId - Variant ID * @param {number} entry.oldPrice - Original price before rollback * @param {number} entry.compareAtPrice - Compare-at price being used as new price * @param {number} entry.newPrice - New price (same as compare-at price) * @returns {Promise} */ async logRollbackUpdate(entry) { const message = `${this.colors.green}🔄${this.colors.reset} Rolled back "${entry.productTitle}" - Price: ${entry.oldPrice} → ${entry.newPrice} (from Compare At: ${entry.compareAtPrice})`; console.log(message); // Also log to progress file with rollback-specific format try { await this.progressService.logRollbackUpdate(entry); } catch (error) { // Progress logging should not block main operations console.warn(`Warning: Failed to log to progress file: ${error.message}`); } } /** * Logs completion summary (Requirement 3.4) * @param {Object} summary - Summary statistics * @param {number} summary.totalProducts - Total products processed * @param {number} summary.successfulUpdates - Successful updates * @param {number} summary.failedUpdates - Failed updates * @param {Date} summary.startTime - Operation start time * @returns {Promise} */ async logCompletionSummary(summary) { await this.info("=".repeat(50)); await this.info("OPERATION COMPLETE"); await this.info("=".repeat(50)); await this.info(`Total Products Processed: ${summary.totalProducts}`); await this.info( `Successful Updates: ${this.colors.green}${summary.successfulUpdates}${this.colors.reset}` ); if (summary.failedUpdates > 0) { await this.info( `Failed Updates: ${this.colors.red}${summary.failedUpdates}${this.colors.reset}` ); } else { await this.info(`Failed Updates: ${summary.failedUpdates}`); } if (summary.startTime) { const duration = Math.round((new Date() - summary.startTime) / 1000); await this.info(`Duration: ${duration} seconds`); } // Also log to progress file await this.progressService.logCompletionSummary(summary); } /** * Logs rollback completion summary (Requirements 3.5, 7.3, 8.3) * @param {Object} summary - Rollback summary statistics * @param {number} summary.totalProducts - Total products processed * @param {number} summary.totalVariants - Total variants processed * @param {number} summary.eligibleVariants - Variants eligible for rollback * @param {number} summary.successfulRollbacks - Successful rollback operations * @param {number} summary.failedRollbacks - Failed rollback operations * @param {number} summary.skippedVariants - Variants skipped (no compare-at price) * @param {Date} summary.startTime - Operation start time * @returns {Promise} */ async logRollbackSummary(summary) { await this.info("=".repeat(50)); await this.info("ROLLBACK OPERATION COMPLETE"); await this.info("=".repeat(50)); await this.info(`Total Products Processed: ${summary.totalProducts}`); await this.info(`Total Variants Processed: ${summary.totalVariants}`); await this.info(`Eligible Variants: ${summary.eligibleVariants}`); await this.info( `Successful Rollbacks: ${this.colors.green}${summary.successfulRollbacks}${this.colors.reset}` ); if (summary.failedRollbacks > 0) { await this.info( `Failed Rollbacks: ${this.colors.red}${summary.failedRollbacks}${this.colors.reset}` ); } else { await this.info(`Failed Rollbacks: ${summary.failedRollbacks}`); } if (summary.skippedVariants > 0) { await this.info( `Skipped Variants: ${this.colors.yellow}${summary.skippedVariants}${this.colors.reset} (no compare-at price)` ); } else { await this.info(`Skipped Variants: ${summary.skippedVariants}`); } if (summary.startTime) { const duration = Math.round((new Date() - summary.startTime) / 1000); await this.info(`Duration: ${duration} seconds`); } // Also log to progress file with rollback-specific format try { await this.progressService.logRollbackSummary(summary); } catch (error) { // Progress logging should not block main operations console.warn(`Warning: Failed to log to progress file: ${error.message}`); } } /** * Logs error details and continues processing (Requirement 3.5) * @param {Object} entry - Error entry * @param {string} entry.productTitle - Product title * @param {string} entry.productId - Product ID * @param {string} entry.variantId - Variant ID (optional) * @param {string} entry.errorMessage - Error message * @param {Object} schedulingContext - Optional scheduling context for error logging (Requirements 5.3, 5.4) * @returns {Promise} */ async logProductError(entry, schedulingContext = null) { const variantInfo = entry.variantId ? ` (Variant: ${entry.variantId})` : ""; const schedulingInfo = schedulingContext && schedulingContext.isScheduled ? ` [Scheduled Operation]` : ""; const message = `${this.colors.red}❌${this.colors.reset} Failed to update "${entry.productTitle}"${variantInfo}: ${entry.errorMessage}${schedulingInfo}`; console.error(message); // Also log to progress file with scheduling context await this.progressService.logError(entry, schedulingContext); } /** * Logs API rate limiting information * @param {number} retryAfter - Seconds to wait before retry * @returns {Promise} */ async logRateLimit(retryAfter) { await this.warning( `Rate limit encountered. Waiting ${retryAfter} seconds before retry...` ); } /** * Logs network retry attempts * @param {number} attempt - Current attempt number * @param {number} maxAttempts - Maximum attempts * @param {string} error - Error message * @returns {Promise} */ async logRetryAttempt(attempt, maxAttempts, error) { await this.warning( `Network error (attempt ${attempt}/${maxAttempts}): ${error}. Retrying...` ); } /** * Logs when a product is skipped due to invalid data * @param {string} productTitle - Product title * @param {string} reason - Reason for skipping * @returns {Promise} */ async logSkippedProduct(productTitle, reason) { await this.warning(`Skipped "${productTitle}": ${reason}`); } /** * Logs scheduling confirmation with operation details (Requirements 2.1, 2.3) * @param {Object} schedulingInfo - Scheduling information * @param {Date} schedulingInfo.scheduledTime - Target execution time * @param {string} schedulingInfo.originalInput - Original datetime input * @param {string} schedulingInfo.operationType - Type of operation (update/rollback) * @param {Object} schedulingInfo.config - Operation configuration * @returns {Promise} */ async logSchedulingConfirmation(schedulingInfo) { const { scheduledTime, originalInput, operationType, config } = schedulingInfo; await this.info("=".repeat(50)); await this.info("SCHEDULED OPERATION CONFIRMED"); await this.info("=".repeat(50)); await this.info(`Operation Type: ${operationType}`); await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`); await this.info(`Original Input: ${originalInput}`); await this.info(`Target Tag: ${config.targetTag}`); if (operationType === "update") { await this.info(`Price Adjustment: ${config.priceAdjustmentPercentage}%`); } await this.info(`Shop Domain: ${config.shopDomain}`); const delay = scheduledTime.getTime() - new Date().getTime(); const timeRemaining = this.formatTimeRemaining(delay); await this.info(`Time Remaining: ${timeRemaining}`); await this.info("Press Ctrl+C to cancel the scheduled operation"); await this.info("=".repeat(50)); // Also log to progress file await this.progressService.logSchedulingConfirmation(schedulingInfo); } /** * Logs countdown updates during scheduled wait period (Requirements 2.2, 2.3) * @param {Object} countdownInfo - Countdown information * @param {Date} countdownInfo.scheduledTime - Target execution time * @param {number} countdownInfo.remainingMs - Milliseconds remaining * @returns {Promise} */ async logCountdownUpdate(countdownInfo) { const { scheduledTime, remainingMs } = countdownInfo; const timeRemaining = this.formatTimeRemaining(remainingMs); await this.info( `Scheduled execution in: ${timeRemaining} (at ${scheduledTime.toLocaleString()})` ); } /** * Logs the start of scheduled operation execution (Requirements 2.3, 5.4) * @param {Object} executionInfo - Execution information * @param {Date} executionInfo.scheduledTime - Original scheduled time * @param {Date} executionInfo.actualTime - Actual execution time * @param {string} executionInfo.operationType - Type of operation * @returns {Promise} */ async logScheduledExecutionStart(executionInfo) { const { scheduledTime, actualTime, operationType } = executionInfo; const delay = actualTime.getTime() - scheduledTime.getTime(); const delayText = Math.abs(delay) < 1000 ? "on time" : delay > 0 ? `${Math.round(delay / 1000)}s late` : `${Math.round(Math.abs(delay) / 1000)}s early`; await this.info("=".repeat(50)); await this.info("SCHEDULED OPERATION STARTING"); await this.info("=".repeat(50)); await this.info(`Operation Type: ${operationType}`); await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`); await this.info(`Actual Start Time: ${actualTime.toLocaleString()}`); await this.info(`Timing: ${delayText}`); await this.info("=".repeat(50)); // Also log to progress file await this.progressService.logScheduledExecutionStart(executionInfo); } /** * Logs scheduled operation cancellation (Requirements 3.1, 3.2) * @param {Object} cancellationInfo - Cancellation information * @param {Date} cancellationInfo.scheduledTime - Original scheduled time * @param {Date} cancellationInfo.cancelledTime - Time when cancelled * @param {string} cancellationInfo.operationType - Type of operation * @param {string} cancellationInfo.reason - Cancellation reason * @returns {Promise} */ async logScheduledOperationCancellation(cancellationInfo) { const { scheduledTime, cancelledTime, operationType, reason } = cancellationInfo; const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime(); const remainingText = this.formatTimeRemaining(timeRemaining); await this.info("=".repeat(50)); await this.info("SCHEDULED OPERATION CANCELLED"); await this.info("=".repeat(50)); await this.info(`Operation Type: ${operationType}`); await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`); await this.info(`Cancelled Time: ${cancelledTime.toLocaleString()}`); await this.info(`Time Remaining: ${remainingText}`); await this.info(`Reason: ${reason}`); await this.info("=".repeat(50)); // Also log to progress file await this.progressService.logScheduledOperationCancellation( cancellationInfo ); } /** * Format time remaining into human-readable string * @param {number} milliseconds - Time remaining in milliseconds * @returns {string} Formatted time string (e.g., "2h 30m 15s") */ formatTimeRemaining(milliseconds) { if (milliseconds <= 0) { return "0s"; } const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const remainingHours = hours % 24; const remainingMinutes = minutes % 60; const remainingSeconds = seconds % 60; const parts = []; if (days > 0) parts.push(`${days}d`); if (remainingHours > 0) parts.push(`${remainingHours}h`); if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`); if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`); return parts.join(" "); } /** * Logs comprehensive error analysis and recommendations * @param {Array} errors - Array of error objects * @param {Object} summary - Operation summary statistics * @param {Object} schedulingContext - Optional scheduling context for error analysis (Requirements 5.3, 5.4) * @returns {Promise} */ async logErrorAnalysis(errors, summary, schedulingContext = null) { if (!errors || errors.length === 0) { return; } const operationType = summary.successfulRollbacks !== undefined ? "ROLLBACK" : "UPDATE"; const schedulingPrefix = schedulingContext && schedulingContext.isScheduled ? "SCHEDULED " : ""; await this.info("=".repeat(50)); await this.info(`${schedulingPrefix}${operationType} ERROR ANALYSIS`); await this.info("=".repeat(50)); if (schedulingContext && schedulingContext.isScheduled) { await this.info( `Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}` ); await this.info( `Original Schedule Input: ${schedulingContext.originalInput}` ); await this.info("=".repeat(50)); } // Enhanced categorization for rollback operations const categories = {}; const retryableErrors = []; const nonRetryableErrors = []; errors.forEach((error) => { const category = this.categorizeError( error.errorMessage || error.error || "Unknown" ); if (!categories[category]) { categories[category] = []; } categories[category].push(error); // Track retryable vs non-retryable errors for rollback analysis if (error.retryable === true) { retryableErrors.push(error); } else if (error.retryable === false) { nonRetryableErrors.push(error); } }); // Display category breakdown await this.info("Error Categories:"); Object.entries(categories).forEach(([category, categoryErrors]) => { const percentage = ( (categoryErrors.length / errors.length) * 100 ).toFixed(1); this.info( ` ${category}: ${categoryErrors.length} errors (${percentage}%)` ); }); // Rollback-specific error analysis (Requirements 4.3, 4.5) if (operationType === "ROLLBACK") { await this.info("\nRollback Error Analysis:"); if (retryableErrors.length > 0) { await this.info( ` Retryable Errors: ${retryableErrors.length} (${( (retryableErrors.length / errors.length) * 100 ).toFixed(1)}%)` ); } if (nonRetryableErrors.length > 0) { await this.info( ` Non-Retryable Errors: ${nonRetryableErrors.length} (${( (nonRetryableErrors.length / errors.length) * 100 ).toFixed(1)}%)` ); } // Analyze rollback-specific error patterns const validationErrors = errors.filter( (e) => e.errorType === "validation_error" || e.errorType === "validation" ); if (validationErrors.length > 0) { await this.info( ` Products without compare-at prices: ${validationErrors.length}` ); } const networkErrors = errors.filter( (e) => e.errorType === "network_error" ); if (networkErrors.length > 0) { await this.info( ` Network-related failures: ${networkErrors.length} (consider retry)` ); } } // Provide recommendations based on error patterns await this.info("\nRecommendations:"); await this.provideErrorRecommendations(categories, summary, operationType); // Log to progress file as well with scheduling context await this.progressService.logErrorAnalysis(errors, schedulingContext); } /** * Categorize error for analysis (same logic as progress service) * @param {string} errorMessage - Error message to categorize * @returns {string} Error category */ categorizeError(errorMessage) { const message = errorMessage.toLowerCase(); if ( message.includes("rate limit") || message.includes("429") || message.includes("throttled") ) { return "Rate Limiting"; } if ( message.includes("network") || message.includes("connection") || message.includes("timeout") ) { return "Network Issues"; } if ( message.includes("authentication") || message.includes("unauthorized") || message.includes("401") ) { return "Authentication"; } if ( message.includes("permission") || message.includes("forbidden") || message.includes("403") ) { return "Permissions"; } if (message.includes("not found") || message.includes("404")) { return "Resource Not Found"; } if ( message.includes("validation") || message.includes("invalid") || message.includes("price") ) { return "Data Validation"; } if ( message.includes("server error") || message.includes("500") || message.includes("502") || message.includes("503") ) { return "Server Errors"; } if (message.includes("shopify") && message.includes("api")) { return "Shopify API"; } return "Other"; } /** * Provide recommendations based on error patterns * @param {Object} categories - Categorized errors * @param {Object} summary - Operation summary * @param {string} operationType - Type of operation ('UPDATE' or 'ROLLBACK') * @returns {Promise} */ async provideErrorRecommendations( categories, summary, operationType = "UPDATE" ) { if (categories["Rate Limiting"]) { await this.info( " • Consider reducing batch size or adding delays between requests" ); await this.info( " • Check if your API plan supports the current request volume" ); } if (categories["Network Issues"]) { await this.info(" • Check your internet connection stability"); await this.info(" • Consider running the script during off-peak hours"); if (operationType === "ROLLBACK") { await this.info( " • Network errors during rollback are retryable - consider re-running" ); } } if (categories["Authentication"]) { await this.info( " • Verify your Shopify access token is valid and not expired" ); await this.info(" • Check that your app has the required permissions"); } if (categories["Data Validation"]) { if (operationType === "ROLLBACK") { await this.info( " • Products without compare-at prices cannot be rolled back" ); await this.info( " • Consider filtering products to only include those with compare-at prices" ); await this.info( " • Review which products were updated in the original price adjustment" ); } else { await this.info( " • Review product data for invalid prices or missing information" ); await this.info( " • Consider adding more robust data validation before updates" ); } } if (categories["Server Errors"]) { await this.info(" • Shopify may be experiencing temporary issues"); await this.info(" • Try running the script again later"); if (operationType === "ROLLBACK") { await this.info(" • Server errors during rollback are retryable"); } } // Rollback-specific recommendations (Requirement 4.5) if (operationType === "ROLLBACK") { if (categories["Resource Not Found"]) { await this.info( " • Some products or variants may have been deleted since the original update" ); await this.info( " • Consider checking product existence before rollback operations" ); } if (categories["Permissions"]) { await this.info( " • Ensure your API credentials have product update permissions" ); await this.info( " • Rollback operations require the same permissions as price updates" ); } } // Success rate analysis with operation-specific metrics let successRate; if (operationType === "ROLLBACK") { successRate = summary.eligibleVariants > 0 ? ( (summary.successfulRollbacks / summary.eligibleVariants) * 100 ).toFixed(1) : 0; } else { successRate = summary.totalVariants > 0 ? ((summary.successfulUpdates / summary.totalVariants) * 100).toFixed( 1 ) : 0; } if (successRate < 50) { await this.warning( ` • Low success rate detected (${successRate}%) - consider reviewing configuration` ); if (operationType === "ROLLBACK") { await this.warning( " • Many products may not have valid compare-at prices for rollback" ); } } else if (successRate < 90) { await this.info( ` • Moderate success rate (${successRate}%) - some optimization may be beneficial` ); } else { await this.info( ` • Good success rate (${successRate}%) - most operations completed successfully` ); } } } module.exports = Logger;