Steps 1-11, still need to do 12

This commit is contained in:
2025-08-07 15:29:23 -05:00
parent b66a516d20
commit 4019e921d3
15 changed files with 3731 additions and 55 deletions

View File

@@ -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,
};
}

View File

@@ -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);
}
}

View File

@@ -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
View 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;

View File

@@ -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);
}
/**