Implemented Rollback Functionality
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
430
src/index.js
430
src/index.js
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user