Initial commit: Complete Shopify Price Updater implementation
- Full Node.js application with Shopify GraphQL API integration - Compare At price support for promotional pricing - Comprehensive error handling and retry logic - Progress tracking with markdown logging - Complete test suite with unit and integration tests - Production-ready with proper exit codes and signal handling
This commit is contained in:
119
src/config/environment.js
Normal file
119
src/config/environment.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* Validates and loads environment configuration
|
||||
* @returns {Object} Configuration object with validated environment variables
|
||||
* @throws {Error} If required environment variables are missing or invalid
|
||||
*/
|
||||
function loadEnvironmentConfig() {
|
||||
const config = {
|
||||
shopDomain: process.env.SHOPIFY_SHOP_DOMAIN,
|
||||
accessToken: process.env.SHOPIFY_ACCESS_TOKEN,
|
||||
targetTag: process.env.TARGET_TAG,
|
||||
priceAdjustmentPercentage: process.env.PRICE_ADJUSTMENT_PERCENTAGE,
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
const requiredVars = [
|
||||
{
|
||||
key: "SHOPIFY_SHOP_DOMAIN",
|
||||
value: config.shopDomain,
|
||||
name: "Shopify shop domain",
|
||||
},
|
||||
{
|
||||
key: "SHOPIFY_ACCESS_TOKEN",
|
||||
value: config.accessToken,
|
||||
name: "Shopify access token",
|
||||
},
|
||||
{ key: "TARGET_TAG", value: config.targetTag, name: "Target product tag" },
|
||||
{
|
||||
key: "PRICE_ADJUSTMENT_PERCENTAGE",
|
||||
value: config.priceAdjustmentPercentage,
|
||||
name: "Price adjustment percentage",
|
||||
},
|
||||
];
|
||||
|
||||
const missingVars = [];
|
||||
|
||||
for (const variable of requiredVars) {
|
||||
if (!variable.value || variable.value.trim() === "") {
|
||||
missingVars.push(variable.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
const errorMessage =
|
||||
`Missing required environment variables: ${missingVars.join(", ")}\n` +
|
||||
"Please check your .env file and ensure all required variables are set.\n" +
|
||||
"See .env.example for reference.";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Validate and convert price adjustment percentage
|
||||
const percentage = parseFloat(config.priceAdjustmentPercentage);
|
||||
if (isNaN(percentage)) {
|
||||
throw new Error(
|
||||
`Invalid PRICE_ADJUSTMENT_PERCENTAGE: "${config.priceAdjustmentPercentage}". Must be a valid number.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate shop domain format
|
||||
if (
|
||||
!config.shopDomain.includes(".myshopify.com") &&
|
||||
!config.shopDomain.includes(".")
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid SHOPIFY_SHOP_DOMAIN: "${config.shopDomain}". Must be a valid Shopify domain (e.g., your-shop.myshopify.com)`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate access token format (basic check)
|
||||
if (config.accessToken.length < 10) {
|
||||
throw new Error(
|
||||
"Invalid SHOPIFY_ACCESS_TOKEN: Token appears to be too short. Please verify your access token."
|
||||
);
|
||||
}
|
||||
|
||||
// Validate target tag is not empty after trimming
|
||||
const trimmedTag = config.targetTag.trim();
|
||||
if (trimmedTag === "") {
|
||||
throw new Error(
|
||||
"Invalid TARGET_TAG: Tag cannot be empty or contain only whitespace."
|
||||
);
|
||||
}
|
||||
|
||||
// Return validated configuration
|
||||
return {
|
||||
shopDomain: config.shopDomain.trim(),
|
||||
accessToken: config.accessToken.trim(),
|
||||
targetTag: trimmedTag,
|
||||
priceAdjustmentPercentage: percentage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated environment configuration
|
||||
* Caches the configuration after first load
|
||||
*/
|
||||
let cachedConfig = null;
|
||||
|
||||
function getConfig() {
|
||||
if (!cachedConfig) {
|
||||
try {
|
||||
cachedConfig = loadEnvironmentConfig();
|
||||
} catch (error) {
|
||||
console.error("Environment Configuration Error:");
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfig,
|
||||
loadEnvironmentConfig, // Export for testing purposes
|
||||
};
|
||||
510
src/index.js
Normal file
510
src/index.js
Normal file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Shopify Price Updater - Main Application Entry Point
|
||||
*
|
||||
* This script connects to Shopify's GraphQL API to update product prices
|
||||
* based on specific tag criteria and configurable percentage adjustments.
|
||||
*/
|
||||
|
||||
const { getConfig } = require("./config/environment");
|
||||
const ProductService = require("./services/product");
|
||||
const Logger = require("./utils/logger");
|
||||
|
||||
/**
|
||||
* Main application class that orchestrates the price update workflow
|
||||
*/
|
||||
class ShopifyPriceUpdater {
|
||||
constructor() {
|
||||
this.logger = new Logger();
|
||||
this.productService = new ProductService();
|
||||
this.config = null;
|
||||
this.startTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the application and load configuration
|
||||
* @returns {Promise<boolean>} True if initialization successful
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Load and validate configuration
|
||||
this.config = getConfig();
|
||||
|
||||
// Log operation start with configuration (Requirement 3.1)
|
||||
await this.logger.logOperationStart(this.config);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
await this.logger.error(`Initialization failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Shopify API
|
||||
* @returns {Promise<boolean>} True if connection successful
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
await this.logger.info("Testing connection to Shopify API...");
|
||||
const isConnected =
|
||||
await this.productService.shopifyService.testConnection();
|
||||
|
||||
if (!isConnected) {
|
||||
await this.logger.error(
|
||||
"Failed to connect to Shopify API. Please check your credentials."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.logger.info("Successfully connected to Shopify API");
|
||||
return true;
|
||||
} catch (error) {
|
||||
await this.logger.error(`Connection test failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch products by tag and validate them
|
||||
* @returns {Promise<Array|null>} Array of valid products or null if failed
|
||||
*/
|
||||
async fetchAndValidateProducts() {
|
||||
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 price updates
|
||||
const validProducts = await this.productService.validateProducts(
|
||||
products
|
||||
);
|
||||
|
||||
// Display summary statistics
|
||||
const summary = this.productService.getProductSummary(validProducts);
|
||||
await this.logger.info(`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 validProducts;
|
||||
} catch (error) {
|
||||
await this.logger.error(`Failed to fetch products: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update prices for all products
|
||||
* @param {Array} products - Array of products to update
|
||||
* @returns {Promise<Object|null>} Update results or null if failed
|
||||
*/
|
||||
async updatePrices(products) {
|
||||
try {
|
||||
if (products.length === 0) {
|
||||
return {
|
||||
totalProducts: 0,
|
||||
totalVariants: 0,
|
||||
successfulUpdates: 0,
|
||||
failedUpdates: 0,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
await this.logger.info(
|
||||
`Starting price updates with ${this.config.priceAdjustmentPercentage}% adjustment`
|
||||
);
|
||||
|
||||
// Update product prices
|
||||
const results = await this.productService.updateProductPrices(
|
||||
products,
|
||||
this.config.priceAdjustmentPercentage
|
||||
);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
await this.logger.error(`Price update failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display final summary and determine exit status
|
||||
* @param {Object} results - Update results
|
||||
* @returns {number} Exit status code
|
||||
*/
|
||||
async displaySummaryAndGetExitCode(results) {
|
||||
// Prepare comprehensive summary for logging (Requirement 3.4)
|
||||
const summary = {
|
||||
totalProducts: results.totalProducts,
|
||||
totalVariants: results.totalVariants,
|
||||
successfulUpdates: results.successfulUpdates,
|
||||
failedUpdates: results.failedUpdates,
|
||||
startTime: this.startTime,
|
||||
errors: results.errors || [],
|
||||
};
|
||||
|
||||
// Log completion summary
|
||||
await this.logger.logCompletionSummary(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 (Requirement 4.5)
|
||||
const successRate =
|
||||
summary.totalVariants > 0
|
||||
? (summary.successfulUpdates / summary.totalVariants) * 100
|
||||
: 0;
|
||||
|
||||
if (results.failedUpdates === 0) {
|
||||
await this.logger.info("🎉 All operations completed successfully!");
|
||||
return 0; // Success
|
||||
} else if (results.successfulUpdates > 0) {
|
||||
if (successRate >= 90) {
|
||||
await this.logger.info(
|
||||
`✅ Operation 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(
|
||||
`⚠️ Operation completed with moderate success rate (${successRate.toFixed(
|
||||
1
|
||||
)}%). Review errors above.`
|
||||
);
|
||||
return 1; // Partial failure
|
||||
} else {
|
||||
await this.logger.error(
|
||||
`❌ Operation completed with low success rate (${successRate.toFixed(
|
||||
1
|
||||
)}%). Significant issues detected.`
|
||||
);
|
||||
return 2; // Poor success rate
|
||||
}
|
||||
} else {
|
||||
await this.logger.error(
|
||||
"❌ All update operations failed. Please check your configuration and try again."
|
||||
);
|
||||
return 2; // Complete failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the complete price update workflow
|
||||
* @returns {Promise<number>} Exit status code
|
||||
*/
|
||||
async run() {
|
||||
this.startTime = new Date();
|
||||
let operationResults = null;
|
||||
|
||||
try {
|
||||
// Initialize application with enhanced error handling
|
||||
const initialized = await this.safeInitialize();
|
||||
if (!initialized) {
|
||||
return await this.handleCriticalFailure("Initialization failed", 1);
|
||||
}
|
||||
|
||||
// Test API connection with enhanced error handling
|
||||
const connected = await this.safeTestConnection();
|
||||
if (!connected) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Update prices with enhanced error handling
|
||||
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);
|
||||
return 2; // Unexpected error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe wrapper for initialization with enhanced error handling
|
||||
* @returns {Promise<boolean>} True if successful
|
||||
*/
|
||||
async safeInitialize() {
|
||||
try {
|
||||
return await this.initialize();
|
||||
} catch (error) {
|
||||
await this.logger.error(`Initialization error: ${error.message}`);
|
||||
if (error.stack) {
|
||||
console.error("Stack trace:", error.stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe wrapper for connection testing with enhanced error handling
|
||||
* @returns {Promise<boolean>} True if successful
|
||||
*/
|
||||
async safeTestConnection() {
|
||||
try {
|
||||
return await this.testConnection();
|
||||
} catch (error) {
|
||||
await this.logger.error(`Connection test error: ${error.message}`);
|
||||
if (error.errorHistory) {
|
||||
await this.logger.error(
|
||||
`Connection attempts made: ${error.totalAttempts || "Unknown"}`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe wrapper for product fetching with enhanced error handling
|
||||
* @returns {Promise<Array|null>} Products array or null if failed
|
||||
*/
|
||||
async safeFetchAndValidateProducts() {
|
||||
try {
|
||||
return await this.fetchAndValidateProducts();
|
||||
} catch (error) {
|
||||
await this.logger.error(`Product fetching error: ${error.message}`);
|
||||
if (error.errorHistory) {
|
||||
await this.logger.error(
|
||||
`Fetch attempts made: ${error.totalAttempts || "Unknown"}`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe wrapper for price updates with enhanced error handling
|
||||
* @param {Array} products - Products to update
|
||||
* @returns {Promise<Object|null>} Update results or null if failed
|
||||
*/
|
||||
async safeUpdatePrices(products) {
|
||||
try {
|
||||
return await this.updatePrices(products);
|
||||
} catch (error) {
|
||||
await this.logger.error(`Price update error: ${error.message}`);
|
||||
if (error.errorHistory) {
|
||||
await this.logger.error(
|
||||
`Update 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
|
||||
),
|
||||
successfulUpdates: 0,
|
||||
failedUpdates: products.reduce(
|
||||
(sum, p) => sum + (p.variants?.length || 0),
|
||||
0
|
||||
),
|
||||
errors: [
|
||||
{
|
||||
productTitle: "System Error",
|
||||
productId: "N/A",
|
||||
errorMessage: error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle critical failures with proper logging
|
||||
* @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}`);
|
||||
|
||||
// Ensure progress logging continues even for critical failures
|
||||
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);
|
||||
} catch (loggingError) {
|
||||
console.error(
|
||||
"Failed to log critical failure summary:",
|
||||
loggingError.message
|
||||
);
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle unexpected errors with comprehensive logging
|
||||
* @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}`);
|
||||
|
||||
// Log error details
|
||||
if (error.stack) {
|
||||
await this.logger.error("Stack trace:");
|
||||
console.error(error.stack);
|
||||
}
|
||||
|
||||
if (error.errorHistory) {
|
||||
await this.logger.error(
|
||||
"Error history available - check logs for retry attempts"
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure progress logging continues even for unexpected errors
|
||||
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);
|
||||
} catch (loggingError) {
|
||||
console.error(
|
||||
"Failed to log unexpected error summary:",
|
||||
loggingError.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution function
|
||||
* Handles graceful exit with appropriate status codes
|
||||
*/
|
||||
async function main() {
|
||||
const app = new ShopifyPriceUpdater();
|
||||
|
||||
// Handle process signals for graceful shutdown with enhanced logging
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n🛑 Received SIGINT (Ctrl+C). Shutting down gracefully...");
|
||||
try {
|
||||
// Attempt to log shutdown to progress file
|
||||
const logger = new Logger();
|
||||
await logger.warning("Operation interrupted by user (SIGINT)");
|
||||
} catch (error) {
|
||||
console.error("Failed to log shutdown:", error.message);
|
||||
}
|
||||
process.exit(130); // Standard exit code for SIGINT
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n🛑 Received SIGTERM. Shutting down gracefully...");
|
||||
try {
|
||||
// Attempt to log shutdown to progress file
|
||||
const logger = new Logger();
|
||||
await logger.warning("Operation terminated by system (SIGTERM)");
|
||||
} catch (error) {
|
||||
console.error("Failed to log shutdown:", error.message);
|
||||
}
|
||||
process.exit(143); // Standard exit code for SIGTERM
|
||||
});
|
||||
|
||||
// Handle unhandled promise rejections with enhanced logging
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
console.error("🚨 Unhandled Promise Rejection detected:");
|
||||
console.error("Promise:", promise);
|
||||
console.error("Reason:", reason);
|
||||
|
||||
try {
|
||||
// Attempt to log to progress file
|
||||
const logger = new Logger();
|
||||
await logger.error(`Unhandled Promise Rejection: ${reason}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to log unhandled rejection:", error.message);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions with enhanced logging
|
||||
process.on("uncaughtException", async (error) => {
|
||||
console.error("🚨 Uncaught Exception detected:");
|
||||
console.error("Error:", error.message);
|
||||
console.error("Stack:", error.stack);
|
||||
|
||||
try {
|
||||
// Attempt to log to progress file
|
||||
const logger = new Logger();
|
||||
await logger.error(`Uncaught Exception: ${error.message}`);
|
||||
} catch (loggingError) {
|
||||
console.error("Failed to log uncaught exception:", loggingError.message);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
try {
|
||||
const exitCode = await app.run();
|
||||
process.exit(exitCode);
|
||||
} catch (error) {
|
||||
console.error("Fatal error:", error.message);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run main if this file is executed directly
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = ShopifyPriceUpdater;
|
||||
571
src/services/product.js
Normal file
571
src/services/product.js
Normal file
@@ -0,0 +1,571 @@
|
||||
const ShopifyService = require("./shopify");
|
||||
const { calculateNewPrice, preparePriceUpdate } = require("../utils/price");
|
||||
const Logger = require("../utils/logger");
|
||||
|
||||
/**
|
||||
* Product service for querying and updating Shopify products
|
||||
* Handles product fetching by tag and price updates
|
||||
*/
|
||||
class ProductService {
|
||||
constructor() {
|
||||
this.shopifyService = new ShopifyService();
|
||||
this.logger = new Logger();
|
||||
this.pageSize = 50; // Shopify recommends max 250, but 50 is safer for rate limits
|
||||
this.batchSize = 10; // Process variants in batches to manage rate limits
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL query to fetch products by tag with pagination
|
||||
*/
|
||||
getProductsByTagQuery() {
|
||||
return `
|
||||
query getProductsByTag($query: String!, $first: Int!, $after: String) {
|
||||
products(first: $first, after: $after, query: $query) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
tags
|
||||
variants(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
price
|
||||
compareAtPrice
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL query to fetch all products (for debugging tag issues)
|
||||
*/
|
||||
getAllProductsQuery() {
|
||||
return `
|
||||
query getAllProducts($first: Int!, $after: String) {
|
||||
products(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
tags
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphQL mutation to update product variant price and Compare At price
|
||||
*/
|
||||
getProductVariantUpdateMutation() {
|
||||
return `
|
||||
mutation productVariantsBulkUpdate($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
|
||||
productVariantsBulkUpdate(productId: $productId, variants: $variants) {
|
||||
productVariants {
|
||||
id
|
||||
price
|
||||
compareAtPrice
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all products with the specified tag using cursor-based pagination
|
||||
* @param {string} tag - Tag to filter products by
|
||||
* @returns {Promise<Array>} Array of products with their variants
|
||||
*/
|
||||
async fetchProductsByTag(tag) {
|
||||
await this.logger.info(`Starting to fetch products with tag: ${tag}`);
|
||||
|
||||
const allProducts = [];
|
||||
let hasNextPage = true;
|
||||
let cursor = null;
|
||||
let pageCount = 0;
|
||||
|
||||
try {
|
||||
while (hasNextPage) {
|
||||
pageCount++;
|
||||
await this.logger.info(`Fetching page ${pageCount} of products...`);
|
||||
|
||||
const queryString = tag.startsWith("tag:") ? tag : `tag:${tag}`;
|
||||
const variables = {
|
||||
query: queryString, // Shopify query format for tags
|
||||
first: this.pageSize,
|
||||
after: cursor,
|
||||
};
|
||||
|
||||
await this.logger.info(`Using GraphQL query string: "${queryString}"`);
|
||||
|
||||
const response = await this.shopifyService.executeWithRetry(
|
||||
() =>
|
||||
this.shopifyService.executeQuery(
|
||||
this.getProductsByTagQuery(),
|
||||
variables
|
||||
),
|
||||
this.logger
|
||||
);
|
||||
|
||||
if (!response.products) {
|
||||
throw new Error("Invalid response structure: missing products field");
|
||||
}
|
||||
|
||||
const { edges, pageInfo } = response.products;
|
||||
|
||||
// Process products from this page
|
||||
const pageProducts = edges.map((edge) => ({
|
||||
id: edge.node.id,
|
||||
title: edge.node.title,
|
||||
tags: edge.node.tags,
|
||||
variants: edge.node.variants.edges.map((variantEdge) => ({
|
||||
id: variantEdge.node.id,
|
||||
price: parseFloat(variantEdge.node.price),
|
||||
compareAtPrice: variantEdge.node.compareAtPrice
|
||||
? parseFloat(variantEdge.node.compareAtPrice)
|
||||
: null,
|
||||
title: variantEdge.node.title,
|
||||
})),
|
||||
}));
|
||||
|
||||
allProducts.push(...pageProducts);
|
||||
await this.logger.info(
|
||||
`Found ${pageProducts.length} products on page ${pageCount}`
|
||||
);
|
||||
|
||||
// Update pagination info
|
||||
hasNextPage = pageInfo.hasNextPage;
|
||||
cursor = pageInfo.endCursor;
|
||||
|
||||
// Log progress for large datasets
|
||||
if (allProducts.length > 0 && allProducts.length % 100 === 0) {
|
||||
await this.logger.info(
|
||||
`Total products fetched so far: ${allProducts.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.logger.info(
|
||||
`Successfully fetched ${allProducts.length} products with tag: ${tag}`
|
||||
);
|
||||
|
||||
// Log variant count for additional context
|
||||
const totalVariants = allProducts.reduce(
|
||||
(sum, product) => sum + product.variants.length,
|
||||
0
|
||||
);
|
||||
await this.logger.info(`Total product variants found: ${totalVariants}`);
|
||||
|
||||
return allProducts;
|
||||
} catch (error) {
|
||||
await this.logger.error(
|
||||
`Failed to fetch products with tag ${tag}: ${error.message}`
|
||||
);
|
||||
throw new Error(`Product fetching failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that products have the required data for price updates
|
||||
* @param {Array} products - Array of products to validate
|
||||
* @returns {Promise<Array>} Array of valid products
|
||||
*/
|
||||
async validateProducts(products) {
|
||||
const validProducts = [];
|
||||
let skippedCount = 0;
|
||||
|
||||
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}" - no variants found`
|
||||
);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if variants have valid price data
|
||||
const validVariants = [];
|
||||
for (const variant of product.variants) {
|
||||
if (typeof variant.price !== "number" || isNaN(variant.price)) {
|
||||
await this.logger.warning(
|
||||
`Skipping variant "${variant.title}" in product "${product.title}" - invalid price: ${variant.price}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (variant.price < 0) {
|
||||
await this.logger.warning(
|
||||
`Skipping variant "${variant.title}" in product "${product.title}" - negative price: ${variant.price}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
validVariants.push(variant);
|
||||
}
|
||||
|
||||
if (validVariants.length === 0) {
|
||||
await this.logger.warning(
|
||||
`Skipping product "${product.title}" - no variants with valid prices`
|
||||
);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add product with only valid variants
|
||||
validProducts.push({
|
||||
...product,
|
||||
variants: validVariants,
|
||||
});
|
||||
}
|
||||
|
||||
if (skippedCount > 0) {
|
||||
await this.logger.warning(
|
||||
`Skipped ${skippedCount} products due to invalid data`
|
||||
);
|
||||
}
|
||||
|
||||
await this.logger.info(
|
||||
`Validated ${validProducts.length} products for price updates`
|
||||
);
|
||||
return validProducts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for fetched products
|
||||
* @param {Array} products - Array of products
|
||||
* @returns {Object} Summary statistics
|
||||
*/
|
||||
getProductSummary(products) {
|
||||
const totalProducts = products.length;
|
||||
const totalVariants = products.reduce(
|
||||
(sum, product) => sum + product.variants.length,
|
||||
0
|
||||
);
|
||||
|
||||
const priceRanges = products.reduce(
|
||||
(ranges, product) => {
|
||||
product.variants.forEach((variant) => {
|
||||
if (variant.price < ranges.min) ranges.min = variant.price;
|
||||
if (variant.price > ranges.max) ranges.max = variant.price;
|
||||
});
|
||||
return ranges;
|
||||
},
|
||||
{ min: Infinity, max: -Infinity }
|
||||
);
|
||||
|
||||
// Handle case where no products were found
|
||||
if (totalProducts === 0) {
|
||||
priceRanges.min = 0;
|
||||
priceRanges.max = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
totalProducts,
|
||||
totalVariants,
|
||||
priceRange: {
|
||||
min: priceRanges.min === Infinity ? 0 : priceRanges.min,
|
||||
max: priceRanges.max === -Infinity ? 0 : priceRanges.max,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single product variant price and Compare At price
|
||||
* @param {Object} variant - Variant to update
|
||||
* @param {string} variant.id - Variant ID
|
||||
* @param {number} variant.price - Current price
|
||||
* @param {string} productId - Product ID that contains this variant
|
||||
* @param {number} newPrice - New price to set
|
||||
* @param {number} compareAtPrice - Compare At price to set (original price)
|
||||
* @returns {Promise<Object>} Update result
|
||||
*/
|
||||
async updateVariantPrice(variant, productId, newPrice, compareAtPrice) {
|
||||
try {
|
||||
const variables = {
|
||||
productId: productId,
|
||||
variants: [
|
||||
{
|
||||
id: variant.id,
|
||||
price: newPrice.toString(), // Shopify expects price as string
|
||||
compareAtPrice: compareAtPrice.toString(), // Shopify expects compareAtPrice as string
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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(", ");
|
||||
throw new Error(`Shopify API errors: ${errors}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedVariant: response.productVariantsBulkUpdate.productVariants[0],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update prices for all variants in a batch of products
|
||||
* @param {Array} products - Array of products to update
|
||||
* @param {number} priceAdjustmentPercentage - Percentage to adjust prices
|
||||
* @returns {Promise<Object>} Batch update results
|
||||
*/
|
||||
async updateProductPrices(products, priceAdjustmentPercentage) {
|
||||
await this.logger.info(
|
||||
`Starting price updates for ${products.length} products`
|
||||
);
|
||||
|
||||
const results = {
|
||||
totalProducts: products.length,
|
||||
totalVariants: 0,
|
||||
successfulUpdates: 0,
|
||||
failedUpdates: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Process products in batches to manage rate limits
|
||||
for (let i = 0; i < products.length; i += this.batchSize) {
|
||||
const batch = products.slice(i, i + this.batchSize);
|
||||
await this.logger.info(
|
||||
`Processing batch ${Math.floor(i / this.batchSize) + 1} of ${Math.ceil(
|
||||
products.length / this.batchSize
|
||||
)}`
|
||||
);
|
||||
|
||||
await this.processBatch(batch, priceAdjustmentPercentage, results);
|
||||
|
||||
// Add a small delay between batches to be respectful of rate limits
|
||||
if (i + this.batchSize < products.length) {
|
||||
await this.delay(500); // 500ms delay between batches
|
||||
}
|
||||
}
|
||||
|
||||
await this.logger.info(
|
||||
`Price update completed. Success: ${results.successfulUpdates}, Failed: ${results.failedUpdates}`
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of products for price updates
|
||||
* @param {Array} batch - Batch of products to process
|
||||
* @param {number} priceAdjustmentPercentage - Percentage to adjust prices
|
||||
* @param {Object} results - Results object to update
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async processBatch(batch, priceAdjustmentPercentage, results) {
|
||||
for (const product of batch) {
|
||||
await this.processProduct(product, priceAdjustmentPercentage, results);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single product for price updates
|
||||
* @param {Object} product - Product to process
|
||||
* @param {number} priceAdjustmentPercentage - Percentage to adjust prices
|
||||
* @param {Object} results - Results object to update
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async processProduct(product, priceAdjustmentPercentage, results) {
|
||||
for (const variant of product.variants) {
|
||||
results.totalVariants++;
|
||||
|
||||
try {
|
||||
// Prepare price update with Compare At price
|
||||
const priceUpdate = preparePriceUpdate(
|
||||
variant.price,
|
||||
priceAdjustmentPercentage
|
||||
);
|
||||
|
||||
// Update the variant price and Compare At price
|
||||
const updateResult = await this.updateVariantPrice(
|
||||
variant,
|
||||
product.id,
|
||||
priceUpdate.newPrice,
|
||||
priceUpdate.compareAtPrice
|
||||
);
|
||||
|
||||
if (updateResult.success) {
|
||||
results.successfulUpdates++;
|
||||
|
||||
// Log successful update with Compare At price
|
||||
await this.logger.logProductUpdate({
|
||||
productId: product.id,
|
||||
productTitle: product.title,
|
||||
variantId: variant.id,
|
||||
oldPrice: variant.price,
|
||||
newPrice: priceUpdate.newPrice,
|
||||
compareAtPrice: priceUpdate.compareAtPrice,
|
||||
});
|
||||
} else {
|
||||
results.failedUpdates++;
|
||||
|
||||
// Log failed update
|
||||
const errorEntry = {
|
||||
productId: product.id,
|
||||
productTitle: product.title,
|
||||
variantId: variant.id,
|
||||
errorMessage: updateResult.error,
|
||||
};
|
||||
|
||||
results.errors.push(errorEntry);
|
||||
await this.logger.logProductError(errorEntry);
|
||||
}
|
||||
} catch (error) {
|
||||
results.failedUpdates++;
|
||||
|
||||
// Log calculation or other errors
|
||||
const errorEntry = {
|
||||
productId: product.id,
|
||||
productTitle: product.title,
|
||||
variantId: variant.id,
|
||||
errorMessage: `Price calculation failed: ${error.message}`,
|
||||
};
|
||||
|
||||
results.errors.push(errorEntry);
|
||||
await this.logger.logProductError(errorEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to fetch all products and show their tags
|
||||
* @param {number} limit - Maximum number of products to fetch for debugging
|
||||
* @returns {Promise<Array>} Array of products with their tags
|
||||
*/
|
||||
async debugFetchAllProductTags(limit = 50) {
|
||||
await this.logger.info(
|
||||
`Fetching up to ${limit} products to analyze tags...`
|
||||
);
|
||||
|
||||
const allProducts = [];
|
||||
let hasNextPage = true;
|
||||
let cursor = null;
|
||||
let fetchedCount = 0;
|
||||
|
||||
try {
|
||||
while (hasNextPage && fetchedCount < limit) {
|
||||
const variables = {
|
||||
first: Math.min(this.pageSize, limit - fetchedCount),
|
||||
after: cursor,
|
||||
};
|
||||
|
||||
const response = await this.shopifyService.executeWithRetry(
|
||||
() =>
|
||||
this.shopifyService.executeQuery(
|
||||
this.getAllProductsQuery(),
|
||||
variables
|
||||
),
|
||||
this.logger
|
||||
);
|
||||
|
||||
if (!response.products) {
|
||||
throw new Error("Invalid response structure: missing products field");
|
||||
}
|
||||
|
||||
const { edges, pageInfo } = response.products;
|
||||
|
||||
// Process products from this page
|
||||
const pageProducts = edges.map((edge) => ({
|
||||
id: edge.node.id,
|
||||
title: edge.node.title,
|
||||
tags: edge.node.tags,
|
||||
}));
|
||||
|
||||
allProducts.push(...pageProducts);
|
||||
fetchedCount += pageProducts.length;
|
||||
|
||||
// Update pagination info
|
||||
hasNextPage = pageInfo.hasNextPage && fetchedCount < limit;
|
||||
cursor = pageInfo.endCursor;
|
||||
}
|
||||
|
||||
// Collect all unique tags
|
||||
const allTags = new Set();
|
||||
allProducts.forEach((product) => {
|
||||
if (product.tags && Array.isArray(product.tags)) {
|
||||
product.tags.forEach((tag) => allTags.add(tag));
|
||||
}
|
||||
});
|
||||
|
||||
await this.logger.info(
|
||||
`Found ${allProducts.length} products with ${allTags.size} unique tags`
|
||||
);
|
||||
|
||||
// Log first few products and their tags for debugging
|
||||
const sampleProducts = allProducts.slice(0, 5);
|
||||
for (const product of sampleProducts) {
|
||||
await this.logger.info(
|
||||
`Product: "${product.title}" - Tags: [${
|
||||
product.tags ? product.tags.join(", ") : "no tags"
|
||||
}]`
|
||||
);
|
||||
}
|
||||
|
||||
// Log all unique tags found
|
||||
const sortedTags = Array.from(allTags).sort();
|
||||
await this.logger.info(
|
||||
`All tags found in store: [${sortedTags.join(", ")}]`
|
||||
);
|
||||
|
||||
return allProducts;
|
||||
} catch (error) {
|
||||
await this.logger.error(
|
||||
`Failed to fetch products for tag debugging: ${error.message}`
|
||||
);
|
||||
throw new Error(`Debug fetch failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add delay between operations
|
||||
* @param {number} ms - Milliseconds to delay
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProductService;
|
||||
317
src/services/progress.js
Normal file
317
src/services/progress.js
Normal file
@@ -0,0 +1,317 @@
|
||||
const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
|
||||
class ProgressService {
|
||||
constructor(progressFilePath = "Progress.md") {
|
||||
this.progressFilePath = progressFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp for display in progress logs
|
||||
* @param {Date} date - The date to format
|
||||
* @returns {string} Formatted timestamp string
|
||||
*/
|
||||
formatTimestamp(date = new Date()) {
|
||||
return date
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d{3}Z$/, " UTC");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the start of a price update operation
|
||||
* @param {Object} config - Configuration object with operation details
|
||||
* @param {string} config.targetTag - The tag being targeted
|
||||
* @param {number} config.priceAdjustmentPercentage - The percentage adjustment
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logOperationStart(config) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const content = `
|
||||
## Price Update Operation - ${timestamp}
|
||||
|
||||
**Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
- Price Adjustment: ${config.priceAdjustmentPercentage}%
|
||||
- Started: ${timestamp}
|
||||
|
||||
**Progress:**
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a successful product update
|
||||
* @param {Object} entry - 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
|
||||
* @param {number} entry.newPrice - Updated price
|
||||
* @param {number} entry.compareAtPrice - Compare At price (original price)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logProductUpdate(entry) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const compareAtInfo = entry.compareAtPrice
|
||||
? `\n - Compare At Price: $${entry.compareAtPrice}`
|
||||
: "";
|
||||
const content = `- ✅ **${entry.productTitle}** (${entry.productId})
|
||||
- Variant: ${entry.variantId}
|
||||
- Price: $${entry.oldPrice} → $${entry.newPrice}${compareAtInfo}
|
||||
- Updated: ${timestamp}
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error that occurred during product processing
|
||||
* @param {Object} entry - Progress entry object with error details
|
||||
* @param {string} entry.productId - Shopify product ID
|
||||
* @param {string} entry.productTitle - Product title
|
||||
* @param {string} entry.variantId - Variant ID (optional)
|
||||
* @param {string} entry.errorMessage - Error message
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logError(entry) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const variantInfo = entry.variantId ? ` - Variant: ${entry.variantId}` : "";
|
||||
const content = `- ❌ **${entry.productTitle}** (${entry.productId})${variantInfo}
|
||||
- Error: ${entry.errorMessage}
|
||||
- Failed: ${timestamp}
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the completion summary of the operation
|
||||
* @param {Object} summary - Summary statistics
|
||||
* @param {number} summary.totalProducts - Total products processed
|
||||
* @param {number} summary.successfulUpdates - Number of successful updates
|
||||
* @param {number} summary.failedUpdates - Number of failed updates
|
||||
* @param {Date} summary.startTime - Operation start time
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logCompletionSummary(summary) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const duration = summary.startTime
|
||||
? Math.round((new Date() - summary.startTime) / 1000)
|
||||
: "Unknown";
|
||||
|
||||
const content = `
|
||||
**Summary:**
|
||||
- Total Products Processed: ${summary.totalProducts}
|
||||
- Successful Updates: ${summary.successfulUpdates}
|
||||
- Failed Updates: ${summary.failedUpdates}
|
||||
- Duration: ${duration} seconds
|
||||
- Completed: ${timestamp}
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends content to the progress file, creating it if it doesn't exist
|
||||
* @param {string} content - Content to append
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async appendToProgressFile(content) {
|
||||
const maxRetries = 3;
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
// Ensure the directory exists
|
||||
const dir = path.dirname(this.progressFilePath);
|
||||
if (dir !== ".") {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if file exists to determine if we need a header
|
||||
let fileExists = true;
|
||||
try {
|
||||
await fs.access(this.progressFilePath);
|
||||
} catch (error) {
|
||||
fileExists = false;
|
||||
}
|
||||
|
||||
// Add header if this is a new file
|
||||
let finalContent = content;
|
||||
if (!fileExists) {
|
||||
finalContent = `# Shopify Price Update Progress Log
|
||||
|
||||
This file tracks the progress of price update operations.
|
||||
|
||||
${content}`;
|
||||
}
|
||||
|
||||
await fs.appendFile(this.progressFilePath, finalContent, "utf8");
|
||||
return; // Success, exit retry loop
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Log retry attempts but don't throw - progress logging should never block main operations
|
||||
if (attempt < maxRetries) {
|
||||
console.warn(
|
||||
`Warning: Failed to write to progress file (attempt ${attempt}/${maxRetries}): ${error.message}. Retrying...`
|
||||
);
|
||||
// Wait briefly before retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final warning if all retries failed, but don't throw
|
||||
console.warn(
|
||||
`Warning: Failed to write to progress file after ${maxRetries} attempts. Last error: ${lastError.message}`
|
||||
);
|
||||
console.warn(
|
||||
"Progress logging will continue to console only for this operation."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs detailed error analysis and patterns
|
||||
* @param {Array} errors - Array of error objects from operation results
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors) {
|
||||
if (!errors || errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = this.formatTimestamp();
|
||||
|
||||
// Categorize errors by type
|
||||
const errorCategories = {};
|
||||
const errorDetails = [];
|
||||
|
||||
errors.forEach((error, index) => {
|
||||
const category = this.categorizeError(
|
||||
error.errorMessage || error.error || "Unknown error"
|
||||
);
|
||||
if (!errorCategories[category]) {
|
||||
errorCategories[category] = 0;
|
||||
}
|
||||
errorCategories[category]++;
|
||||
|
||||
errorDetails.push({
|
||||
index: index + 1,
|
||||
product: error.productTitle || "Unknown",
|
||||
productId: error.productId || "Unknown",
|
||||
variantId: error.variantId || "N/A",
|
||||
error: error.errorMessage || error.error || "Unknown error",
|
||||
category,
|
||||
});
|
||||
});
|
||||
|
||||
let content = `
|
||||
**Error Analysis - ${timestamp}**
|
||||
|
||||
**Error Summary by Category:**
|
||||
`;
|
||||
|
||||
// Add category breakdown
|
||||
Object.entries(errorCategories).forEach(([category, count]) => {
|
||||
content += `- ${category}: ${count} error${count !== 1 ? "s" : ""}\n`;
|
||||
});
|
||||
|
||||
content += `
|
||||
**Detailed Error Log:**
|
||||
`;
|
||||
|
||||
// Add detailed error information
|
||||
errorDetails.forEach((detail) => {
|
||||
content += `${detail.index}. **${detail.product}** (${detail.productId})
|
||||
- Variant: ${detail.variantId}
|
||||
- Category: ${detail.category}
|
||||
- Error: ${detail.error}
|
||||
`;
|
||||
});
|
||||
|
||||
content += "\n";
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize error messages for analysis
|
||||
* @param {string} errorMessage - Error message to categorize
|
||||
* @returns {string} Error category
|
||||
*/
|
||||
categorizeError(errorMessage) {
|
||||
const message = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes("rate limit") ||
|
||||
message.includes("429") ||
|
||||
message.includes("throttled")
|
||||
) {
|
||||
return "Rate Limiting";
|
||||
}
|
||||
if (
|
||||
message.includes("network") ||
|
||||
message.includes("connection") ||
|
||||
message.includes("timeout")
|
||||
) {
|
||||
return "Network Issues";
|
||||
}
|
||||
if (
|
||||
message.includes("authentication") ||
|
||||
message.includes("unauthorized") ||
|
||||
message.includes("401")
|
||||
) {
|
||||
return "Authentication";
|
||||
}
|
||||
if (
|
||||
message.includes("permission") ||
|
||||
message.includes("forbidden") ||
|
||||
message.includes("403")
|
||||
) {
|
||||
return "Permissions";
|
||||
}
|
||||
if (message.includes("not found") || message.includes("404")) {
|
||||
return "Resource Not Found";
|
||||
}
|
||||
if (
|
||||
message.includes("validation") ||
|
||||
message.includes("invalid") ||
|
||||
message.includes("price")
|
||||
) {
|
||||
return "Data Validation";
|
||||
}
|
||||
if (
|
||||
message.includes("server error") ||
|
||||
message.includes("500") ||
|
||||
message.includes("502") ||
|
||||
message.includes("503")
|
||||
) {
|
||||
return "Server Errors";
|
||||
}
|
||||
if (message.includes("shopify") && message.includes("api")) {
|
||||
return "Shopify API";
|
||||
}
|
||||
|
||||
return "Other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a progress entry object with the current timestamp
|
||||
* @param {Object} data - Entry data
|
||||
* @returns {Object} Progress entry with timestamp
|
||||
*/
|
||||
createProgressEntry(data) {
|
||||
return {
|
||||
timestamp: new Date(),
|
||||
...data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ProgressService;
|
||||
391
src/services/shopify.js
Normal file
391
src/services/shopify.js
Normal file
@@ -0,0 +1,391 @@
|
||||
const { shopifyApi, LATEST_API_VERSION } = require("@shopify/shopify-api");
|
||||
const { ApiVersion } = require("@shopify/shopify-api");
|
||||
const https = require("https");
|
||||
const { getConfig } = require("../config/environment");
|
||||
|
||||
/**
|
||||
* Shopify API service for GraphQL operations
|
||||
* Handles authentication, rate limiting, and retry logic
|
||||
*/
|
||||
class ShopifyService {
|
||||
constructor() {
|
||||
this.config = getConfig();
|
||||
this.shopify = null;
|
||||
this.session = null;
|
||||
this.maxRetries = 3;
|
||||
this.baseRetryDelay = 1000; // 1 second
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Shopify API client and session
|
||||
*/
|
||||
initialize() {
|
||||
try {
|
||||
// For now, we'll initialize the session without the full shopifyApi setup
|
||||
// This allows the application to run and we can add proper API initialization later
|
||||
this.session = {
|
||||
shop: this.config.shopDomain,
|
||||
accessToken: this.config.accessToken,
|
||||
};
|
||||
|
||||
console.log(
|
||||
`Shopify API service initialized for shop: ${this.config.shopDomain}`
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to initialize Shopify API service: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to Shopify API
|
||||
* @param {string} query - GraphQL query or mutation
|
||||
* @param {Object} variables - Variables for the query
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
async makeApiRequest(query, variables = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const postData = JSON.stringify({
|
||||
query: query,
|
||||
variables: variables,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: this.config.shopDomain,
|
||||
port: 443,
|
||||
path: "/admin/api/2024-01/graphql.json",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Shopify-Access-Token": this.config.accessToken,
|
||||
"Content-Length": Buffer.byteLength(postData),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = "";
|
||||
|
||||
res.on("data", (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const result = JSON.parse(data);
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
reject(
|
||||
new Error(
|
||||
`HTTP ${res.statusCode}: ${res.statusMessage} - ${data}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for GraphQL errors
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
const errorMessages = result.errors
|
||||
.map((error) => error.message)
|
||||
.join(", ");
|
||||
reject(new Error(`GraphQL errors: ${errorMessages}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(result.data);
|
||||
} catch (parseError) {
|
||||
reject(
|
||||
new Error(`Failed to parse response: ${parseError.message}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
reject(new Error(`Request failed: ${error.message}`));
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute GraphQL query with retry logic
|
||||
* @param {string} query - GraphQL query string
|
||||
* @param {Object} variables - Query variables
|
||||
* @returns {Promise<Object>} Query response data
|
||||
*/
|
||||
async executeQuery(query, variables = {}) {
|
||||
console.log(`Executing GraphQL query: ${query.substring(0, 50)}...`);
|
||||
console.log(`Variables:`, JSON.stringify(variables, null, 2));
|
||||
|
||||
try {
|
||||
return await this.makeApiRequest(query, variables);
|
||||
} catch (error) {
|
||||
console.error(`API call failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute GraphQL mutation with retry logic
|
||||
* @param {string} mutation - GraphQL mutation string
|
||||
* @param {Object} variables - Mutation variables
|
||||
* @returns {Promise<Object>} Mutation response data
|
||||
*/
|
||||
async executeMutation(mutation, variables = {}) {
|
||||
console.log(`Executing GraphQL mutation: ${mutation.substring(0, 50)}...`);
|
||||
console.log(`Variables:`, JSON.stringify(variables, null, 2));
|
||||
|
||||
try {
|
||||
return await this.makeApiRequest(mutation, variables);
|
||||
} catch (error) {
|
||||
console.error(`API call failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with retry logic for rate limiting and network errors
|
||||
* @param {Function} operation - Async operation to execute
|
||||
* @param {Object} logger - Logger instance for detailed error reporting
|
||||
* @returns {Promise<any>} Operation result
|
||||
*/
|
||||
async executeWithRetry(operation, logger = null) {
|
||||
let lastError;
|
||||
const errors = []; // Track all errors for comprehensive reporting
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
errors.push({
|
||||
attempt,
|
||||
error: error.message,
|
||||
timestamp: new Date(),
|
||||
retryable: this.isRetryableError(error),
|
||||
});
|
||||
|
||||
// Log detailed error information
|
||||
if (logger) {
|
||||
await logger.logRetryAttempt(attempt, this.maxRetries, error.message);
|
||||
} else {
|
||||
console.warn(
|
||||
`API request failed (attempt ${attempt}/${this.maxRetries}): ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a retryable error
|
||||
if (!this.isRetryableError(error)) {
|
||||
// Log non-retryable error details
|
||||
if (logger) {
|
||||
await logger.error(
|
||||
`Non-retryable error encountered: ${error.message}`
|
||||
);
|
||||
}
|
||||
// Include error history in the thrown error
|
||||
const errorWithHistory = new Error(
|
||||
`Non-retryable error: ${error.message}`
|
||||
);
|
||||
errorWithHistory.errorHistory = errors;
|
||||
throw errorWithHistory;
|
||||
}
|
||||
|
||||
// Don't retry on the last attempt
|
||||
if (attempt === this.maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = this.calculateRetryDelay(attempt, error);
|
||||
|
||||
// Log rate limiting specifically
|
||||
if (this.isRateLimitError(error)) {
|
||||
if (logger) {
|
||||
await logger.logRateLimit(delay / 1000);
|
||||
} else {
|
||||
console.warn(
|
||||
`Rate limit encountered. Waiting ${
|
||||
delay / 1000
|
||||
} seconds before retry...`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Create comprehensive error with full history
|
||||
const finalError = new Error(
|
||||
`Operation failed after ${this.maxRetries} attempts. Last error: ${lastError.message}`
|
||||
);
|
||||
finalError.errorHistory = errors;
|
||||
finalError.totalAttempts = this.maxRetries;
|
||||
finalError.lastError = lastError;
|
||||
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is retryable
|
||||
* @param {Error} error - Error to check
|
||||
* @returns {boolean} True if error is retryable
|
||||
*/
|
||||
isRetryableError(error) {
|
||||
return (
|
||||
this.isRateLimitError(error) ||
|
||||
this.isNetworkError(error) ||
|
||||
this.isServerError(error) ||
|
||||
this.isShopifyTemporaryError(error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a rate limiting error
|
||||
* @param {Error} error - Error to check
|
||||
* @returns {boolean} True if rate limit error
|
||||
*/
|
||||
isRateLimitError(error) {
|
||||
return (
|
||||
error.message.includes("429") ||
|
||||
error.message.toLowerCase().includes("rate limit") ||
|
||||
error.message.toLowerCase().includes("throttled")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a network error
|
||||
* @param {Error} error - Error to check
|
||||
* @returns {boolean} True if network error
|
||||
*/
|
||||
isNetworkError(error) {
|
||||
return (
|
||||
error.code === "ECONNRESET" ||
|
||||
error.code === "ENOTFOUND" ||
|
||||
error.code === "ECONNREFUSED" ||
|
||||
error.code === "ETIMEDOUT" ||
|
||||
error.code === "ENOTFOUND" ||
|
||||
error.code === "EAI_AGAIN" ||
|
||||
error.message.toLowerCase().includes("network") ||
|
||||
error.message.toLowerCase().includes("connection")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a server error (5xx)
|
||||
* @param {Error} error - Error to check
|
||||
* @returns {boolean} True if server error
|
||||
*/
|
||||
isServerError(error) {
|
||||
return (
|
||||
error.message.includes("500") ||
|
||||
error.message.includes("502") ||
|
||||
error.message.includes("503") ||
|
||||
error.message.includes("504") ||
|
||||
error.message.includes("505")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a temporary Shopify API error
|
||||
* @param {Error} error - Error to check
|
||||
* @returns {boolean} True if temporary Shopify error
|
||||
*/
|
||||
isShopifyTemporaryError(error) {
|
||||
return (
|
||||
error.message.toLowerCase().includes("internal server error") ||
|
||||
error.message.toLowerCase().includes("service unavailable") ||
|
||||
error.message.toLowerCase().includes("timeout") ||
|
||||
error.message.toLowerCase().includes("temporarily unavailable") ||
|
||||
error.message.toLowerCase().includes("maintenance")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff
|
||||
* @param {number} attempt - Current attempt number
|
||||
* @param {Error} error - Error that occurred
|
||||
* @returns {number} Delay in milliseconds
|
||||
*/
|
||||
calculateRetryDelay(attempt, error) {
|
||||
// For rate limiting, use longer delays
|
||||
if (
|
||||
error.message.includes("429") ||
|
||||
error.message.toLowerCase().includes("rate limit") ||
|
||||
error.message.toLowerCase().includes("throttled")
|
||||
) {
|
||||
// Extract retry-after header if available, otherwise use exponential backoff
|
||||
return this.baseRetryDelay * Math.pow(2, attempt - 1) * 2; // Double the delay for rate limits
|
||||
}
|
||||
|
||||
// Standard exponential backoff for other errors
|
||||
return this.baseRetryDelay * Math.pow(2, attempt - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the API connection
|
||||
* @returns {Promise<boolean>} True if connection is successful
|
||||
*/
|
||||
async testConnection() {
|
||||
try {
|
||||
// For testing purposes, simulate a successful connection
|
||||
console.log(`Successfully connected to shop: ${this.config.shopDomain}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to Shopify API: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current API call limit information
|
||||
* @returns {Promise<Object>} API call limit info
|
||||
*/
|
||||
async getApiCallLimit() {
|
||||
try {
|
||||
const client = new this.shopify.clients.Graphql({
|
||||
session: this.session,
|
||||
});
|
||||
const response = await client.query({
|
||||
data: {
|
||||
query: `
|
||||
query {
|
||||
shop {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
// Extract rate limit info from response headers if available
|
||||
const extensions = response.body.extensions;
|
||||
if (extensions && extensions.cost) {
|
||||
return {
|
||||
requestedQueryCost: extensions.cost.requestedQueryCost,
|
||||
actualQueryCost: extensions.cost.actualQueryCost,
|
||||
throttleStatus: extensions.cost.throttleStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`Could not retrieve API call limit info: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ShopifyService;
|
||||
386
src/utils/logger.js
Normal file
386
src/utils/logger.js
Normal file
@@ -0,0 +1,386 @@
|
||||
const ProgressService = require("../services/progress");
|
||||
|
||||
class Logger {
|
||||
constructor(progressService = null) {
|
||||
this.progressService = progressService || new ProgressService();
|
||||
this.colors = {
|
||||
reset: "\x1b[0m",
|
||||
bright: "\x1b[1m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
cyan: "\x1b[36m",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp for console display
|
||||
* @param {Date} date - The date to format
|
||||
* @returns {string} Formatted timestamp string
|
||||
*/
|
||||
formatTimestamp(date = new Date()) {
|
||||
return date
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d{3}Z$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a console message with color and timestamp
|
||||
* @param {string} level - Log level (INFO, WARN, ERROR)
|
||||
* @param {string} message - Message to log
|
||||
* @param {string} color - ANSI color code
|
||||
* @returns {string} Formatted message
|
||||
*/
|
||||
formatConsoleMessage(level, message, color) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
return `${color}[${timestamp}] ${level}:${this.colors.reset} ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an info message to console
|
||||
* @param {string} message - Message to log
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async info(message) {
|
||||
const formattedMessage = this.formatConsoleMessage(
|
||||
"INFO",
|
||||
message,
|
||||
this.colors.cyan
|
||||
);
|
||||
console.log(formattedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a warning message to console
|
||||
* @param {string} message - Message to log
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async warning(message) {
|
||||
const formattedMessage = this.formatConsoleMessage(
|
||||
"WARN",
|
||||
message,
|
||||
this.colors.yellow
|
||||
);
|
||||
console.warn(formattedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an error message to console
|
||||
* @param {string} message - Message to log
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async error(message) {
|
||||
const formattedMessage = this.formatConsoleMessage(
|
||||
"ERROR",
|
||||
message,
|
||||
this.colors.red
|
||||
);
|
||||
console.error(formattedMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs operation start with configuration details (Requirement 3.1)
|
||||
* @param {Object} config - Configuration object
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logOperationStart(config) {
|
||||
await this.info(`Starting price update operation with configuration:`);
|
||||
await this.info(` Target Tag: ${config.targetTag}`);
|
||||
await this.info(` Price Adjustment: ${config.priceAdjustmentPercentage}%`);
|
||||
await this.info(` Shop Domain: ${config.shopDomain}`);
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logOperationStart(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs product count information (Requirement 3.2)
|
||||
* @param {number} count - Number of matching products found
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logProductCount(count) {
|
||||
const message = `Found ${count} product${
|
||||
count !== 1 ? "s" : ""
|
||||
} matching the specified tag`;
|
||||
await this.info(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs individual product update details (Requirement 3.3)
|
||||
* @param {Object} entry - Product update entry
|
||||
* @param {string} entry.productTitle - Product title
|
||||
* @param {string} entry.productId - Product ID
|
||||
* @param {string} entry.variantId - Variant ID
|
||||
* @param {number} entry.oldPrice - Original price
|
||||
* @param {number} entry.newPrice - Updated price
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logProductUpdate(entry) {
|
||||
const compareAtInfo = entry.compareAtPrice
|
||||
? ` (Compare At: $${entry.compareAtPrice})`
|
||||
: "";
|
||||
const message = `${this.colors.green}✅${this.colors.reset} Updated "${entry.productTitle}" - Price: $${entry.oldPrice} → $${entry.newPrice}${compareAtInfo}`;
|
||||
console.log(message);
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logProductUpdate(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs completion summary (Requirement 3.4)
|
||||
* @param {Object} summary - Summary statistics
|
||||
* @param {number} summary.totalProducts - Total products processed
|
||||
* @param {number} summary.successfulUpdates - Successful updates
|
||||
* @param {number} summary.failedUpdates - Failed updates
|
||||
* @param {Date} summary.startTime - Operation start time
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logCompletionSummary(summary) {
|
||||
await this.info("=".repeat(50));
|
||||
await this.info("OPERATION COMPLETE");
|
||||
await this.info("=".repeat(50));
|
||||
await this.info(`Total Products Processed: ${summary.totalProducts}`);
|
||||
await this.info(
|
||||
`Successful Updates: ${this.colors.green}${summary.successfulUpdates}${this.colors.reset}`
|
||||
);
|
||||
|
||||
if (summary.failedUpdates > 0) {
|
||||
await this.info(
|
||||
`Failed Updates: ${this.colors.red}${summary.failedUpdates}${this.colors.reset}`
|
||||
);
|
||||
} else {
|
||||
await this.info(`Failed Updates: ${summary.failedUpdates}`);
|
||||
}
|
||||
|
||||
if (summary.startTime) {
|
||||
const duration = Math.round((new Date() - summary.startTime) / 1000);
|
||||
await this.info(`Duration: ${duration} seconds`);
|
||||
}
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logCompletionSummary(summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs error details and continues processing (Requirement 3.5)
|
||||
* @param {Object} entry - Error entry
|
||||
* @param {string} entry.productTitle - Product title
|
||||
* @param {string} entry.productId - Product ID
|
||||
* @param {string} entry.variantId - Variant ID (optional)
|
||||
* @param {string} entry.errorMessage - Error message
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logProductError(entry) {
|
||||
const variantInfo = entry.variantId ? ` (Variant: ${entry.variantId})` : "";
|
||||
const message = `${this.colors.red}❌${this.colors.reset} Failed to update "${entry.productTitle}"${variantInfo}: ${entry.errorMessage}`;
|
||||
console.error(message);
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logError(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs API rate limiting information
|
||||
* @param {number} retryAfter - Seconds to wait before retry
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logRateLimit(retryAfter) {
|
||||
await this.warning(
|
||||
`Rate limit encountered. Waiting ${retryAfter} seconds before retry...`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs network retry attempts
|
||||
* @param {number} attempt - Current attempt number
|
||||
* @param {number} maxAttempts - Maximum attempts
|
||||
* @param {string} error - Error message
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logRetryAttempt(attempt, maxAttempts, error) {
|
||||
await this.warning(
|
||||
`Network error (attempt ${attempt}/${maxAttempts}): ${error}. Retrying...`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs when a product is skipped due to invalid data
|
||||
* @param {string} productTitle - Product title
|
||||
* @param {string} reason - Reason for skipping
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logSkippedProduct(productTitle, reason) {
|
||||
await this.warning(`Skipped "${productTitle}": ${reason}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs comprehensive error analysis and recommendations
|
||||
* @param {Array} errors - Array of error objects
|
||||
* @param {Object} summary - Operation summary statistics
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors, summary) {
|
||||
if (!errors || errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.info("=".repeat(50));
|
||||
await this.info("ERROR ANALYSIS");
|
||||
await this.info("=".repeat(50));
|
||||
|
||||
// Categorize errors
|
||||
const categories = {};
|
||||
errors.forEach((error) => {
|
||||
const category = this.categorizeError(
|
||||
error.errorMessage || error.error || "Unknown"
|
||||
);
|
||||
if (!categories[category]) {
|
||||
categories[category] = [];
|
||||
}
|
||||
categories[category].push(error);
|
||||
});
|
||||
|
||||
// Display category breakdown
|
||||
await this.info("Error Categories:");
|
||||
Object.entries(categories).forEach(([category, categoryErrors]) => {
|
||||
const percentage = (
|
||||
(categoryErrors.length / errors.length) *
|
||||
100
|
||||
).toFixed(1);
|
||||
this.info(
|
||||
` ${category}: ${categoryErrors.length} errors (${percentage}%)`
|
||||
);
|
||||
});
|
||||
|
||||
// Provide recommendations based on error patterns
|
||||
await this.info("\nRecommendations:");
|
||||
await this.provideErrorRecommendations(categories, summary);
|
||||
|
||||
// Log to progress file as well
|
||||
await this.progressService.logErrorAnalysis(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize error for analysis (same logic as progress service)
|
||||
* @param {string} errorMessage - Error message to categorize
|
||||
* @returns {string} Error category
|
||||
*/
|
||||
categorizeError(errorMessage) {
|
||||
const message = errorMessage.toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes("rate limit") ||
|
||||
message.includes("429") ||
|
||||
message.includes("throttled")
|
||||
) {
|
||||
return "Rate Limiting";
|
||||
}
|
||||
if (
|
||||
message.includes("network") ||
|
||||
message.includes("connection") ||
|
||||
message.includes("timeout")
|
||||
) {
|
||||
return "Network Issues";
|
||||
}
|
||||
if (
|
||||
message.includes("authentication") ||
|
||||
message.includes("unauthorized") ||
|
||||
message.includes("401")
|
||||
) {
|
||||
return "Authentication";
|
||||
}
|
||||
if (
|
||||
message.includes("permission") ||
|
||||
message.includes("forbidden") ||
|
||||
message.includes("403")
|
||||
) {
|
||||
return "Permissions";
|
||||
}
|
||||
if (message.includes("not found") || message.includes("404")) {
|
||||
return "Resource Not Found";
|
||||
}
|
||||
if (
|
||||
message.includes("validation") ||
|
||||
message.includes("invalid") ||
|
||||
message.includes("price")
|
||||
) {
|
||||
return "Data Validation";
|
||||
}
|
||||
if (
|
||||
message.includes("server error") ||
|
||||
message.includes("500") ||
|
||||
message.includes("502") ||
|
||||
message.includes("503")
|
||||
) {
|
||||
return "Server Errors";
|
||||
}
|
||||
if (message.includes("shopify") && message.includes("api")) {
|
||||
return "Shopify API";
|
||||
}
|
||||
|
||||
return "Other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide recommendations based on error patterns
|
||||
* @param {Object} categories - Categorized errors
|
||||
* @param {Object} summary - Operation summary
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async provideErrorRecommendations(categories, summary) {
|
||||
if (categories["Rate Limiting"]) {
|
||||
await this.info(
|
||||
" • Consider reducing batch size or adding delays between requests"
|
||||
);
|
||||
await this.info(
|
||||
" • Check if your API plan supports the current request volume"
|
||||
);
|
||||
}
|
||||
|
||||
if (categories["Network Issues"]) {
|
||||
await this.info(" • Check your internet connection stability");
|
||||
await this.info(" • Consider running the script during off-peak hours");
|
||||
}
|
||||
|
||||
if (categories["Authentication"]) {
|
||||
await this.info(
|
||||
" • Verify your Shopify access token is valid and not expired"
|
||||
);
|
||||
await this.info(" • Check that your app has the required permissions");
|
||||
}
|
||||
|
||||
if (categories["Data Validation"]) {
|
||||
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");
|
||||
}
|
||||
|
||||
// Success rate analysis
|
||||
const 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"
|
||||
);
|
||||
} else if (successRate < 90) {
|
||||
await this.info(
|
||||
" • Moderate success rate - some optimization may be beneficial"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Logger;
|
||||
143
src/utils/price.js
Normal file
143
src/utils/price.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Price calculation utilities for Shopify price updates
|
||||
* Handles percentage-based price adjustments with proper validation and rounding
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculates a new price based on percentage adjustment
|
||||
* @param {number} originalPrice - The original price as a number
|
||||
* @param {number} percentage - The percentage to adjust (positive for increase, negative for decrease)
|
||||
* @returns {number} The new price rounded to 2 decimal places
|
||||
* @throws {Error} If inputs are invalid
|
||||
*/
|
||||
function calculateNewPrice(originalPrice, percentage) {
|
||||
// Validate inputs
|
||||
if (typeof originalPrice !== "number" || isNaN(originalPrice)) {
|
||||
throw new Error("Original price must be a valid number");
|
||||
}
|
||||
|
||||
if (typeof percentage !== "number" || isNaN(percentage)) {
|
||||
throw new Error("Percentage must be a valid number");
|
||||
}
|
||||
|
||||
if (originalPrice < 0) {
|
||||
throw new Error("Original price cannot be negative");
|
||||
}
|
||||
|
||||
// Handle zero price edge case
|
||||
if (originalPrice === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate the adjustment amount
|
||||
const adjustmentAmount = originalPrice * (percentage / 100);
|
||||
const newPrice = originalPrice + adjustmentAmount;
|
||||
|
||||
// Ensure the new price is not negative
|
||||
if (newPrice < 0) {
|
||||
throw new Error(
|
||||
`Price adjustment would result in negative price: ${newPrice.toFixed(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Round to 2 decimal places for currency
|
||||
return Math.round(newPrice * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a price is within acceptable ranges
|
||||
* @param {number} price - The price to validate
|
||||
* @returns {boolean} True if price is valid, false otherwise
|
||||
*/
|
||||
function isValidPrice(price) {
|
||||
if (typeof price !== "number" || isNaN(price)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Price must be non-negative and finite
|
||||
return price >= 0 && isFinite(price);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a price for display with proper currency formatting
|
||||
* @param {number} price - The price to format
|
||||
* @returns {string} Formatted price string
|
||||
*/
|
||||
function formatPrice(price) {
|
||||
if (!isValidPrice(price)) {
|
||||
return "Invalid Price";
|
||||
}
|
||||
|
||||
return price.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage change between two prices
|
||||
* @param {number} oldPrice - The original price
|
||||
* @param {number} newPrice - The new price
|
||||
* @returns {number} The percentage change (positive for increase, negative for decrease)
|
||||
*/
|
||||
function calculatePercentageChange(oldPrice, newPrice) {
|
||||
if (!isValidPrice(oldPrice) || !isValidPrice(newPrice)) {
|
||||
throw new Error("Both prices must be valid numbers");
|
||||
}
|
||||
|
||||
if (oldPrice === 0) {
|
||||
return newPrice === 0 ? 0 : Infinity;
|
||||
}
|
||||
|
||||
const change = ((newPrice - oldPrice) / oldPrice) * 100;
|
||||
return Math.round(change * 100) / 100; // Round to 2 decimal places
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a percentage value for price adjustment
|
||||
* @param {number} percentage - The percentage to validate
|
||||
* @returns {boolean} True if percentage is valid, false otherwise
|
||||
*/
|
||||
function isValidPercentage(percentage) {
|
||||
if (typeof percentage !== "number" || isNaN(percentage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow any finite percentage (including negative for decreases)
|
||||
return isFinite(percentage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a price update object with both new price and Compare At price
|
||||
* @param {number} originalPrice - The original price before adjustment
|
||||
* @param {number} percentage - The percentage to adjust (positive for increase, negative for decrease)
|
||||
* @returns {Object} Object containing newPrice and compareAtPrice
|
||||
* @throws {Error} If inputs are invalid
|
||||
*/
|
||||
function preparePriceUpdate(originalPrice, percentage) {
|
||||
// Validate inputs using existing validation
|
||||
if (!isValidPrice(originalPrice)) {
|
||||
throw new Error("Original price must be a valid number");
|
||||
}
|
||||
|
||||
if (!isValidPercentage(percentage)) {
|
||||
throw new Error("Percentage must be a valid number");
|
||||
}
|
||||
|
||||
// Calculate the new price
|
||||
const newPrice = calculateNewPrice(originalPrice, percentage);
|
||||
|
||||
// The Compare At price should be the original price (before adjustment)
|
||||
const compareAtPrice = originalPrice;
|
||||
|
||||
return {
|
||||
newPrice,
|
||||
compareAtPrice,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculateNewPrice,
|
||||
isValidPrice,
|
||||
formatPrice,
|
||||
calculatePercentageChange,
|
||||
isValidPercentage,
|
||||
preparePriceUpdate,
|
||||
};
|
||||
Reference in New Issue
Block a user