const fs = require("fs").promises; const path = require("path"); /** * ScheduleService - Manages scheduled operations with JSON persistence * Handles CRUD operations for schedule management in the TUI */ class ScheduleService { constructor() { this.schedulesFile = path.join(process.cwd(), "schedules.json"); } /** * Load schedules from JSON file * @returns {Promise} Array of schedule objects */ async loadSchedules() { try { const data = await fs.readFile(this.schedulesFile, "utf8"); const schedules = JSON.parse(data); // Ensure all schedules have required properties and convert date strings back to Date objects return schedules.map((schedule) => ({ ...schedule, scheduledTime: new Date(schedule.scheduledTime), createdAt: new Date(schedule.createdAt), lastExecuted: schedule.lastExecuted ? new Date(schedule.lastExecuted) : null, nextExecution: schedule.nextExecution ? new Date(schedule.nextExecution) : null, })); } catch (error) { if (error.code === "ENOENT") { // File doesn't exist, return empty array return []; } throw new Error(`Failed to load schedules: ${error.message}`); } } /** * Save schedules to JSON file * @param {Array} schedules - Array of schedule objects * @returns {Promise} */ async saveSchedules(schedules) { try { // Convert Date objects to ISO strings for JSON serialization const serializedSchedules = schedules.map((schedule) => ({ ...schedule, scheduledTime: schedule.scheduledTime.toISOString(), createdAt: schedule.createdAt.toISOString(), lastExecuted: schedule.lastExecuted ? schedule.lastExecuted.toISOString() : null, nextExecution: schedule.nextExecution ? schedule.nextExecution.toISOString() : null, })); await fs.writeFile( this.schedulesFile, JSON.stringify(serializedSchedules, null, 2), "utf8" ); } catch (error) { throw new Error(`Failed to save schedules: ${error.message}`); } } /** * Add a new schedule * @param {Object} schedule - Schedule object to add * @returns {Promise} The added schedule with generated ID */ async addSchedule(schedule) { const validationError = this.validateSchedule(schedule); if (validationError) { throw new Error(`Invalid schedule: ${validationError}`); } const schedules = await this.loadSchedules(); // Generate unique ID const id = this._generateId(schedules); // Create new schedule with defaults const newSchedule = { id, operationType: schedule.operationType, scheduledTime: new Date(schedule.scheduledTime), recurrence: schedule.recurrence || "once", enabled: schedule.enabled !== undefined ? schedule.enabled : true, config: schedule.config || {}, status: "pending", createdAt: new Date(), lastExecuted: null, nextExecution: this._calculateNextExecution( new Date(schedule.scheduledTime), schedule.recurrence || "once" ), }; schedules.push(newSchedule); await this.saveSchedules(schedules); return newSchedule; } /** * Update an existing schedule * @param {string} id - Schedule ID to update * @param {Object} updates - Updates to apply * @returns {Promise} The updated schedule */ async updateSchedule(id, updates) { const schedules = await this.loadSchedules(); const scheduleIndex = schedules.findIndex((s) => s.id === id); if (scheduleIndex === -1) { throw new Error(`Schedule with ID ${id} not found`); } // Merge updates with existing schedule const updatedSchedule = { ...schedules[scheduleIndex], ...updates, }; // Validate the updated schedule const validationError = this.validateSchedule(updatedSchedule); if (validationError) { throw new Error(`Invalid schedule update: ${validationError}`); } // Ensure dates are Date objects if (updates.scheduledTime) { updatedSchedule.scheduledTime = new Date(updates.scheduledTime); updatedSchedule.nextExecution = this._calculateNextExecution( updatedSchedule.scheduledTime, updatedSchedule.recurrence ); } schedules[scheduleIndex] = updatedSchedule; await this.saveSchedules(schedules); return updatedSchedule; } /** * Delete a schedule * @param {string} id - Schedule ID to delete * @returns {Promise} True if deleted, false if not found */ async deleteSchedule(id) { const schedules = await this.loadSchedules(); const initialLength = schedules.length; const filteredSchedules = schedules.filter((s) => s.id !== id); if (filteredSchedules.length === initialLength) { return false; // Schedule not found } await this.saveSchedules(filteredSchedules); return true; } /** * Validate schedule data * @param {Object} schedule - Schedule object to validate * @returns {string|null} Error message if invalid, null if valid */ validateSchedule(schedule) { if (!schedule) { return "Schedule object is required"; } // Validate operation type if (!schedule.operationType) { return "Operation type is required"; } if (!["update", "rollback"].includes(schedule.operationType)) { return 'Operation type must be "update" or "rollback"'; } // Validate scheduled time if (!schedule.scheduledTime) { return "Scheduled time is required"; } const scheduledTime = new Date(schedule.scheduledTime); if (isNaN(scheduledTime.getTime())) { return "Scheduled time must be a valid date"; } // Check if scheduled time is in the future (for new schedules) if (!schedule.id && scheduledTime <= new Date()) { return "Scheduled time must be in the future"; } // Validate recurrence if ( schedule.recurrence && !["once", "daily", "weekly", "monthly"].includes(schedule.recurrence) ) { return "Recurrence must be one of: once, daily, weekly, monthly"; } // Validate status if ( schedule.status && !["pending", "completed", "failed", "cancelled"].includes(schedule.status) ) { return "Status must be one of: pending, completed, failed, cancelled"; } // Validate enabled flag if ( schedule.enabled !== undefined && typeof schedule.enabled !== "boolean" ) { return "Enabled must be a boolean value"; } // Validate config object if (schedule.config && typeof schedule.config !== "object") { return "Config must be an object"; } return null; // Valid } /** * Generate unique ID for new schedule * @param {Array} existingSchedules - Array of existing schedules * @returns {string} Unique ID * @private */ _generateId(existingSchedules) { const timestamp = Date.now(); const random = Math.random().toString(36).substr(2, 9); let id = `schedule_${timestamp}_${random}`; // Ensure uniqueness (very unlikely collision, but safety check) while (existingSchedules.some((s) => s.id === id)) { const newRandom = Math.random().toString(36).substr(2, 9); id = `schedule_${timestamp}_${newRandom}`; } return id; } /** * Calculate next execution time based on recurrence * @param {Date} scheduledTime - Original scheduled time * @param {string} recurrence - Recurrence pattern * @returns {Date|null} Next execution time or null for 'once' * @private */ _calculateNextExecution(scheduledTime, recurrence) { if (recurrence === "once") { return null; } const nextExecution = new Date(scheduledTime); switch (recurrence) { case "daily": nextExecution.setDate(nextExecution.getDate() + 1); break; case "weekly": nextExecution.setDate(nextExecution.getDate() + 7); break; case "monthly": nextExecution.setMonth(nextExecution.getMonth() + 1); break; default: return null; } return nextExecution; } /** * Get schedules by status * @param {string} status - Status to filter by * @returns {Promise} Filtered schedules */ async getSchedulesByStatus(status) { const schedules = await this.loadSchedules(); return schedules.filter((schedule) => schedule.status === status); } /** * Get schedules by operation type * @param {string} operationType - Operation type to filter by * @returns {Promise} Filtered schedules */ async getSchedulesByOperationType(operationType) { const schedules = await this.loadSchedules(); return schedules.filter( (schedule) => schedule.operationType === operationType ); } /** * Get enabled schedules * @returns {Promise} Enabled schedules */ async getEnabledSchedules() { const schedules = await this.loadSchedules(); return schedules.filter((schedule) => schedule.enabled); } /** * Mark schedule as completed * @param {string} id - Schedule ID * @returns {Promise} Updated schedule */ async markScheduleCompleted(id) { return await this.updateSchedule(id, { status: "completed", lastExecuted: new Date(), }); } /** * Mark schedule as failed * @param {string} id - Schedule ID * @param {string} errorMessage - Error message * @returns {Promise} Updated schedule */ async markScheduleFailed(id, errorMessage) { return await this.updateSchedule(id, { status: "failed", lastExecuted: new Date(), errorMessage: errorMessage, }); } } module.exports = ScheduleService;