Steps 1-11, still need to do 12
This commit is contained in:
@@ -15,6 +15,7 @@ function loadEnvironmentConfig() {
|
||||
targetTag: process.env.TARGET_TAG,
|
||||
priceAdjustmentPercentage: process.env.PRICE_ADJUSTMENT_PERCENTAGE,
|
||||
operationMode: process.env.OPERATION_MODE || "update", // Default to "update" for backward compatibility
|
||||
scheduledExecutionTime: process.env.SCHEDULED_EXECUTION_TIME || null, // Optional scheduling parameter
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
@@ -94,6 +95,33 @@ function loadEnvironmentConfig() {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate scheduled execution time if provided using enhanced ScheduleService validation
|
||||
let scheduledTime = null;
|
||||
let isScheduled = false;
|
||||
|
||||
if (config.scheduledExecutionTime) {
|
||||
// Use ScheduleService for comprehensive validation and error handling
|
||||
const ScheduleService = require("../services/schedule");
|
||||
const Logger = require("../utils/logger");
|
||||
|
||||
// Create a temporary schedule service instance for validation
|
||||
const tempLogger = new Logger();
|
||||
const scheduleService = new ScheduleService(tempLogger);
|
||||
|
||||
try {
|
||||
// Use the enhanced validation from ScheduleService
|
||||
scheduledTime = scheduleService.parseScheduledTime(
|
||||
config.scheduledExecutionTime
|
||||
);
|
||||
isScheduled = true;
|
||||
} catch (error) {
|
||||
// Re-throw with environment configuration context
|
||||
throw new Error(
|
||||
`SCHEDULED_EXECUTION_TIME validation failed:\n${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return validated configuration
|
||||
return {
|
||||
shopDomain: config.shopDomain.trim(),
|
||||
@@ -101,6 +129,8 @@ function loadEnvironmentConfig() {
|
||||
targetTag: trimmedTag,
|
||||
priceAdjustmentPercentage: percentage,
|
||||
operationMode: config.operationMode,
|
||||
scheduledExecutionTime: scheduledTime,
|
||||
isScheduled: isScheduled,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
252
src/index.js
252
src/index.js
@@ -9,6 +9,7 @@
|
||||
|
||||
const { getConfig } = require("./config/environment");
|
||||
const ProductService = require("./services/product");
|
||||
const ScheduleService = require("./services/schedule");
|
||||
const Logger = require("./utils/logger");
|
||||
|
||||
/**
|
||||
@@ -18,6 +19,7 @@ class ShopifyPriceUpdater {
|
||||
constructor() {
|
||||
this.logger = new Logger();
|
||||
this.productService = new ProductService();
|
||||
this.scheduleService = new ScheduleService(this.logger);
|
||||
this.config = null;
|
||||
this.startTime = null;
|
||||
}
|
||||
@@ -136,14 +138,30 @@ class ShopifyPriceUpdater {
|
||||
`Starting price updates with ${this.config.priceAdjustmentPercentage}% adjustment`
|
||||
);
|
||||
|
||||
// Update product prices
|
||||
const results = await this.productService.updateProductPrices(
|
||||
products,
|
||||
this.config.priceAdjustmentPercentage
|
||||
);
|
||||
// Mark operation as in progress to prevent cancellation during updates
|
||||
if (this.setOperationInProgress) {
|
||||
this.setOperationInProgress(true);
|
||||
}
|
||||
|
||||
return results;
|
||||
try {
|
||||
// Update product prices
|
||||
const results = await this.productService.updateProductPrices(
|
||||
products,
|
||||
this.config.priceAdjustmentPercentage
|
||||
);
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
// Mark operation as complete
|
||||
if (this.setOperationInProgress) {
|
||||
this.setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ensure operation state is cleared on error
|
||||
if (this.setOperationInProgress) {
|
||||
this.setOperationInProgress(false);
|
||||
}
|
||||
await this.logger.error(`Price update failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
@@ -280,11 +298,29 @@ class ShopifyPriceUpdater {
|
||||
|
||||
await this.logger.info(`Starting price rollback operations`);
|
||||
|
||||
// Execute rollback operations
|
||||
const results = await this.productService.rollbackProductPrices(products);
|
||||
// Mark operation as in progress to prevent cancellation during rollback
|
||||
if (this.setOperationInProgress) {
|
||||
this.setOperationInProgress(true);
|
||||
}
|
||||
|
||||
return results;
|
||||
try {
|
||||
// Execute rollback operations
|
||||
const results = await this.productService.rollbackProductPrices(
|
||||
products
|
||||
);
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
// Mark operation as complete
|
||||
if (this.setOperationInProgress) {
|
||||
this.setOperationInProgress(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ensure operation state is cleared on error
|
||||
if (this.setOperationInProgress) {
|
||||
this.setOperationInProgress(false);
|
||||
}
|
||||
await this.logger.error(`Price rollback failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
@@ -418,6 +454,14 @@ class ShopifyPriceUpdater {
|
||||
return await this.handleCriticalFailure("API connection failed", 1);
|
||||
}
|
||||
|
||||
// Check for scheduled execution and handle scheduling if configured
|
||||
if (this.config.isScheduled) {
|
||||
const shouldProceed = await this.handleScheduledExecution();
|
||||
if (!shouldProceed) {
|
||||
return 0; // Operation was cancelled during scheduling
|
||||
}
|
||||
}
|
||||
|
||||
// Display operation mode indication in console output (Requirements 9.3, 8.4)
|
||||
await this.displayOperationModeHeader();
|
||||
|
||||
@@ -467,6 +511,57 @@ class ShopifyPriceUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scheduled execution workflow
|
||||
* @returns {Promise<boolean>} True if execution should proceed, false if cancelled
|
||||
*/
|
||||
async handleScheduledExecution() {
|
||||
try {
|
||||
// Use the already validated scheduled time from config
|
||||
const scheduledTime = this.config.scheduledExecutionTime;
|
||||
|
||||
// Display scheduling confirmation and countdown
|
||||
await this.logger.info("🕐 Scheduled execution mode activated");
|
||||
await this.scheduleService.displayScheduleInfo(scheduledTime);
|
||||
|
||||
// Wait until scheduled time with cancellation support
|
||||
const shouldProceed = await this.scheduleService.waitUntilScheduledTime(
|
||||
scheduledTime,
|
||||
() => {
|
||||
// Cancellation callback - log the cancellation
|
||||
this.logger.info("Scheduled operation cancelled by user");
|
||||
}
|
||||
);
|
||||
|
||||
if (!shouldProceed) {
|
||||
// Update scheduling state - no longer waiting
|
||||
if (this.setSchedulingActive) {
|
||||
this.setSchedulingActive(false);
|
||||
}
|
||||
await this.logger.info("Operation cancelled. Exiting gracefully.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scheduling wait period is complete, operations will begin
|
||||
if (this.setSchedulingActive) {
|
||||
this.setSchedulingActive(false);
|
||||
}
|
||||
|
||||
// Log that scheduled execution is starting
|
||||
await this.logger.info(
|
||||
"⏰ Scheduled time reached. Beginning operation..."
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Update scheduling state on error
|
||||
if (this.setSchedulingActive) {
|
||||
this.setSchedulingActive(false);
|
||||
}
|
||||
await this.logger.error(`Scheduling error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe wrapper for initialization with enhanced error handling
|
||||
* @returns {Promise<boolean>} True if successful
|
||||
@@ -760,30 +855,111 @@ class ShopifyPriceUpdater {
|
||||
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
|
||||
});
|
||||
// Enhanced signal handling state management
|
||||
let schedulingActive = false;
|
||||
let operationInProgress = false;
|
||||
let signalHandlersSetup = false;
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n🛑 Received SIGTERM. Shutting down gracefully...");
|
||||
/**
|
||||
* Enhanced signal handler that coordinates with scheduling and operation states
|
||||
* @param {string} signal - The signal received (SIGINT, SIGTERM)
|
||||
* @param {number} exitCode - Exit code to use
|
||||
*/
|
||||
const handleShutdown = async (signal, exitCode) => {
|
||||
// During scheduled waiting period - provide clear cancellation message
|
||||
if (schedulingActive && !operationInProgress) {
|
||||
console.log(`\n🛑 Received ${signal} during scheduled wait period.`);
|
||||
console.log("📋 Cancelling scheduled operation...");
|
||||
|
||||
try {
|
||||
// Clean up scheduling resources
|
||||
if (app.scheduleService) {
|
||||
app.scheduleService.cleanup();
|
||||
}
|
||||
|
||||
// Log cancellation to progress file
|
||||
const logger = new Logger();
|
||||
await logger.warning(
|
||||
`Scheduled operation cancelled by ${signal} signal`
|
||||
);
|
||||
console.log(
|
||||
"✅ Scheduled operation cancelled successfully. No price updates were performed."
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to log cancellation:", error.message);
|
||||
}
|
||||
|
||||
process.exit(0); // Clean cancellation, exit with success
|
||||
return;
|
||||
}
|
||||
|
||||
// During active price update operations - prevent interruption
|
||||
if (operationInProgress) {
|
||||
console.log(
|
||||
`\n⚠️ Received ${signal} during active price update operations.`
|
||||
);
|
||||
console.log(
|
||||
"🔒 Cannot cancel while price updates are in progress to prevent data corruption."
|
||||
);
|
||||
console.log("⏳ Please wait for current operations to complete...");
|
||||
console.log(
|
||||
"💡 Tip: You can cancel during the countdown period before operations begin."
|
||||
);
|
||||
return; // Do not exit, let operations complete
|
||||
}
|
||||
|
||||
// Normal shutdown for non-scheduled operations or after operations complete
|
||||
console.log(`\n🛑 Received ${signal}. Shutting down gracefully...`);
|
||||
try {
|
||||
// Clean up scheduling resources
|
||||
if (app.scheduleService) {
|
||||
app.scheduleService.cleanup();
|
||||
}
|
||||
|
||||
// Attempt to log shutdown to progress file
|
||||
const logger = new Logger();
|
||||
await logger.warning("Operation terminated by system (SIGTERM)");
|
||||
await logger.warning(`Operation interrupted by ${signal}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to log shutdown:", error.message);
|
||||
}
|
||||
process.exit(143); // Standard exit code for SIGTERM
|
||||
});
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up enhanced signal handlers with proper coordination
|
||||
*/
|
||||
const setupSignalHandlers = () => {
|
||||
if (signalHandlersSetup) {
|
||||
return; // Avoid duplicate handlers
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => handleShutdown("SIGINT", 130));
|
||||
process.on("SIGTERM", () => handleShutdown("SIGTERM", 143));
|
||||
signalHandlersSetup = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update scheduling state for signal handler coordination
|
||||
* @param {boolean} active - Whether scheduling is currently active
|
||||
*/
|
||||
const setSchedulingActive = (active) => {
|
||||
schedulingActive = active;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update operation state for signal handler coordination
|
||||
* @param {boolean} inProgress - Whether price update operations are in progress
|
||||
*/
|
||||
const setOperationInProgress = (inProgress) => {
|
||||
operationInProgress = inProgress;
|
||||
};
|
||||
|
||||
// Make state management functions available to the app
|
||||
app.setSchedulingActive = setSchedulingActive;
|
||||
app.setOperationInProgress = setOperationInProgress;
|
||||
|
||||
// Set up enhanced signal handlers
|
||||
setupSignalHandlers();
|
||||
|
||||
// Handle unhandled promise rejections with enhanced logging
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
@@ -820,10 +996,34 @@ async function main() {
|
||||
});
|
||||
|
||||
try {
|
||||
// Check if scheduling is active to coordinate signal handling
|
||||
const { getConfig } = require("./config/environment");
|
||||
const config = getConfig();
|
||||
|
||||
// Set initial scheduling state
|
||||
if (config.isScheduled) {
|
||||
setSchedulingActive(true);
|
||||
}
|
||||
|
||||
const exitCode = await app.run();
|
||||
|
||||
// Clear all states after run completes
|
||||
setSchedulingActive(false);
|
||||
setOperationInProgress(false);
|
||||
|
||||
process.exit(exitCode);
|
||||
} catch (error) {
|
||||
console.error("Fatal error:", error.message);
|
||||
|
||||
// Clean up scheduling resources on error
|
||||
if (app.scheduleService) {
|
||||
app.scheduleService.cleanup();
|
||||
}
|
||||
|
||||
// Clear states on error
|
||||
setSchedulingActive(false);
|
||||
setOperationInProgress(false);
|
||||
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,32 @@ class ProgressService {
|
||||
* @param {Object} config - Configuration object with operation details
|
||||
* @param {string} config.targetTag - The tag being targeted
|
||||
* @param {number} config.priceAdjustmentPercentage - The percentage adjustment
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logOperationStart(config) {
|
||||
async logOperationStart(config, schedulingContext = null) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const content = `
|
||||
## Price Update Operation - ${timestamp}
|
||||
const operationTitle =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? "Scheduled Price Update Operation"
|
||||
: "Price Update Operation";
|
||||
|
||||
let content = `
|
||||
## ${operationTitle} - ${timestamp}
|
||||
|
||||
**Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
- Price Adjustment: ${config.priceAdjustmentPercentage}%
|
||||
- Started: ${timestamp}
|
||||
`;
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
content += `- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
|
||||
- Original Schedule Input: ${schedulingContext.originalInput}
|
||||
`;
|
||||
}
|
||||
|
||||
content += `
|
||||
**Progress:**
|
||||
`;
|
||||
|
||||
@@ -45,18 +59,32 @@ class ProgressService {
|
||||
* 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
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logRollbackStart(config) {
|
||||
async logRollbackStart(config, schedulingContext = null) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const content = `
|
||||
## Price Rollback Operation - ${timestamp}
|
||||
const operationTitle =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? "Scheduled Price Rollback Operation"
|
||||
: "Price Rollback Operation";
|
||||
|
||||
let content = `
|
||||
## ${operationTitle} - ${timestamp}
|
||||
|
||||
**Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
- Operation Mode: rollback
|
||||
- Started: ${timestamp}
|
||||
`;
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
content += `- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
|
||||
- Original Schedule Input: ${schedulingContext.originalInput}
|
||||
`;
|
||||
}
|
||||
|
||||
content += `
|
||||
**Progress:**
|
||||
`;
|
||||
|
||||
@@ -117,14 +145,20 @@ class ProgressService {
|
||||
* @param {string} entry.productTitle - Product title
|
||||
* @param {string} entry.variantId - Variant ID (optional)
|
||||
* @param {string} entry.errorMessage - Error message
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logError(entry) {
|
||||
async logError(entry, schedulingContext = null) {
|
||||
const timestamp = this.formatTimestamp();
|
||||
const variantInfo = entry.variantId ? ` - Variant: ${entry.variantId}` : "";
|
||||
const schedulingInfo =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? ` - Scheduled Operation: ${schedulingContext.scheduledTime.toLocaleString()}`
|
||||
: "";
|
||||
|
||||
const content = `- ❌ **${entry.productTitle}** (${entry.productId})${variantInfo}
|
||||
- Error: ${entry.errorMessage}
|
||||
- Failed: ${timestamp}
|
||||
- Failed: ${timestamp}${schedulingInfo}
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
@@ -257,16 +291,145 @@ ${content}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs detailed error analysis and patterns
|
||||
* @param {Array} errors - Array of error objects from operation results
|
||||
* Logs scheduling confirmation to progress file (Requirements 2.1, 2.3)
|
||||
* @param {Object} schedulingInfo - Scheduling information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors) {
|
||||
async logSchedulingConfirmation(schedulingInfo) {
|
||||
const { scheduledTime, originalInput, operationType, config } =
|
||||
schedulingInfo;
|
||||
const timestamp = this.formatTimestamp();
|
||||
|
||||
const content = `
|
||||
## Scheduled Operation Confirmation - ${timestamp}
|
||||
|
||||
**Scheduling Details:**
|
||||
- Operation Type: ${operationType}
|
||||
- Scheduled Time: ${scheduledTime.toLocaleString()}
|
||||
- Original Input: ${originalInput}
|
||||
- Confirmation Time: ${timestamp}
|
||||
|
||||
**Operation Configuration:**
|
||||
- Target Tag: ${config.targetTag}
|
||||
${
|
||||
operationType === "update"
|
||||
? `- Price Adjustment: ${config.priceAdjustmentPercentage}%`
|
||||
: ""
|
||||
}
|
||||
- Shop Domain: ${config.shopDomain}
|
||||
|
||||
**Status:** Waiting for scheduled execution time
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs scheduled execution start to progress file (Requirements 2.3, 5.4)
|
||||
* @param {Object} executionInfo - Execution information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logScheduledExecutionStart(executionInfo) {
|
||||
const { scheduledTime, actualTime, operationType } = executionInfo;
|
||||
const timestamp = this.formatTimestamp();
|
||||
const delay = actualTime.getTime() - scheduledTime.getTime();
|
||||
const delayText =
|
||||
Math.abs(delay) < 1000
|
||||
? "on time"
|
||||
: delay > 0
|
||||
? `${Math.round(delay / 1000)}s late`
|
||||
: `${Math.round(Math.abs(delay) / 1000)}s early`;
|
||||
|
||||
const content = `
|
||||
**Scheduled Execution Started - ${timestamp}**
|
||||
- Operation Type: ${operationType}
|
||||
- Scheduled Time: ${scheduledTime.toLocaleString()}
|
||||
- Actual Start Time: ${actualTime.toLocaleString()}
|
||||
- Timing: ${delayText}
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs scheduled operation cancellation to progress file (Requirements 3.1, 3.2)
|
||||
* @param {Object} cancellationInfo - Cancellation information
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logScheduledOperationCancellation(cancellationInfo) {
|
||||
const { scheduledTime, cancelledTime, operationType, reason } =
|
||||
cancellationInfo;
|
||||
const timestamp = this.formatTimestamp();
|
||||
const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime();
|
||||
const remainingText = this.formatTimeRemaining(timeRemaining);
|
||||
|
||||
const content = `
|
||||
## Scheduled Operation Cancelled - ${timestamp}
|
||||
|
||||
**Cancellation Details:**
|
||||
- Operation Type: ${operationType}
|
||||
- Scheduled Time: ${scheduledTime.toLocaleString()}
|
||||
- Cancelled Time: ${cancelledTime.toLocaleString()}
|
||||
- Time Remaining: ${remainingText}
|
||||
- Reason: ${reason}
|
||||
|
||||
**Status:** Operation cancelled before execution
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
await this.appendToProgressFile(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining into human-readable string
|
||||
* @param {number} milliseconds - Time remaining in milliseconds
|
||||
* @returns {string} Formatted time string (e.g., "2h 30m 15s")
|
||||
*/
|
||||
formatTimeRemaining(milliseconds) {
|
||||
if (milliseconds <= 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
const remainingHours = hours % 24;
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (remainingHours > 0) parts.push(`${remainingHours}h`);
|
||||
if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs detailed error analysis and patterns
|
||||
* @param {Array} errors - Array of error objects from operation results
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors, schedulingContext = null) {
|
||||
if (!errors || errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = this.formatTimestamp();
|
||||
const analysisTitle =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? "Scheduled Operation Error Analysis"
|
||||
: "Error Analysis";
|
||||
|
||||
// Categorize errors by type
|
||||
const errorCategories = {};
|
||||
@@ -310,8 +473,18 @@ ${content}`;
|
||||
});
|
||||
|
||||
let content = `
|
||||
**Error Analysis - ${timestamp}**
|
||||
**${analysisTitle} - ${timestamp}**
|
||||
`;
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
content += `
|
||||
**Scheduling Context:**
|
||||
- Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}
|
||||
- Original Schedule Input: ${schedulingContext.originalInput}
|
||||
`;
|
||||
}
|
||||
|
||||
content += `
|
||||
**Error Summary by Category:**
|
||||
`;
|
||||
|
||||
|
||||
640
src/services/schedule.js
Normal file
640
src/services/schedule.js
Normal file
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* ScheduleService - Handles scheduling functionality for delayed execution
|
||||
* Supports datetime parsing, validation, delay calculation, and countdown display
|
||||
*/
|
||||
class ScheduleService {
|
||||
constructor(logger) {
|
||||
this.logger = logger;
|
||||
this.cancelRequested = false;
|
||||
this.countdownInterval = null;
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate scheduled time from environment variable
|
||||
* @param {string} scheduledTimeString - ISO 8601 datetime string
|
||||
* @returns {Date} Parsed date object
|
||||
* @throws {Error} If datetime format is invalid or in the past
|
||||
*/
|
||||
parseScheduledTime(scheduledTimeString) {
|
||||
// Enhanced input validation with clear error messages
|
||||
if (!scheduledTimeString) {
|
||||
throw new Error(
|
||||
"❌ Scheduled time is required but not provided.\n" +
|
||||
"💡 Set the SCHEDULED_EXECUTION_TIME environment variable with a valid datetime.\n" +
|
||||
"📝 Example: SCHEDULED_EXECUTION_TIME='2024-12-25T10:30:00'"
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof scheduledTimeString !== "string") {
|
||||
throw new Error(
|
||||
"❌ Scheduled time must be provided as a string.\n" +
|
||||
`📊 Received type: ${typeof scheduledTimeString}\n` +
|
||||
"💡 Ensure SCHEDULED_EXECUTION_TIME is set as a string value."
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedInput = scheduledTimeString.trim();
|
||||
if (trimmedInput === "") {
|
||||
throw new Error(
|
||||
"❌ Scheduled time cannot be empty or contain only whitespace.\n" +
|
||||
"💡 Provide a valid ISO 8601 datetime string.\n" +
|
||||
"📝 Example: SCHEDULED_EXECUTION_TIME='2024-12-25T10:30:00'"
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced datetime format validation with detailed error messages
|
||||
const iso8601Regex =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$/;
|
||||
|
||||
if (!iso8601Regex.test(trimmedInput)) {
|
||||
const commonFormats = [
|
||||
"YYYY-MM-DDTHH:MM:SS (e.g., '2024-12-25T10:30:00')",
|
||||
"YYYY-MM-DDTHH:MM:SSZ (e.g., '2024-12-25T10:30:00Z')",
|
||||
"YYYY-MM-DDTHH:MM:SS+HH:MM (e.g., '2024-12-25T10:30:00-05:00')",
|
||||
"YYYY-MM-DDTHH:MM:SS.sssZ (e.g., '2024-12-25T10:30:00.000Z')",
|
||||
];
|
||||
|
||||
throw new Error(
|
||||
`❌ Invalid datetime format: "${trimmedInput}"\n\n` +
|
||||
"📋 The datetime must be in ISO 8601 format. Accepted formats:\n" +
|
||||
commonFormats.map((format) => ` • ${format}`).join("\n") +
|
||||
"\n\n" +
|
||||
"🔍 Common issues to check:\n" +
|
||||
" • Use 'T' to separate date and time (not space)\n" +
|
||||
" • Use 24-hour format (00-23 for hours)\n" +
|
||||
" • Ensure month and day are two digits (01-12, 01-31)\n" +
|
||||
" • Include timezone if needed (+HH:MM, -HH:MM, or Z for UTC)\n\n" +
|
||||
"💡 Tip: Use your local timezone or add 'Z' for UTC"
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation for datetime component values before parsing
|
||||
const dateParts = trimmedInput.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
|
||||
);
|
||||
if (dateParts) {
|
||||
const [, year, month, day, hour, minute, second] = dateParts;
|
||||
const yearNum = parseInt(year);
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
const valueIssues = [];
|
||||
if (yearNum < 1970 || yearNum > 3000)
|
||||
valueIssues.push(`Year ${year} seems unusual (expected 1970-3000)`);
|
||||
if (monthNum < 1 || monthNum > 12)
|
||||
valueIssues.push(`Month ${month} must be 01-12`);
|
||||
if (dayNum < 1 || dayNum > 31)
|
||||
valueIssues.push(`Day ${day} must be 01-31`);
|
||||
if (hourNum > 23)
|
||||
valueIssues.push(`Hour ${hour} must be 00-23 (24-hour format)`);
|
||||
if (minuteNum > 59) valueIssues.push(`Minute ${minute} must be 00-59`);
|
||||
if (secondNum > 59) valueIssues.push(`Second ${second} must be 00-59`);
|
||||
|
||||
if (valueIssues.length > 0) {
|
||||
throw new Error(
|
||||
`❌ Invalid datetime values: "${trimmedInput}"\n\n` +
|
||||
"🔍 Detected issues:\n" +
|
||||
valueIssues.map((issue) => ` • ${issue}`).join("\n") +
|
||||
"\n\n" +
|
||||
"💡 Common fixes:\n" +
|
||||
" • Check if the date exists (e.g., February 30th doesn't exist)\n" +
|
||||
" • Verify month is 01-12, not 0-11\n" +
|
||||
" • Ensure day is valid for the given month and year\n" +
|
||||
" • Use 24-hour format for time (00-23 for hours)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to parse the datetime with enhanced error handling
|
||||
let scheduledTime;
|
||||
try {
|
||||
scheduledTime = new Date(trimmedInput);
|
||||
} catch (parseError) {
|
||||
throw new Error(
|
||||
`❌ Failed to parse datetime: "${trimmedInput}"\n` +
|
||||
`🔧 Parse error: ${parseError.message}\n` +
|
||||
"💡 Please verify the datetime values are valid (e.g., month 1-12, day 1-31)"
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced validation for parsed date
|
||||
if (isNaN(scheduledTime.getTime())) {
|
||||
// Provide specific guidance based on common datetime issues
|
||||
const dateParts = trimmedInput.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/
|
||||
);
|
||||
let specificGuidance = "";
|
||||
|
||||
if (dateParts) {
|
||||
const [, year, month, day, hour, minute, second] = dateParts;
|
||||
const yearNum = parseInt(year);
|
||||
const monthNum = parseInt(month);
|
||||
const dayNum = parseInt(day);
|
||||
const hourNum = parseInt(hour);
|
||||
const minuteNum = parseInt(minute);
|
||||
const secondNum = parseInt(second);
|
||||
|
||||
const issues = [];
|
||||
if (yearNum < 1970 || yearNum > 3000)
|
||||
issues.push(`Year ${year} seems unusual`);
|
||||
if (monthNum < 1 || monthNum > 12)
|
||||
issues.push(`Month ${month} must be 01-12`);
|
||||
if (dayNum < 1 || dayNum > 31) issues.push(`Day ${day} must be 01-31`);
|
||||
if (hourNum > 23) issues.push(`Hour ${hour} must be 00-23`);
|
||||
if (minuteNum > 59) issues.push(`Minute ${minute} must be 00-59`);
|
||||
if (secondNum > 59) issues.push(`Second ${second} must be 00-59`);
|
||||
|
||||
if (issues.length > 0) {
|
||||
specificGuidance =
|
||||
"\n🔍 Detected issues:\n" +
|
||||
issues.map((issue) => ` • ${issue}`).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`❌ Invalid datetime values: "${trimmedInput}"\n` +
|
||||
"📊 The datetime format is correct, but the values are invalid.\n" +
|
||||
specificGuidance +
|
||||
"\n\n" +
|
||||
"💡 Common fixes:\n" +
|
||||
" • Check if the date exists (e.g., February 30th doesn't exist)\n" +
|
||||
" • Verify month is 01-12, not 0-11\n" +
|
||||
" • Ensure day is valid for the given month and year\n" +
|
||||
" • Use 24-hour format for time (00-23 for hours)"
|
||||
);
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
// Enhanced past datetime validation with helpful context
|
||||
if (scheduledTime <= currentTime) {
|
||||
const timeDifference = currentTime.getTime() - scheduledTime.getTime();
|
||||
const minutesDiff = Math.floor(timeDifference / (1000 * 60));
|
||||
const hoursDiff = Math.floor(timeDifference / (1000 * 60 * 60));
|
||||
const daysDiff = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||
|
||||
let timeAgoText = "";
|
||||
if (daysDiff > 0) {
|
||||
timeAgoText = `${daysDiff} day${daysDiff > 1 ? "s" : ""} ago`;
|
||||
} else if (hoursDiff > 0) {
|
||||
timeAgoText = `${hoursDiff} hour${hoursDiff > 1 ? "s" : ""} ago`;
|
||||
} else if (minutesDiff > 0) {
|
||||
timeAgoText = `${minutesDiff} minute${minutesDiff > 1 ? "s" : ""} ago`;
|
||||
} else {
|
||||
timeAgoText = "just now";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`❌ Scheduled time is in the past: "${trimmedInput}"\n\n` +
|
||||
`📅 Scheduled time: ${scheduledTime.toLocaleString()} (${timeAgoText})\n` +
|
||||
`🕐 Current time: ${currentTime.toLocaleString()}\n\n` +
|
||||
"💡 Solutions:\n" +
|
||||
" • Set a future datetime for the scheduled operation\n" +
|
||||
" • Check your system clock if the time seems incorrect\n" +
|
||||
" • Consider timezone differences if using a specific timezone\n\n" +
|
||||
"📝 Example for 1 hour from now:\n" +
|
||||
` SCHEDULED_EXECUTION_TIME='${new Date(
|
||||
currentTime.getTime() + 60 * 60 * 1000
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 19)}'`
|
||||
);
|
||||
}
|
||||
|
||||
// Enhanced distant future validation with detailed warning
|
||||
const sevenDaysFromNow = new Date(
|
||||
currentTime.getTime() + 7 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
if (scheduledTime > sevenDaysFromNow) {
|
||||
const daysDiff = Math.ceil(
|
||||
(scheduledTime.getTime() - currentTime.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
// Display comprehensive warning with context
|
||||
console.warn(
|
||||
`\n⚠️ WARNING: Distant Future Scheduling Detected\n` +
|
||||
`📅 Scheduled time: ${scheduledTime.toLocaleString()}\n` +
|
||||
`📊 Days from now: ${daysDiff} days\n` +
|
||||
`🕐 Current time: ${currentTime.toLocaleString()}\n\n` +
|
||||
`🤔 This operation is scheduled more than 7 days in the future.\n` +
|
||||
`💭 Please verify this is intentional, as:\n` +
|
||||
` • Long-running processes may be interrupted by system restarts\n` +
|
||||
` • Product data or pricing strategies might change\n` +
|
||||
` • API tokens or store configuration could be updated\n\n` +
|
||||
`✅ If this is correct, the operation will proceed as scheduled.\n` +
|
||||
`❌ If this is a mistake, press Ctrl+C to cancel and update the datetime.\n`
|
||||
);
|
||||
|
||||
// Log the warning for audit purposes
|
||||
if (this.logger) {
|
||||
this.logger
|
||||
.warning(
|
||||
`Scheduled operation set for distant future: ${daysDiff} days from now (${scheduledTime.toISOString()})`
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error("Failed to log distant future warning:", err.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return scheduledTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate milliseconds until scheduled execution
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
* @returns {number} Milliseconds until execution
|
||||
*/
|
||||
calculateDelay(scheduledTime) {
|
||||
const currentTime = new Date();
|
||||
const delay = scheduledTime.getTime() - currentTime.getTime();
|
||||
|
||||
// Ensure delay is not negative (shouldn't happen after validation, but safety check)
|
||||
return Math.max(0, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining into human-readable string
|
||||
* @param {number} milliseconds - Time remaining in milliseconds
|
||||
* @returns {string} Formatted time string (e.g., "2h 30m 15s")
|
||||
*/
|
||||
formatTimeRemaining(milliseconds) {
|
||||
if (milliseconds <= 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
const remainingHours = hours % 24;
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (remainingHours > 0) parts.push(`${remainingHours}h`);
|
||||
if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Display scheduling confirmation and countdown
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async displayScheduleInfo(scheduledTime) {
|
||||
const delay = this.calculateDelay(scheduledTime);
|
||||
const timeRemaining = this.formatTimeRemaining(delay);
|
||||
|
||||
// Display initial scheduling confirmation
|
||||
await this.logger.info(
|
||||
`Operation scheduled for: ${scheduledTime.toLocaleString()}`
|
||||
);
|
||||
await this.logger.info(`Time remaining: ${timeRemaining}`);
|
||||
await this.logger.info("Press Ctrl+C to cancel the scheduled operation");
|
||||
|
||||
// Start countdown display (update every 30 seconds for efficiency)
|
||||
this.startCountdownDisplay(scheduledTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start countdown display with periodic updates
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
*/
|
||||
startCountdownDisplay(scheduledTime) {
|
||||
// Clear any existing countdown
|
||||
this.stopCountdownDisplay();
|
||||
|
||||
// Update countdown every 30 seconds
|
||||
this.countdownInterval = setInterval(() => {
|
||||
if (this.cancelRequested) {
|
||||
this.stopCountdownDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.calculateDelay(scheduledTime);
|
||||
|
||||
if (delay <= 0) {
|
||||
this.stopCountdownDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeRemaining = this.formatTimeRemaining(delay);
|
||||
// Use console.log for countdown updates to avoid async issues in interval
|
||||
console.log(
|
||||
`[${new Date()
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d{3}Z$/, "")}] INFO: Time remaining: ${timeRemaining}`
|
||||
);
|
||||
}, 30000); // Update every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop countdown display
|
||||
*/
|
||||
stopCountdownDisplay() {
|
||||
if (this.countdownInterval) {
|
||||
clearInterval(this.countdownInterval);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until scheduled time with cancellation support
|
||||
* @param {Date} scheduledTime - Target execution time
|
||||
* @param {Function} onCancel - Callback function to execute on cancellation
|
||||
* @returns {Promise<boolean>} True if execution should proceed, false if cancelled
|
||||
*/
|
||||
async waitUntilScheduledTime(scheduledTime, onCancel) {
|
||||
const delay = this.calculateDelay(scheduledTime);
|
||||
|
||||
if (delay <= 0) {
|
||||
return true; // Execute immediately
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
// Set timeout for scheduled execution
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
this.stopCountdownDisplay();
|
||||
this.currentTimeoutId = null;
|
||||
|
||||
if (!this.cancelRequested) {
|
||||
// Use console.log for immediate execution message
|
||||
console.log(
|
||||
`[${new Date()
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(
|
||||
/\.\d{3}Z$/,
|
||||
""
|
||||
)}] INFO: Scheduled time reached. Starting operation...`
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
}, delay);
|
||||
|
||||
// Store timeout ID for cleanup
|
||||
this.currentTimeoutId = timeoutId;
|
||||
|
||||
// Set up cancellation check mechanism
|
||||
// The main signal handlers will call cleanup() which sets cancelRequested
|
||||
const checkCancellation = () => {
|
||||
if (resolved) return;
|
||||
|
||||
if (this.cancelRequested) {
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
this.stopCountdownDisplay();
|
||||
this.currentTimeoutId = null;
|
||||
|
||||
if (onCancel && typeof onCancel === "function") {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
resolve(false);
|
||||
} else if (!resolved) {
|
||||
// Check again in 100ms
|
||||
setTimeout(checkCancellation, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Start cancellation checking
|
||||
setTimeout(checkCancellation, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the scheduled operation
|
||||
* @param {Function} operationCallback - The operation to execute
|
||||
* @returns {Promise<number>} Exit code from the operation
|
||||
*/
|
||||
async executeScheduledOperation(operationCallback) {
|
||||
try {
|
||||
await this.logger.info("Executing scheduled operation...");
|
||||
const result = await operationCallback();
|
||||
await this.logger.info("Scheduled operation completed successfully");
|
||||
return result || 0;
|
||||
} catch (error) {
|
||||
await this.logger.error(`Scheduled operation failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources and request cancellation
|
||||
*/
|
||||
cleanup() {
|
||||
this.stopCountdownDisplay();
|
||||
this.cancelRequested = true;
|
||||
|
||||
// Clear any active timeout
|
||||
if (this.currentTimeoutId) {
|
||||
clearTimeout(this.currentTimeoutId);
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the service state (for testing or reuse)
|
||||
*/
|
||||
reset() {
|
||||
this.stopCountdownDisplay();
|
||||
this.cancelRequested = false;
|
||||
|
||||
if (this.currentTimeoutId) {
|
||||
clearTimeout(this.currentTimeoutId);
|
||||
this.currentTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate scheduling configuration and provide comprehensive error handling
|
||||
* @param {string} scheduledTimeString - Raw scheduled time string from environment
|
||||
* @returns {Object} Validation result with parsed time or error details
|
||||
*/
|
||||
validateSchedulingConfiguration(scheduledTimeString) {
|
||||
try {
|
||||
const scheduledTime = this.parseScheduledTime(scheduledTimeString);
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
scheduledTime: scheduledTime,
|
||||
originalInput: scheduledTimeString,
|
||||
validationError: null,
|
||||
warningMessage: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// Categorize the error for better handling with more specific detection
|
||||
let errorCategory = "unknown";
|
||||
let helpfulSuggestions = [];
|
||||
|
||||
// Check for missing input first (highest priority)
|
||||
if (
|
||||
error.message.includes("required") ||
|
||||
error.message.includes("empty") ||
|
||||
error.message.includes("provided")
|
||||
) {
|
||||
errorCategory = "missing_input";
|
||||
helpfulSuggestions = [
|
||||
"Set the SCHEDULED_EXECUTION_TIME environment variable",
|
||||
"Ensure the value is not empty or whitespace only",
|
||||
"Use a valid ISO 8601 datetime string",
|
||||
];
|
||||
}
|
||||
// Check for past time (high priority)
|
||||
else if (error.message.includes("past")) {
|
||||
errorCategory = "past_time";
|
||||
helpfulSuggestions = [
|
||||
"Set a future datetime for the scheduled operation",
|
||||
"Check your system clock if the time seems incorrect",
|
||||
"Consider timezone differences when scheduling",
|
||||
];
|
||||
}
|
||||
// Check for format issues first (more specific patterns)
|
||||
else if (
|
||||
error.message.includes("❌ Invalid datetime format") ||
|
||||
error.message.includes("Invalid datetime format") ||
|
||||
error.message.includes("ISO 8601")
|
||||
) {
|
||||
errorCategory = "format";
|
||||
helpfulSuggestions = [
|
||||
"Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS",
|
||||
"Add timezone if needed: +HH:MM, -HH:MM, or Z for UTC",
|
||||
"Separate date and time with 'T', not a space",
|
||||
];
|
||||
}
|
||||
// Check for invalid values (month, day, hour issues) - specific patterns
|
||||
else if (
|
||||
error.message.includes("❌ Invalid datetime values") ||
|
||||
error.message.includes("Invalid datetime values") ||
|
||||
error.message.includes("Month") ||
|
||||
error.message.includes("Day") ||
|
||||
error.message.includes("Hour") ||
|
||||
error.message.includes("must be")
|
||||
) {
|
||||
errorCategory = "invalid_values";
|
||||
helpfulSuggestions = [
|
||||
"Check if the date exists (e.g., February 30th doesn't exist)",
|
||||
"Verify month is 01-12, day is valid for the month",
|
||||
"Use 24-hour format for time (00-23 for hours)",
|
||||
];
|
||||
}
|
||||
// Check for parse errors (catch remaining format-related errors)
|
||||
else if (
|
||||
error.message.includes("parse") ||
|
||||
error.message.includes("format")
|
||||
) {
|
||||
errorCategory = "format";
|
||||
helpfulSuggestions = [
|
||||
"Use ISO 8601 format: YYYY-MM-DDTHH:MM:SS",
|
||||
"Add timezone if needed: +HH:MM, -HH:MM, or Z for UTC",
|
||||
"Separate date and time with 'T', not a space",
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
scheduledTime: null,
|
||||
originalInput: scheduledTimeString,
|
||||
validationError: error.message,
|
||||
errorCategory: errorCategory,
|
||||
suggestions: helpfulSuggestions,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display comprehensive error information for scheduling failures
|
||||
* @param {Object} validationResult - Result from validateSchedulingConfiguration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async displaySchedulingError(validationResult) {
|
||||
if (validationResult.isValid) {
|
||||
return; // No error to display
|
||||
}
|
||||
|
||||
// Display error header
|
||||
console.error("\n" + "=".repeat(60));
|
||||
console.error("🚨 SCHEDULING CONFIGURATION ERROR");
|
||||
console.error("=".repeat(60));
|
||||
|
||||
// Display the main error message
|
||||
console.error("\n" + validationResult.validationError);
|
||||
|
||||
// Display additional context if available
|
||||
if (validationResult.originalInput) {
|
||||
console.error(`\n📝 Input received: "${validationResult.originalInput}"`);
|
||||
}
|
||||
|
||||
// Display category-specific help
|
||||
if (
|
||||
validationResult.suggestions &&
|
||||
validationResult.suggestions.length > 0
|
||||
) {
|
||||
console.error("\n💡 Suggestions to fix this issue:");
|
||||
validationResult.suggestions.forEach((suggestion) => {
|
||||
console.error(` • ${suggestion}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Display general help information
|
||||
console.error("\n📚 Additional Resources:");
|
||||
console.error(" • Check .env.example for configuration examples");
|
||||
console.error(" • Verify your system timezone settings");
|
||||
console.error(" • Use online ISO 8601 datetime generators if needed");
|
||||
|
||||
console.error("\n" + "=".repeat(60) + "\n");
|
||||
|
||||
// Log the error to the progress file if logger is available
|
||||
if (this.logger) {
|
||||
try {
|
||||
await this.logger.error(
|
||||
`Scheduling configuration error: ${validationResult.errorCategory} - ${validationResult.validationError}`
|
||||
);
|
||||
} catch (loggingError) {
|
||||
console.error("Failed to log scheduling error:", loggingError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scheduling errors with proper exit codes and user guidance
|
||||
* @param {string} scheduledTimeString - Raw scheduled time string from environment
|
||||
* @returns {Promise<number>} Exit code (0 for success, 1 for error)
|
||||
*/
|
||||
async handleSchedulingValidation(scheduledTimeString) {
|
||||
const validationResult =
|
||||
this.validateSchedulingConfiguration(scheduledTimeString);
|
||||
|
||||
if (!validationResult.isValid) {
|
||||
await this.displaySchedulingError(validationResult);
|
||||
return 1; // Error exit code
|
||||
}
|
||||
|
||||
// If validation passed, store the parsed time for later use
|
||||
this.validatedScheduledTime = validationResult.scheduledTime;
|
||||
return 0; // Success exit code
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ScheduleService;
|
||||
@@ -83,32 +83,60 @@ class Logger {
|
||||
/**
|
||||
* Logs operation start with configuration details (Requirement 3.1)
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logOperationStart(config) {
|
||||
await this.info(`Starting price update operation with configuration:`);
|
||||
async logOperationStart(config, schedulingContext = null) {
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
await this.info(
|
||||
`Starting scheduled price update operation with configuration:`
|
||||
);
|
||||
await this.info(
|
||||
` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
|
||||
);
|
||||
await this.info(
|
||||
` Original Schedule Input: ${schedulingContext.originalInput}`
|
||||
);
|
||||
} else {
|
||||
await this.info(`Starting price update operation with configuration:`);
|
||||
}
|
||||
|
||||
await this.info(` Target Tag: ${config.targetTag}`);
|
||||
await this.info(` Price Adjustment: ${config.priceAdjustmentPercentage}%`);
|
||||
await this.info(` Shop Domain: ${config.shopDomain}`);
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logOperationStart(config);
|
||||
// Also log to progress file with scheduling context
|
||||
await this.progressService.logOperationStart(config, schedulingContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs rollback operation start with configuration details (Requirements 3.1, 7.1, 8.3)
|
||||
* @param {Object} config - Configuration object
|
||||
* @param {Object} schedulingContext - Optional scheduling context
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logRollbackStart(config) {
|
||||
await this.info(`Starting price rollback operation with configuration:`);
|
||||
async logRollbackStart(config, schedulingContext = null) {
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
await this.info(
|
||||
`Starting scheduled price rollback operation with configuration:`
|
||||
);
|
||||
await this.info(
|
||||
` Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
|
||||
);
|
||||
await this.info(
|
||||
` Original Schedule Input: ${schedulingContext.originalInput}`
|
||||
);
|
||||
} else {
|
||||
await this.info(`Starting price rollback operation with configuration:`);
|
||||
}
|
||||
|
||||
await this.info(` Target Tag: ${config.targetTag}`);
|
||||
await this.info(` Operation Mode: rollback`);
|
||||
await this.info(` Shop Domain: ${config.shopDomain}`);
|
||||
|
||||
// Also log to progress file with rollback-specific format
|
||||
// Also log to progress file with rollback-specific format and scheduling context
|
||||
try {
|
||||
await this.progressService.logRollbackStart(config);
|
||||
await this.progressService.logRollbackStart(config, schedulingContext);
|
||||
} catch (error) {
|
||||
// Progress logging should not block main operations
|
||||
console.warn(`Warning: Failed to log to progress file: ${error.message}`);
|
||||
@@ -267,15 +295,20 @@ class Logger {
|
||||
* @param {string} entry.productId - Product ID
|
||||
* @param {string} entry.variantId - Variant ID (optional)
|
||||
* @param {string} entry.errorMessage - Error message
|
||||
* @param {Object} schedulingContext - Optional scheduling context for error logging (Requirements 5.3, 5.4)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logProductError(entry) {
|
||||
async logProductError(entry, schedulingContext = null) {
|
||||
const variantInfo = entry.variantId ? ` (Variant: ${entry.variantId})` : "";
|
||||
const message = `${this.colors.red}❌${this.colors.reset} Failed to update "${entry.productTitle}"${variantInfo}: ${entry.errorMessage}`;
|
||||
const schedulingInfo =
|
||||
schedulingContext && schedulingContext.isScheduled
|
||||
? ` [Scheduled Operation]`
|
||||
: "";
|
||||
const message = `${this.colors.red}❌${this.colors.reset} Failed to update "${entry.productTitle}"${variantInfo}: ${entry.errorMessage}${schedulingInfo}`;
|
||||
console.error(message);
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logError(entry);
|
||||
// Also log to progress file with scheduling context
|
||||
await this.progressService.logError(entry, schedulingContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,24 +345,182 @@ class Logger {
|
||||
await this.warning(`Skipped "${productTitle}": ${reason}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs scheduling confirmation with operation details (Requirements 2.1, 2.3)
|
||||
* @param {Object} schedulingInfo - Scheduling information
|
||||
* @param {Date} schedulingInfo.scheduledTime - Target execution time
|
||||
* @param {string} schedulingInfo.originalInput - Original datetime input
|
||||
* @param {string} schedulingInfo.operationType - Type of operation (update/rollback)
|
||||
* @param {Object} schedulingInfo.config - Operation configuration
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logSchedulingConfirmation(schedulingInfo) {
|
||||
const { scheduledTime, originalInput, operationType, config } =
|
||||
schedulingInfo;
|
||||
|
||||
await this.info("=".repeat(50));
|
||||
await this.info("SCHEDULED OPERATION CONFIRMED");
|
||||
await this.info("=".repeat(50));
|
||||
await this.info(`Operation Type: ${operationType}`);
|
||||
await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
|
||||
await this.info(`Original Input: ${originalInput}`);
|
||||
await this.info(`Target Tag: ${config.targetTag}`);
|
||||
|
||||
if (operationType === "update") {
|
||||
await this.info(`Price Adjustment: ${config.priceAdjustmentPercentage}%`);
|
||||
}
|
||||
|
||||
await this.info(`Shop Domain: ${config.shopDomain}`);
|
||||
|
||||
const delay = scheduledTime.getTime() - new Date().getTime();
|
||||
const timeRemaining = this.formatTimeRemaining(delay);
|
||||
await this.info(`Time Remaining: ${timeRemaining}`);
|
||||
await this.info("Press Ctrl+C to cancel the scheduled operation");
|
||||
await this.info("=".repeat(50));
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logSchedulingConfirmation(schedulingInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs countdown updates during scheduled wait period (Requirements 2.2, 2.3)
|
||||
* @param {Object} countdownInfo - Countdown information
|
||||
* @param {Date} countdownInfo.scheduledTime - Target execution time
|
||||
* @param {number} countdownInfo.remainingMs - Milliseconds remaining
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logCountdownUpdate(countdownInfo) {
|
||||
const { scheduledTime, remainingMs } = countdownInfo;
|
||||
const timeRemaining = this.formatTimeRemaining(remainingMs);
|
||||
|
||||
await this.info(
|
||||
`Scheduled execution in: ${timeRemaining} (at ${scheduledTime.toLocaleString()})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the start of scheduled operation execution (Requirements 2.3, 5.4)
|
||||
* @param {Object} executionInfo - Execution information
|
||||
* @param {Date} executionInfo.scheduledTime - Original scheduled time
|
||||
* @param {Date} executionInfo.actualTime - Actual execution time
|
||||
* @param {string} executionInfo.operationType - Type of operation
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logScheduledExecutionStart(executionInfo) {
|
||||
const { scheduledTime, actualTime, operationType } = executionInfo;
|
||||
const delay = actualTime.getTime() - scheduledTime.getTime();
|
||||
const delayText =
|
||||
Math.abs(delay) < 1000
|
||||
? "on time"
|
||||
: delay > 0
|
||||
? `${Math.round(delay / 1000)}s late`
|
||||
: `${Math.round(Math.abs(delay) / 1000)}s early`;
|
||||
|
||||
await this.info("=".repeat(50));
|
||||
await this.info("SCHEDULED OPERATION STARTING");
|
||||
await this.info("=".repeat(50));
|
||||
await this.info(`Operation Type: ${operationType}`);
|
||||
await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
|
||||
await this.info(`Actual Start Time: ${actualTime.toLocaleString()}`);
|
||||
await this.info(`Timing: ${delayText}`);
|
||||
await this.info("=".repeat(50));
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logScheduledExecutionStart(executionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs scheduled operation cancellation (Requirements 3.1, 3.2)
|
||||
* @param {Object} cancellationInfo - Cancellation information
|
||||
* @param {Date} cancellationInfo.scheduledTime - Original scheduled time
|
||||
* @param {Date} cancellationInfo.cancelledTime - Time when cancelled
|
||||
* @param {string} cancellationInfo.operationType - Type of operation
|
||||
* @param {string} cancellationInfo.reason - Cancellation reason
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logScheduledOperationCancellation(cancellationInfo) {
|
||||
const { scheduledTime, cancelledTime, operationType, reason } =
|
||||
cancellationInfo;
|
||||
const timeRemaining = scheduledTime.getTime() - cancelledTime.getTime();
|
||||
const remainingText = this.formatTimeRemaining(timeRemaining);
|
||||
|
||||
await this.info("=".repeat(50));
|
||||
await this.info("SCHEDULED OPERATION CANCELLED");
|
||||
await this.info("=".repeat(50));
|
||||
await this.info(`Operation Type: ${operationType}`);
|
||||
await this.info(`Scheduled Time: ${scheduledTime.toLocaleString()}`);
|
||||
await this.info(`Cancelled Time: ${cancelledTime.toLocaleString()}`);
|
||||
await this.info(`Time Remaining: ${remainingText}`);
|
||||
await this.info(`Reason: ${reason}`);
|
||||
await this.info("=".repeat(50));
|
||||
|
||||
// Also log to progress file
|
||||
await this.progressService.logScheduledOperationCancellation(
|
||||
cancellationInfo
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time remaining into human-readable string
|
||||
* @param {number} milliseconds - Time remaining in milliseconds
|
||||
* @returns {string} Formatted time string (e.g., "2h 30m 15s")
|
||||
*/
|
||||
formatTimeRemaining(milliseconds) {
|
||||
if (milliseconds <= 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
const remainingHours = hours % 24;
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (remainingHours > 0) parts.push(`${remainingHours}h`);
|
||||
if (remainingMinutes > 0) parts.push(`${remainingMinutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0)
|
||||
parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs comprehensive error analysis and recommendations
|
||||
* @param {Array} errors - Array of error objects
|
||||
* @param {Object} summary - Operation summary statistics
|
||||
* @param {Object} schedulingContext - Optional scheduling context for error analysis (Requirements 5.3, 5.4)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async logErrorAnalysis(errors, summary) {
|
||||
async logErrorAnalysis(errors, summary, schedulingContext = null) {
|
||||
if (!errors || errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operationType =
|
||||
summary.successfulRollbacks !== undefined ? "ROLLBACK" : "UPDATE";
|
||||
const schedulingPrefix =
|
||||
schedulingContext && schedulingContext.isScheduled ? "SCHEDULED " : "";
|
||||
|
||||
await this.info("=".repeat(50));
|
||||
await this.info(`${operationType} ERROR ANALYSIS`);
|
||||
await this.info(`${schedulingPrefix}${operationType} ERROR ANALYSIS`);
|
||||
await this.info("=".repeat(50));
|
||||
|
||||
if (schedulingContext && schedulingContext.isScheduled) {
|
||||
await this.info(
|
||||
`Scheduled Time: ${schedulingContext.scheduledTime.toLocaleString()}`
|
||||
);
|
||||
await this.info(
|
||||
`Original Schedule Input: ${schedulingContext.originalInput}`
|
||||
);
|
||||
await this.info("=".repeat(50));
|
||||
}
|
||||
|
||||
// Enhanced categorization for rollback operations
|
||||
const categories = {};
|
||||
const retryableErrors = [];
|
||||
@@ -411,8 +602,8 @@ class Logger {
|
||||
await this.info("\nRecommendations:");
|
||||
await this.provideErrorRecommendations(categories, summary, operationType);
|
||||
|
||||
// Log to progress file as well
|
||||
await this.progressService.logErrorAnalysis(errors);
|
||||
// Log to progress file as well with scheduling context
|
||||
await this.progressService.logErrorAnalysis(errors, schedulingContext);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user