Implemented Rollback Functionality

This commit is contained in:
2025-08-06 15:18:44 -05:00
parent d741dd5466
commit 78818793f2
20 changed files with 6365 additions and 74 deletions

View File

@@ -14,6 +14,7 @@ function loadEnvironmentConfig() {
accessToken: process.env.SHOPIFY_ACCESS_TOKEN,
targetTag: process.env.TARGET_TAG,
priceAdjustmentPercentage: process.env.PRICE_ADJUSTMENT_PERCENTAGE,
operationMode: process.env.OPERATION_MODE || "update", // Default to "update" for backward compatibility
};
// Validate required environment variables
@@ -85,12 +86,21 @@ function loadEnvironmentConfig() {
);
}
// Validate operation mode
const validOperationModes = ["update", "rollback"];
if (!validOperationModes.includes(config.operationMode)) {
throw new Error(
`Invalid OPERATION_MODE: "${config.operationMode}". Must be either "update" or "rollback".`
);
}
// Return validated configuration
return {
shopDomain: config.shopDomain.trim(),
accessToken: config.accessToken.trim(),
targetTag: trimmedTag,
priceAdjustmentPercentage: percentage,
operationMode: config.operationMode,
};
}

View File

@@ -31,8 +31,12 @@ class ShopifyPriceUpdater {
// Load and validate configuration
this.config = getConfig();
// Log operation start with configuration (Requirement 3.1)
await this.logger.logOperationStart(this.config);
// Log operation start with configuration based on operation mode (Requirements 3.1, 8.4, 9.3)
if (this.config.operationMode === "rollback") {
await this.logger.logRollbackStart(this.config);
} else {
await this.logger.logOperationStart(this.config);
}
return true;
} catch (error) {
@@ -210,7 +214,191 @@ class ShopifyPriceUpdater {
}
/**
* Run the complete price update workflow
* Fetch products by tag and validate them for rollback operations
* @returns {Promise<Array|null>} Array of rollback-eligible products or null if failed
*/
async fetchAndValidateProductsForRollback() {
try {
// Fetch products by tag
await this.logger.info(
`Fetching products with tag: ${this.config.targetTag}`
);
const products = await this.productService.fetchProductsByTag(
this.config.targetTag
);
// Log product count (Requirement 3.2)
await this.logger.logProductCount(products.length);
if (products.length === 0) {
await this.logger.info(
"No products found with the specified tag. Operation completed."
);
return [];
}
// Validate products for rollback operations
const eligibleProducts =
await this.productService.validateProductsForRollback(products);
// Display summary statistics for rollback
const summary = this.productService.getProductSummary(eligibleProducts);
await this.logger.info(`Rollback Product Summary:`);
await this.logger.info(` - Total Products: ${summary.totalProducts}`);
await this.logger.info(` - Total Variants: ${summary.totalVariants}`);
await this.logger.info(
` - Price Range: ${summary.priceRange.min} - ${summary.priceRange.max}`
);
return eligibleProducts;
} catch (error) {
await this.logger.error(
`Failed to fetch products for rollback: ${error.message}`
);
return null;
}
}
/**
* Execute rollback operations for all products
* @param {Array} products - Array of products to rollback
* @returns {Promise<Object|null>} Rollback results or null if failed
*/
async rollbackPrices(products) {
try {
if (products.length === 0) {
return {
totalProducts: 0,
totalVariants: 0,
eligibleVariants: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
};
}
await this.logger.info(`Starting price rollback operations`);
// Execute rollback operations
const results = await this.productService.rollbackProductPrices(products);
return results;
} catch (error) {
await this.logger.error(`Price rollback failed: ${error.message}`);
return null;
}
}
/**
* Display operation mode header with clear indication of active mode (Requirements 9.3, 8.4)
* @returns {Promise<void>}
*/
async displayOperationModeHeader() {
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
blue: "\x1b[34m",
green: "\x1b[32m",
yellow: "\x1b[33m",
};
console.log("\n" + "=".repeat(60));
if (this.config.operationMode === "rollback") {
console.log(
`${colors.bright}${colors.yellow}🔄 SHOPIFY PRICE ROLLBACK MODE${colors.reset}`
);
console.log(
`${colors.yellow}Reverting prices from compare-at to main price${colors.reset}`
);
} else {
console.log(
`${colors.bright}${colors.green}📈 SHOPIFY PRICE UPDATE MODE${colors.reset}`
);
console.log(
`${colors.green}Adjusting prices by ${this.config.priceAdjustmentPercentage}%${colors.reset}`
);
}
console.log("=".repeat(60) + "\n");
// Log operation mode to progress file as well
await this.logger.info(
`Operation Mode: ${this.config.operationMode.toUpperCase()}`
);
}
/**
* Display rollback-specific summary and determine exit status
* @param {Object} results - Rollback results
* @returns {number} Exit status code
*/
async displayRollbackSummary(results) {
// Prepare comprehensive summary for rollback logging (Requirement 3.4, 8.4)
const summary = {
totalProducts: results.totalProducts,
totalVariants: results.totalVariants,
eligibleVariants: results.eligibleVariants,
successfulRollbacks: results.successfulRollbacks,
failedRollbacks: results.failedRollbacks,
skippedVariants: results.skippedVariants,
startTime: this.startTime,
errors: results.errors || [],
};
// Log rollback completion summary
await this.logger.logRollbackSummary(summary);
// Perform error analysis if there were failures (Requirement 3.5)
if (results.errors && results.errors.length > 0) {
await this.logger.logErrorAnalysis(results.errors, summary);
}
// Determine exit status with enhanced logic for rollback (Requirement 4.5)
const successRate =
summary.eligibleVariants > 0
? (summary.successfulRollbacks / summary.eligibleVariants) * 100
: 0;
if (results.failedRollbacks === 0) {
await this.logger.info(
"🎉 All rollback operations completed successfully!"
);
return 0; // Success
} else if (results.successfulRollbacks > 0) {
if (successRate >= 90) {
await this.logger.info(
`✅ Rollback completed with high success rate (${successRate.toFixed(
1
)}%). Minor issues encountered.`
);
return 0; // High success rate, treat as success
} else if (successRate >= 50) {
await this.logger.warning(
`⚠️ Rollback completed with moderate success rate (${successRate.toFixed(
1
)}%). Review errors above.`
);
return 1; // Partial failure
} else {
await this.logger.error(
`❌ Rollback completed with low success rate (${successRate.toFixed(
1
)}%). Significant issues detected.`
);
return 2; // Poor success rate
}
} else {
await this.logger.error(
"❌ All rollback operations failed. Please check your configuration and try again."
);
return 2; // Complete failure
}
}
/**
* Run the complete application workflow with dual operation mode support
* @returns {Promise<number>} Exit status code
*/
async run() {
@@ -230,23 +418,48 @@ class ShopifyPriceUpdater {
return await this.handleCriticalFailure("API connection failed", 1);
}
// Fetch and validate products with enhanced error handling
const products = await this.safeFetchAndValidateProducts();
if (products === null) {
return await this.handleCriticalFailure("Product fetching failed", 1);
}
// Display operation mode indication in console output (Requirements 9.3, 8.4)
await this.displayOperationModeHeader();
// Update prices with enhanced error handling
operationResults = await this.safeUpdatePrices(products);
if (operationResults === null) {
return await this.handleCriticalFailure(
"Price update process failed",
1
);
}
// Operation mode detection logic - route to appropriate workflow (Requirements 8.1, 9.1, 9.2)
if (this.config.operationMode === "rollback") {
// Rollback workflow
const products = await this.safeFetchAndValidateProductsForRollback();
if (products === null) {
return await this.handleCriticalFailure(
"Product fetching for rollback failed",
1
);
}
// Display summary and determine exit code
return await this.displaySummaryAndGetExitCode(operationResults);
operationResults = await this.safeRollbackPrices(products);
if (operationResults === null) {
return await this.handleCriticalFailure(
"Price rollback process failed",
1
);
}
// Display rollback-specific summary and determine exit code
return await this.displayRollbackSummary(operationResults);
} else {
// Default update workflow (Requirements 9.4, 9.5 - backward compatibility)
const products = await this.safeFetchAndValidateProducts();
if (products === null) {
return await this.handleCriticalFailure("Product fetching failed", 1);
}
operationResults = await this.safeUpdatePrices(products);
if (operationResults === null) {
return await this.handleCriticalFailure(
"Price update process failed",
1
);
}
// Display summary and determine exit code
return await this.displaySummaryAndGetExitCode(operationResults);
}
} catch (error) {
// Handle any unexpected errors with comprehensive logging (Requirement 4.5)
await this.handleUnexpectedError(error, operationResults);
@@ -345,31 +558,119 @@ class ShopifyPriceUpdater {
}
/**
* Handle critical failures with proper logging
* Safe wrapper for product fetching for rollback with enhanced error handling
* @returns {Promise<Array|null>} Products array or null if failed
*/
async safeFetchAndValidateProductsForRollback() {
try {
return await this.fetchAndValidateProductsForRollback();
} catch (error) {
await this.logger.error(
`Product fetching for rollback error: ${error.message}`
);
if (error.errorHistory) {
await this.logger.error(
`Fetch attempts made: ${error.totalAttempts || "Unknown"}`
);
}
return null;
}
}
/**
* Safe wrapper for rollback operations with enhanced error handling
* @param {Array} products - Products to rollback
* @returns {Promise<Object|null>} Rollback results or null if failed
*/
async safeRollbackPrices(products) {
try {
return await this.rollbackPrices(products);
} catch (error) {
await this.logger.error(`Price rollback error: ${error.message}`);
if (error.errorHistory) {
await this.logger.error(
`Rollback attempts made: ${error.totalAttempts || "Unknown"}`
);
}
// Return partial results if available
return {
totalProducts: products.length,
totalVariants: products.reduce(
(sum, p) => sum + (p.variants?.length || 0),
0
),
eligibleVariants: products.reduce(
(sum, p) => sum + (p.variants?.length || 0),
0
),
successfulRollbacks: 0,
failedRollbacks: products.reduce(
(sum, p) => sum + (p.variants?.length || 0),
0
),
skippedVariants: 0,
errors: [
{
productTitle: "System Error",
productId: "N/A",
errorMessage: error.message,
},
],
};
}
}
/**
* Handle critical failures with proper logging for both operation modes (Requirements 9.2, 9.3)
* @param {string} message - Failure message
* @param {number} exitCode - Exit code to return
* @returns {Promise<number>} Exit code
*/
async handleCriticalFailure(message, exitCode) {
await this.logger.error(`Critical failure: ${message}`);
await this.logger.error(
`Critical failure in ${
this.config?.operationMode || "unknown"
} mode: ${message}`
);
// Ensure progress logging continues even for critical failures
// Use appropriate summary format based on operation mode
try {
const summary = {
totalProducts: 0,
totalVariants: 0,
successfulUpdates: 0,
failedUpdates: 0,
startTime: this.startTime,
errors: [
{
productTitle: "Critical System Error",
productId: "N/A",
errorMessage: message,
},
],
};
await this.logger.logCompletionSummary(summary);
if (this.config?.operationMode === "rollback") {
const summary = {
totalProducts: 0,
totalVariants: 0,
eligibleVariants: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
startTime: this.startTime,
errors: [
{
productTitle: "Critical System Error",
productId: "N/A",
errorMessage: message,
},
],
};
await this.logger.logRollbackSummary(summary);
} else {
const summary = {
totalProducts: 0,
totalVariants: 0,
successfulUpdates: 0,
failedUpdates: 0,
startTime: this.startTime,
errors: [
{
productTitle: "Critical System Error",
productId: "N/A",
errorMessage: message,
},
],
};
await this.logger.logCompletionSummary(summary);
}
} catch (loggingError) {
console.error(
"Failed to log critical failure summary:",
@@ -381,13 +682,17 @@ class ShopifyPriceUpdater {
}
/**
* Handle unexpected errors with comprehensive logging
* Handle unexpected errors with comprehensive logging for both operation modes (Requirements 9.2, 9.3)
* @param {Error} error - The unexpected error
* @param {Object} operationResults - Partial results if available
* @returns {Promise<void>}
*/
async handleUnexpectedError(error, operationResults) {
await this.logger.error(`Unexpected error occurred: ${error.message}`);
await this.logger.error(
`Unexpected error occurred in ${
this.config?.operationMode || "unknown"
} mode: ${error.message}`
);
// Log error details
if (error.stack) {
@@ -402,22 +707,43 @@ class ShopifyPriceUpdater {
}
// Ensure progress logging continues even for unexpected errors
// Use appropriate summary format based on operation mode
try {
const summary = {
totalProducts: operationResults?.totalProducts || 0,
totalVariants: operationResults?.totalVariants || 0,
successfulUpdates: operationResults?.successfulUpdates || 0,
failedUpdates: operationResults?.failedUpdates || 0,
startTime: this.startTime,
errors: operationResults?.errors || [
{
productTitle: "Unexpected System Error",
productId: "N/A",
errorMessage: error.message,
},
],
};
await this.logger.logCompletionSummary(summary);
if (this.config?.operationMode === "rollback") {
const summary = {
totalProducts: operationResults?.totalProducts || 0,
totalVariants: operationResults?.totalVariants || 0,
eligibleVariants: operationResults?.eligibleVariants || 0,
successfulRollbacks: operationResults?.successfulRollbacks || 0,
failedRollbacks: operationResults?.failedRollbacks || 0,
skippedVariants: operationResults?.skippedVariants || 0,
startTime: this.startTime,
errors: operationResults?.errors || [
{
productTitle: "Unexpected System Error",
productId: "N/A",
errorMessage: error.message,
},
],
};
await this.logger.logRollbackSummary(summary);
} else {
const summary = {
totalProducts: operationResults?.totalProducts || 0,
totalVariants: operationResults?.totalVariants || 0,
successfulUpdates: operationResults?.successfulUpdates || 0,
failedUpdates: operationResults?.failedUpdates || 0,
startTime: this.startTime,
errors: operationResults?.errors || [
{
productTitle: "Unexpected System Error",
productId: "N/A",
errorMessage: error.message,
},
],
};
await this.logger.logCompletionSummary(summary);
}
} catch (loggingError) {
console.error(
"Failed to log unexpected error summary:",

View File

@@ -1,5 +1,10 @@
const ShopifyService = require("./shopify");
const { calculateNewPrice, preparePriceUpdate } = require("../utils/price");
const {
calculateNewPrice,
preparePriceUpdate,
validateRollbackEligibility,
prepareRollbackUpdate,
} = require("../utils/price");
const Logger = require("../utils/logger");
/**
@@ -251,6 +256,452 @@ class ProductService {
return validProducts;
}
/**
* Validate that products have the required data for rollback operations
* Filters products to only include those with variants that have valid compare-at prices
* @param {Array} products - Array of products to validate for rollback
* @returns {Promise<Array>} Array of products eligible for rollback
*/
async validateProductsForRollback(products) {
const eligibleProducts = [];
let skippedProductCount = 0;
let totalVariantsProcessed = 0;
let eligibleVariantsCount = 0;
await this.logger.info(
`Starting rollback validation for ${products.length} products`
);
for (const product of products) {
// Check if product has variants
if (!product.variants || product.variants.length === 0) {
await this.logger.warning(
`Skipping product "${product.title}" for rollback - no variants found`
);
skippedProductCount++;
continue;
}
// Check each variant for rollback eligibility
const eligibleVariants = [];
for (const variant of product.variants) {
totalVariantsProcessed++;
const eligibilityResult = validateRollbackEligibility(variant);
if (eligibilityResult.isEligible) {
eligibleVariants.push(variant);
eligibleVariantsCount++;
} else {
await this.logger.warning(
`Skipping variant "${variant.title}" in product "${product.title}" for rollback - ${eligibilityResult.reason}`
);
}
}
// Only include products that have at least one eligible variant
if (eligibleVariants.length === 0) {
await this.logger.warning(
`Skipping product "${product.title}" for rollback - no variants with valid compare-at prices`
);
skippedProductCount++;
continue;
}
// Add product with only eligible variants
eligibleProducts.push({
...product,
variants: eligibleVariants,
});
}
// Log validation summary
if (skippedProductCount > 0) {
await this.logger.warning(
`Skipped ${skippedProductCount} products during rollback validation`
);
}
await this.logger.info(
`Rollback validation completed: ${eligibleProducts.length} products eligible (${eligibleVariantsCount}/${totalVariantsProcessed} variants eligible)`
);
return eligibleProducts;
}
/**
* Update a single product variant price for rollback operation
* Sets the main price to the compare-at price and removes the compare-at price
* @param {Object} variant - Variant to rollback
* @param {string} variant.id - Variant ID
* @param {number} variant.price - Current price
* @param {number} variant.compareAtPrice - Compare-at price to use as new price
* @param {string} productId - Product ID that contains this variant
* @returns {Promise<Object>} Rollback result
*/
async rollbackVariantPrice(variant, productId) {
try {
// Validate rollback eligibility before attempting operation (Requirement 4.1)
const eligibilityResult = validateRollbackEligibility(variant);
if (!eligibilityResult.isEligible) {
return {
success: false,
error: `Rollback not eligible: ${eligibilityResult.reason}`,
rollbackDetails: {
oldPrice: variant.price,
compareAtPrice: variant.compareAtPrice,
newPrice: null,
},
errorType: "validation",
retryable: false,
};
}
// Prepare rollback update using utility function
const rollbackUpdate = prepareRollbackUpdate(variant);
const variables = {
productId: productId,
variants: [
{
id: variant.id,
price: rollbackUpdate.newPrice.toString(), // Shopify expects price as string
compareAtPrice: rollbackUpdate.compareAtPrice, // null to remove compare-at price
},
],
};
// Use existing retry logic for rollback API operations (Requirement 4.2)
const response = await this.shopifyService.executeWithRetry(
() =>
this.shopifyService.executeMutation(
this.getProductVariantUpdateMutation(),
variables
),
this.logger
);
// Check for user errors in the response
if (
response.productVariantsBulkUpdate.userErrors &&
response.productVariantsBulkUpdate.userErrors.length > 0
) {
const errors = response.productVariantsBulkUpdate.userErrors
.map((error) => `${error.field}: ${error.message}`)
.join(", ");
// Categorize Shopify API errors for better error analysis (Requirement 4.5)
const errorType = this.categorizeShopifyError(errors);
throw new Error(`Shopify API errors: ${errors}`);
}
return {
success: true,
updatedVariant: response.productVariantsBulkUpdate.productVariants[0],
rollbackDetails: {
oldPrice: variant.price,
compareAtPrice: variant.compareAtPrice,
newPrice: rollbackUpdate.newPrice,
},
};
} catch (error) {
// Enhanced error handling with categorization (Requirements 4.3, 4.4, 4.5)
const errorInfo = this.analyzeRollbackError(error, variant);
return {
success: false,
error: error.message,
rollbackDetails: {
oldPrice: variant.price,
compareAtPrice: variant.compareAtPrice,
newPrice: null,
},
errorType: errorInfo.type,
retryable: errorInfo.retryable,
errorHistory: error.errorHistory || null,
};
}
}
/**
* Process a single product for rollback operations
* @param {Object} product - Product to process for rollback
* @param {Object} results - Results object to update
* @returns {Promise<void>}
*/
async processProductForRollback(product, results) {
for (const variant of product.variants) {
results.totalVariants++;
try {
// Perform rollback operation on the variant with enhanced error handling
const rollbackResult = await this.rollbackVariantPrice(
variant,
product.id
);
if (rollbackResult.success) {
results.successfulRollbacks++;
// Log successful rollback using rollback-specific logging method
await this.logger.logRollbackUpdate({
productId: product.id,
productTitle: product.title,
variantId: variant.id,
oldPrice: rollbackResult.rollbackDetails.oldPrice,
newPrice: rollbackResult.rollbackDetails.newPrice,
compareAtPrice: rollbackResult.rollbackDetails.compareAtPrice,
});
} else {
// Handle different types of rollback failures (Requirements 4.1, 4.3, 4.4)
if (rollbackResult.errorType === "validation") {
// Skip variants without compare-at prices gracefully (Requirement 4.1)
results.skippedVariants++;
await this.logger.warning(
`Skipped variant "${variant.title || variant.id}" in product "${
product.title
}": ${rollbackResult.error}`
);
} else {
// Handle API and other errors (Requirements 4.2, 4.3, 4.4)
results.failedRollbacks++;
// Enhanced error entry with additional context (Requirement 4.5)
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: variant.id,
errorMessage: rollbackResult.error,
errorType: rollbackResult.errorType,
retryable: rollbackResult.retryable,
errorHistory: rollbackResult.errorHistory,
};
results.errors.push(errorEntry);
// Log rollback-specific error with enhanced details
await this.logger.error(
`Rollback failed for variant "${
variant.title || variant.id
}" in product "${product.title}": ${rollbackResult.error}`
);
// Log to progress file as well
await this.logger.logProductError(errorEntry);
}
}
} catch (error) {
// Handle unexpected errors that bypass the rollbackVariantPrice error handling (Requirement 4.4)
results.failedRollbacks++;
// Analyze unexpected error for better categorization
const errorInfo = this.analyzeRollbackError(error, variant);
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: variant.id,
errorMessage: `Unexpected rollback error: ${error.message}`,
errorType: errorInfo.type,
retryable: errorInfo.retryable,
errorHistory: error.errorHistory || null,
};
results.errors.push(errorEntry);
await this.logger.error(
`Unexpected rollback error for variant "${
variant.title || variant.id
}" in product "${product.title}": ${error.message}`
);
await this.logger.logProductError(errorEntry);
}
}
}
/**
* Rollback prices for all variants in a batch of products
* Sets main prices to compare-at prices and removes compare-at prices
* @param {Array} products - Array of products to rollback
* @returns {Promise<Object>} Batch rollback results
*/
async rollbackProductPrices(products) {
await this.logger.info(
`Starting price rollback for ${products.length} products`
);
const results = {
totalProducts: products.length,
totalVariants: 0,
eligibleVariants: products.reduce(
(sum, product) => sum + product.variants.length,
0
),
successfulRollbacks: 0,
failedRollbacks: 0,
skippedVariants: 0,
errors: [],
};
let processedProducts = 0;
let consecutiveErrors = 0;
const maxConsecutiveErrors = 5; // Stop processing if too many consecutive errors
// Process products in batches to manage rate limits with enhanced error handling
for (let i = 0; i < products.length; i += this.batchSize) {
const batch = products.slice(i, i + this.batchSize);
const batchNumber = Math.floor(i / this.batchSize) + 1;
const totalBatches = Math.ceil(products.length / this.batchSize);
await this.logger.info(
`Processing rollback batch ${batchNumber} of ${totalBatches} (${batch.length} products)`
);
let batchErrors = 0;
const batchStartTime = Date.now();
// Process each product in the batch with error recovery (Requirements 4.3, 4.4)
for (const product of batch) {
try {
const variantsBefore = results.totalVariants;
await this.processProductForRollback(product, results);
// Check if this product had any successful operations
const variantsProcessed = results.totalVariants - variantsBefore;
const productErrors = results.errors.filter(
(e) => e.productId === product.id
).length;
if (productErrors === 0) {
consecutiveErrors = 0; // Reset consecutive error counter on success
} else if (productErrors === variantsProcessed) {
// All variants in this product failed
consecutiveErrors++;
batchErrors++;
}
processedProducts++;
// Log progress for large batches
if (processedProducts % 10 === 0) {
await this.logger.info(
`Progress: ${processedProducts}/${products.length} products processed`
);
}
} catch (error) {
// Handle product-level errors that bypass processProductForRollback (Requirement 4.4)
consecutiveErrors++;
batchErrors++;
await this.logger.error(
`Failed to process product "${product.title}" (${product.id}): ${error.message}`
);
// Add product-level error to results
const errorEntry = {
productId: product.id,
productTitle: product.title,
variantId: "N/A",
errorMessage: `Product processing failed: ${error.message}`,
errorType: "product_processing_error",
retryable: false,
};
results.errors.push(errorEntry);
await this.logger.logProductError(errorEntry);
}
// Check for too many consecutive errors (Requirement 4.4)
if (consecutiveErrors >= maxConsecutiveErrors) {
await this.logger.error(
`Stopping rollback operation due to ${maxConsecutiveErrors} consecutive errors. This may indicate a systemic issue.`
);
// Add summary error for remaining products
const remainingProducts = products.length - processedProducts;
if (remainingProducts > 0) {
const systemErrorEntry = {
productId: "SYSTEM",
productTitle: `${remainingProducts} remaining products`,
variantId: "N/A",
errorMessage: `Processing stopped due to consecutive errors (${maxConsecutiveErrors} in a row)`,
errorType: "system_error",
retryable: true,
};
results.errors.push(systemErrorEntry);
}
break; // Exit product loop
}
}
// Exit batch loop if we hit consecutive error limit
if (consecutiveErrors >= maxConsecutiveErrors) {
break;
}
// Log batch completion with error summary
const batchDuration = Math.round((Date.now() - batchStartTime) / 1000);
if (batchErrors > 0) {
await this.logger.warning(
`Batch ${batchNumber} completed with ${batchErrors} product errors in ${batchDuration}s`
);
} else {
await this.logger.info(
`Batch ${batchNumber} completed successfully in ${batchDuration}s`
);
}
// Add adaptive delay between batches based on error rate (Requirement 4.2)
if (i + this.batchSize < products.length) {
let delay = 500; // Base delay
// Increase delay if we're seeing errors (rate limiting or server issues)
if (batchErrors > 0) {
delay = Math.min(delay * (1 + batchErrors), 5000); // Cap at 5 seconds
await this.logger.info(
`Increasing delay to ${delay}ms due to batch errors`
);
}
await this.delay(delay);
}
}
// Enhanced completion logging with error analysis (Requirement 4.5)
const successRate =
results.eligibleVariants > 0
? (
(results.successfulRollbacks / results.eligibleVariants) *
100
).toFixed(1)
: 0;
await this.logger.info(
`Price rollback completed. Success: ${results.successfulRollbacks}, Failed: ${results.failedRollbacks}, Skipped: ${results.skippedVariants}, Success Rate: ${successRate}%`
);
// Log error summary if there were failures
if (results.errors.length > 0) {
const errorsByType = {};
results.errors.forEach((error) => {
const type = error.errorType || "unknown";
errorsByType[type] = (errorsByType[type] || 0) + 1;
});
await this.logger.warning(
`Error breakdown: ${Object.entries(errorsByType)
.map(([type, count]) => `${type}: ${count}`)
.join(", ")}`
);
}
return results;
}
/**
* Get summary statistics for fetched products
* @param {Array} products - Array of products
@@ -558,6 +1009,152 @@ class ProductService {
}
}
/**
* Categorize Shopify API errors for better error analysis (Requirement 4.5)
* @param {string} errorMessage - Shopify API error message
* @returns {string} Error category
*/
categorizeShopifyError(errorMessage) {
const message = errorMessage.toLowerCase();
if (
message.includes("price") &&
(message.includes("invalid") || message.includes("must be"))
) {
return "price_validation";
}
if (message.includes("variant") && message.includes("not found")) {
return "variant_not_found";
}
if (message.includes("product") && message.includes("not found")) {
return "product_not_found";
}
if (message.includes("permission") || message.includes("access")) {
return "permission_denied";
}
if (message.includes("rate limit") || message.includes("throttled")) {
return "rate_limit";
}
return "shopify_api_error";
}
/**
* Analyze rollback errors for enhanced error handling (Requirements 4.3, 4.4, 4.5)
* @param {Error} error - Error to analyze
* @param {Object} variant - Variant that caused the error
* @returns {Object} Error analysis result
*/
analyzeRollbackError(error, variant) {
const message = error.message.toLowerCase();
// Network and connection errors (retryable)
if (
message.includes("network") ||
message.includes("connection") ||
message.includes("timeout") ||
message.includes("econnreset")
) {
return {
type: "network_error",
retryable: true,
category: "Network Issues",
};
}
// Rate limiting errors (retryable)
if (
message.includes("rate limit") ||
message.includes("429") ||
message.includes("throttled")
) {
return {
type: "rate_limit",
retryable: true,
category: "Rate Limiting",
};
}
// Server errors (retryable)
if (
message.includes("500") ||
message.includes("502") ||
message.includes("503") ||
message.includes("server error")
) {
return {
type: "server_error",
retryable: true,
category: "Server Errors",
};
}
// Authentication errors (not retryable)
if (
message.includes("unauthorized") ||
message.includes("401") ||
message.includes("authentication")
) {
return {
type: "authentication_error",
retryable: false,
category: "Authentication",
};
}
// Permission errors (not retryable)
if (
message.includes("forbidden") ||
message.includes("403") ||
message.includes("permission")
) {
return {
type: "permission_error",
retryable: false,
category: "Permissions",
};
}
// Data validation errors (not retryable)
if (
message.includes("invalid") ||
message.includes("validation") ||
message.includes("price") ||
message.includes("compare-at")
) {
return {
type: "validation_error",
retryable: false,
category: "Data Validation",
};
}
// Resource not found errors (not retryable)
if (message.includes("not found") || message.includes("404")) {
return {
type: "not_found_error",
retryable: false,
category: "Resource Not Found",
};
}
// Shopify API specific errors
if (message.includes("shopify") && message.includes("api")) {
return {
type: "shopify_api_error",
retryable: false,
category: "Shopify API",
};
}
// Unknown errors (potentially retryable)
return {
type: "unknown_error",
retryable: true,
category: "Other",
};
}
/**
* Utility function to add delay between operations
* @param {number} ms - Milliseconds to delay

View File

@@ -35,6 +35,28 @@ class ProgressService {
- Price Adjustment: ${config.priceAdjustmentPercentage}%
- Started: ${timestamp}
**Progress:**
`;
await this.appendToProgressFile(content);
}
/**
* Logs the start of a price rollback operation (Requirements 7.1, 8.3)
* @param {Object} config - Configuration object with operation details
* @param {string} config.targetTag - The tag being targeted
* @returns {Promise<void>}
*/
async logRollbackStart(config) {
const timestamp = this.formatTimestamp();
const content = `
## Price Rollback Operation - ${timestamp}
**Configuration:**
- Target Tag: ${config.targetTag}
- Operation Mode: rollback
- Started: ${timestamp}
**Progress:**
`;
@@ -66,6 +88,28 @@ class ProgressService {
await this.appendToProgressFile(content);
}
/**
* Logs a successful product rollback (Requirements 7.2, 8.3)
* @param {Object} entry - Rollback progress entry object
* @param {string} entry.productId - Shopify product ID
* @param {string} entry.productTitle - Product title
* @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<void>}
*/
async logRollbackUpdate(entry) {
const timestamp = this.formatTimestamp();
const content = `- 🔄 **${entry.productTitle}** (${entry.productId})
- Variant: ${entry.variantId}
- Price: $${entry.oldPrice}$${entry.newPrice} (from Compare At: $${entry.compareAtPrice})
- Rolled back: ${timestamp}
`;
await this.appendToProgressFile(content);
}
/**
* Logs an error that occurred during product processing
* @param {Object} entry - Progress entry object with error details
@@ -111,6 +155,42 @@ class ProgressService {
---
`;
await this.appendToProgressFile(content);
}
/**
* Logs the completion summary of a rollback operation (Requirements 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 - Number of successful rollbacks
* @param {number} summary.failedRollbacks - Number of failed rollbacks
* @param {number} summary.skippedVariants - Variants skipped (no compare-at price)
* @param {Date} summary.startTime - Operation start time
* @returns {Promise<void>}
*/
async logRollbackSummary(summary) {
const timestamp = this.formatTimestamp();
const duration = summary.startTime
? Math.round((new Date() - summary.startTime) / 1000)
: "Unknown";
const content = `
**Rollback Summary:**
- Total Products Processed: ${summary.totalProducts}
- Total Variants Processed: ${summary.totalVariants}
- Eligible Variants: ${summary.eligibleVariants}
- Successful Rollbacks: ${summary.successfulRollbacks}
- Failed Rollbacks: ${summary.failedRollbacks}
- Skipped Variants: ${summary.skippedVariants} (no compare-at price)
- Duration: ${duration} seconds
- Completed: ${timestamp}
---
`;
await this.appendToProgressFile(content);
@@ -191,6 +271,7 @@ ${content}`;
// Categorize errors by type
const errorCategories = {};
const errorDetails = [];
const retryableCount = { retryable: 0, nonRetryable: 0, unknown: 0 };
errors.forEach((error, index) => {
const category = this.categorizeError(
@@ -201,6 +282,15 @@ ${content}`;
}
errorCategories[category]++;
// Track retryable status for rollback analysis
if (error.retryable === true) {
retryableCount.retryable++;
} else if (error.retryable === false) {
retryableCount.nonRetryable++;
} else {
retryableCount.unknown++;
}
errorDetails.push({
index: index + 1,
product: error.productTitle || "Unknown",
@@ -208,6 +298,14 @@ ${content}`;
variantId: error.variantId || "N/A",
error: error.errorMessage || error.error || "Unknown error",
category,
errorType: error.errorType || "unknown",
retryable:
error.retryable !== undefined
? error.retryable
? "Yes"
: "No"
: "Unknown",
hasHistory: error.errorHistory ? "Yes" : "No",
});
});
@@ -222,15 +320,28 @@ ${content}`;
content += `- ${category}: ${count} error${count !== 1 ? "s" : ""}\n`;
});
// Add retryable analysis for rollback operations
if (retryableCount.retryable > 0 || retryableCount.nonRetryable > 0) {
content += `
**Retryability Analysis:**
- Retryable Errors: ${retryableCount.retryable}
- Non-Retryable Errors: ${retryableCount.nonRetryable}
- Unknown Retryability: ${retryableCount.unknown}
`;
}
content += `
**Detailed Error Log:**
`;
// Add detailed error information
// Add detailed error information with enhanced fields
errorDetails.forEach((detail) => {
content += `${detail.index}. **${detail.product}** (${detail.productId})
- Variant: ${detail.variantId}
- Category: ${detail.category}
- Error Type: ${detail.errorType}
- Retryable: ${detail.retryable}
- Has Retry History: ${detail.hasHistory}
- Error: ${detail.error}
`;
});

View File

@@ -95,6 +95,26 @@ class Logger {
await this.progressService.logOperationStart(config);
}
/**
* Logs rollback operation start with configuration details (Requirements 3.1, 7.1, 8.3)
* @param {Object} config - Configuration object
* @returns {Promise<void>}
*/
async logRollbackStart(config) {
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
try {
await this.progressService.logRollbackStart(config);
} 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
@@ -128,6 +148,30 @@ class Logger {
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<void>}
*/
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
@@ -163,6 +207,59 @@ class Logger {
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<void>}
*/
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
@@ -226,12 +323,18 @@ class Logger {
return;
}
const operationType =
summary.successfulRollbacks !== undefined ? "ROLLBACK" : "UPDATE";
await this.info("=".repeat(50));
await this.info("ERROR ANALYSIS");
await this.info(`${operationType} ERROR ANALYSIS`);
await this.info("=".repeat(50));
// Categorize errors
// Enhanced categorization for rollback operations
const categories = {};
const retryableErrors = [];
const nonRetryableErrors = [];
errors.forEach((error) => {
const category = this.categorizeError(
error.errorMessage || error.error || "Unknown"
@@ -240,6 +343,13 @@ class Logger {
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
@@ -254,9 +364,52 @@ class Logger {
);
});
// 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);
await this.provideErrorRecommendations(categories, summary, operationType);
// Log to progress file as well
await this.progressService.logErrorAnalysis(errors);
@@ -327,9 +480,14 @@ class Logger {
* 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<void>}
*/
async provideErrorRecommendations(categories, summary) {
async provideErrorRecommendations(
categories,
summary,
operationType = "UPDATE"
) {
if (categories["Rate Limiting"]) {
await this.info(
" • Consider reducing batch size or adding delays between requests"
@@ -342,6 +500,11 @@ class Logger {
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"]) {
@@ -352,32 +515,90 @@ class Logger {
}
if (categories["Data Validation"]) {
await this.info(
" • Review product data for invalid prices or missing information"
);
await this.info(
" • Consider adding more robust data validation before updates"
);
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");
}
}
// Success rate analysis
const successRate =
summary.totalVariants > 0
? ((summary.successfulUpdates / summary.totalVariants) * 100).toFixed(1)
: 0;
// 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 - consider reviewing configuration"
` • 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 - some optimization may be beneficial"
` • Moderate success rate (${successRate}%) - some optimization may be beneficial`
);
} else {
await this.info(
` • Good success rate (${successRate}%) - most operations completed successfully`
);
}
}

View File

@@ -133,6 +133,134 @@ function preparePriceUpdate(originalPrice, percentage) {
};
}
/**
* Validates if a variant is eligible for rollback operation
* @param {Object} variant - The variant object with price and compareAtPrice
* @returns {Object} Object containing isEligible boolean and reason if not eligible
*/
function validateRollbackEligibility(variant) {
// Check if variant object exists
if (!variant || typeof variant !== "object") {
return {
isEligible: false,
reason: "Invalid variant object",
variant: null,
};
}
// Extract price and compareAtPrice from variant
const currentPrice = parseFloat(variant.price);
const compareAtPrice =
variant.compareAtPrice !== null && variant.compareAtPrice !== undefined
? parseFloat(variant.compareAtPrice)
: null;
// Check if current price is valid
if (!isValidPrice(currentPrice)) {
return {
isEligible: false,
reason: "Invalid current price",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price exists
if (compareAtPrice === null || compareAtPrice === undefined) {
return {
isEligible: false,
reason: "No compare-at price available",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price is a valid number first
if (
typeof compareAtPrice !== "number" ||
isNaN(compareAtPrice) ||
!isFinite(compareAtPrice)
) {
return {
isEligible: false,
reason: "Invalid compare-at price",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price is positive (greater than 0)
if (compareAtPrice <= 0) {
return {
isEligible: false,
reason: "Compare-at price must be greater than zero",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Check if compare-at price is different from current price
if (Math.abs(currentPrice - compareAtPrice) < 0.01) {
// Use small epsilon for floating point comparison
return {
isEligible: false,
reason: "Compare-at price is the same as current price",
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
// Variant is eligible for rollback
return {
isEligible: true,
variant: {
id: variant.id,
currentPrice,
compareAtPrice,
},
};
}
/**
* Prepares a rollback update object for a variant
* @param {Object} variant - The variant object with price and compareAtPrice
* @returns {Object} Object containing newPrice and compareAtPrice for rollback operation
* @throws {Error} If variant is not eligible for rollback
*/
function prepareRollbackUpdate(variant) {
// First validate if the variant is eligible for rollback
const eligibilityResult = validateRollbackEligibility(variant);
if (!eligibilityResult.isEligible) {
throw new Error(
`Cannot prepare rollback update: ${eligibilityResult.reason}`
);
}
const { currentPrice, compareAtPrice } = eligibilityResult.variant;
// For rollback: new price becomes the compare-at price, compare-at price becomes null
return {
newPrice: compareAtPrice,
compareAtPrice: null,
};
}
module.exports = {
calculateNewPrice,
isValidPrice,
@@ -140,4 +268,6 @@ module.exports = {
calculatePercentageChange,
isValidPercentage,
preparePriceUpdate,
validateRollbackEligibility,
prepareRollbackUpdate,
};