import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Between, In, Repository } from 'typeorm'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import * as cronParser from 'cron-parser'; import { ScheduledWorkflow } from '../entities/scheduled-workflow.entity'; import { WorkflowExecutionLog } from '../entities/workflow-execution-log.entity'; import { CreateScheduleDto } from '../dto/create-schedule.dto'; import { UpdateScheduleDto } from '../dto/update-schedule.dto'; import { GetExecutionLogsDto } from '../dto/get-execution-logs.dto'; import { UserData } from '../../user/entity/user.entity'; import { DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY, DEFAULT_TIMEZONE, EXECUTE_SCHEDULED_WORKFLOW_JOB, SCHEDULE_STATUS_ACTIVE, SCHEDULE_STATUS_INACTIVE, SCHEDULE_STATUS_PAUSED, WORKFLOW_SCHEDULE_QUEUE, } from '../constants/schedule.constants'; import { ScheduleJobData } from '../interfaces/schedule-job-data.interface'; /** * Workflow Schedule Service * Manages scheduled workflows and their execution */ @Injectable() export class WorkflowScheduleService { private readonly logger = new Logger(WorkflowScheduleService.name); constructor( @InjectRepository(ScheduledWorkflow) private readonly scheduledWorkflowRepository: Repository, @InjectRepository(WorkflowExecutionLog) private readonly executionLogRepository: Repository, @InjectQueue(WORKFLOW_SCHEDULE_QUEUE) private readonly scheduleQueue: Queue, ) { } /** * 1. Create a new scheduled workflow */ async createSchedule( createScheduleDto: CreateScheduleDto, loggedInUser: UserData, ): Promise { this.logger.log( `Creating schedule for workflow ${createScheduleDto.workflow_id} by user ${loggedInUser.id}`, ); try { // Validate cron expression this.validateCronExpression(createScheduleDto.cron_expression, createScheduleDto.timezone); // Calculate next execution time const nextExecutionAt = this.calculateNextExecution( createScheduleDto.cron_expression, createScheduleDto.timezone || DEFAULT_TIMEZONE, ); // Create scheduled workflow entity const schedule = this.scheduledWorkflowRepository.create({ workflow_id: createScheduleDto.workflow_id, workflow_name: createScheduleDto.workflow_name, name: createScheduleDto.name, description: createScheduleDto.description, cron_expression: createScheduleDto.cron_expression, timezone: createScheduleDto.timezone || DEFAULT_TIMEZONE, start_date: createScheduleDto.start_date ? new Date(createScheduleDto.start_date) : null, end_date: createScheduleDto.end_date ? new Date(createScheduleDto.end_date) : null, max_executions: createScheduleDto.max_executions, execution_count: 0, next_execution_at: nextExecutionAt, schedule_status: SCHEDULE_STATUS_ACTIVE, retry_config: createScheduleDto.retry_config || { maxRetries: DEFAULT_MAX_RETRIES, retryDelay: DEFAULT_RETRY_DELAY, backoffMultiplier: DEFAULT_BACKOFF_MULTIPLIER, }, metadata: createScheduleDto.metadata || {}, is_enabled: createScheduleDto.is_enabled !== false, organization_id: createScheduleDto.organization_id || loggedInUser.organization_id, enterprise_id: createScheduleDto.enterprise_id || loggedInUser.enterprise_id, level_id: createScheduleDto.level_id || loggedInUser.level_id, level_type: createScheduleDto.level_type || loggedInUser.level_type, appcode: createScheduleDto.appcode || loggedInUser.appcode, created_by: loggedInUser.id, created_date: new Date(), status: createScheduleDto.status || 'ACTIVE', entity_type: 'WFSC', }); const savedSchedule = await this.scheduledWorkflowRepository.save(schedule); // Add job to Bull queue with cron schedule if (savedSchedule.is_enabled && savedSchedule.schedule_status === SCHEDULE_STATUS_ACTIVE) { await this.scheduleJob(savedSchedule, loggedInUser); } this.logger.log(`Schedule created successfully with ID: ${savedSchedule.id}`); return savedSchedule; } catch (error) { this.logger.error(`Failed to create schedule: ${error.message}`, error.stack); throw error; } } /** * 2. Update an existing scheduled workflow */ async updateSchedule( updateScheduleDto: UpdateScheduleDto, loggedInUser: UserData, ): Promise { this.logger.log(`Updating schedule ${updateScheduleDto.id} by user ${loggedInUser.id}`); try { const schedule = await this.scheduledWorkflowRepository.findOne({ where: { id: updateScheduleDto.id }, }); if (!schedule) { throw new NotFoundException(`Scheduled workflow not found with ID: ${updateScheduleDto.id}`); } // Validate cron expression if updated if (updateScheduleDto.cron_expression) { this.validateCronExpression( updateScheduleDto.cron_expression, updateScheduleDto.timezone || schedule.timezone, ); } // Update fields if (updateScheduleDto.workflow_id !== undefined) { schedule.workflow_id = updateScheduleDto.workflow_id; } if (updateScheduleDto.workflow_name !== undefined) { schedule.workflow_name = updateScheduleDto.workflow_name; } if (updateScheduleDto.name !== undefined) { schedule.name = updateScheduleDto.name; } if (updateScheduleDto.description !== undefined) { schedule.description = updateScheduleDto.description; } if (updateScheduleDto.cron_expression !== undefined) { schedule.cron_expression = updateScheduleDto.cron_expression; schedule.next_execution_at = this.calculateNextExecution( updateScheduleDto.cron_expression, updateScheduleDto.timezone || schedule.timezone, ); } if (updateScheduleDto.timezone !== undefined) { schedule.timezone = updateScheduleDto.timezone; } if (updateScheduleDto.start_date !== undefined) { schedule.start_date = updateScheduleDto.start_date ? new Date(updateScheduleDto.start_date) : null; } if (updateScheduleDto.end_date !== undefined) { schedule.end_date = updateScheduleDto.end_date ? new Date(updateScheduleDto.end_date) : null; } if (updateScheduleDto.max_executions !== undefined) { schedule.max_executions = updateScheduleDto.max_executions; } if (updateScheduleDto.schedule_status !== undefined) { schedule.schedule_status = updateScheduleDto.schedule_status; } if (updateScheduleDto.retry_config !== undefined) { schedule.retry_config = updateScheduleDto.retry_config; } if (updateScheduleDto.actions !== undefined) { schedule.actions = updateScheduleDto.actions; } if (updateScheduleDto.metadata !== undefined) { schedule.metadata = updateScheduleDto.metadata; } if (updateScheduleDto.is_enabled !== undefined) { schedule.is_enabled = updateScheduleDto.is_enabled; } if (updateScheduleDto.status !== undefined) { schedule.status = updateScheduleDto.status; } schedule.modified_by = loggedInUser.id; schedule.modified_date = new Date(); const updatedSchedule = await this.scheduledWorkflowRepository.save(schedule); // Remove old job and create new one if schedule changed if (schedule.job_id) { await this.removeJob(schedule.job_id); } if (updatedSchedule.is_enabled && updatedSchedule.schedule_status === SCHEDULE_STATUS_ACTIVE) { await this.scheduleJob(updatedSchedule, loggedInUser); } this.logger.log(`Schedule updated successfully: ${updatedSchedule.id}`); return updatedSchedule; } catch (error) { this.logger.error(`Failed to update schedule: ${error.message}`, error.stack); throw error; } } /** * 3. Get a scheduled workflow by ID */ async getScheduleById(scheduleId: number): Promise { const schedule = await this.scheduledWorkflowRepository.findOne({ where: { id: scheduleId, }, }); if (!schedule) { throw new NotFoundException(`Scheduled workflow not found with ID: ${scheduleId}`); } return schedule; } /** * 4. Get all scheduled workflows with pagination and filters */ async getAllSchedules( page: number = 1, size: number = 10, filters: any = {}, loggedInUser: UserData, ): Promise<{ data: ScheduledWorkflow[]; total: number; page: number; size: number }> { const skip = (page - 1) * size; const whereConditions: any = { enterprise_id: loggedInUser.enterprise_id, }; if (filters.workflow_id) { whereConditions.workflow_id = filters.workflow_id; } if (filters.schedule_status) { whereConditions.schedule_status = filters.schedule_status; } if (filters.is_enabled !== undefined) { whereConditions.is_enabled = filters.is_enabled; } const [data, total] = await this.scheduledWorkflowRepository.findAndCount({ where: whereConditions, skip, take: size, order: { created_date: 'DESC' }, }); return { data, total, page, size, }; } /** * 5. Pause a scheduled workflow */ async pauseSchedule(scheduleId: number, loggedInUser: UserData): Promise { this.logger.log(`Pausing schedule ${scheduleId} by user ${loggedInUser.id}`); const schedule = await this.getScheduleById(scheduleId); if (schedule.schedule_status === SCHEDULE_STATUS_PAUSED) { throw new BadRequestException('Schedule is already paused'); } schedule.schedule_status = SCHEDULE_STATUS_PAUSED; schedule.modified_by = loggedInUser.id; schedule.modified_date = new Date(); const updatedSchedule = await this.scheduledWorkflowRepository.save(schedule); // Remove job from queue if (schedule.job_id) { await this.removeJob(schedule.job_id); updatedSchedule.job_id = null; await this.scheduledWorkflowRepository.save(updatedSchedule); } this.logger.log(`Schedule paused successfully: ${scheduleId}`); return updatedSchedule; } /** * 6. Resume a paused scheduled workflow */ async resumeSchedule(scheduleId: number, loggedInUser: UserData): Promise { this.logger.log(`Resuming schedule ${scheduleId} by user ${loggedInUser.id}`); const schedule = await this.getScheduleById(scheduleId); if (schedule.schedule_status !== SCHEDULE_STATUS_PAUSED) { throw new BadRequestException('Schedule is not paused'); } schedule.schedule_status = SCHEDULE_STATUS_ACTIVE; schedule.modified_by = loggedInUser.id; schedule.modified_date = new Date(); // Recalculate next execution time schedule.next_execution_at = this.calculateNextExecution( schedule.cron_expression, schedule.timezone, ); const updatedSchedule = await this.scheduledWorkflowRepository.save(schedule); // Re-add job to queue if (updatedSchedule.is_enabled) { await this.scheduleJob(updatedSchedule, loggedInUser); } this.logger.log(`Schedule resumed successfully: ${scheduleId}`); return updatedSchedule; } /** * 7. Delete a scheduled workflow */ async deleteSchedule(scheduleId: number, loggedInUser: UserData): Promise { this.logger.log(`Deleting schedule ${scheduleId} by user ${loggedInUser.id}`); const schedule = await this.getScheduleById(scheduleId); // Remove job from queue if (schedule.job_id) { await this.removeJob(schedule.job_id); } // Soft delete by setting status to inactive schedule.schedule_status = SCHEDULE_STATUS_INACTIVE; schedule.status = 'INACTIVE'; schedule.is_enabled = false; schedule.modified_by = loggedInUser.id; schedule.modified_date = new Date(); await this.scheduledWorkflowRepository.save(schedule); this.logger.log(`Schedule deleted successfully: ${scheduleId}`); } /** * 8. Manually trigger a scheduled workflow execution */ async triggerManualExecution( scheduleId: number, loggedInUser: UserData, metadata?: Record, ): Promise<{ executionLogId: number; jobId: string }> { this.logger.log(`Manual trigger for schedule ${scheduleId} by user ${loggedInUser.id}`); const schedule = await this.getScheduleById(scheduleId); const jobData: ScheduleJobData = { id:loggedInUser.id, schedule_id: schedule.id, workflow_id: schedule.workflow_id, workflow_name: schedule.workflow_name, organization_id: schedule.organization_id, enterprise_id: schedule.enterprise_id, level_id: schedule.level_id, level_type: schedule.level_type, appcode: schedule.appcode, createdBy: loggedInUser.id, triggeredBy: 'MANUAL', triggeredAt: new Date(), metadata: metadata || {}, }; // Add job to queue immediately (not scheduled) const job = await this.scheduleQueue.add(EXECUTE_SCHEDULED_WORKFLOW_JOB, jobData, { attempts: schedule.retry_config?.maxRetries || DEFAULT_MAX_RETRIES, backoff: { type: 'exponential', delay: schedule.retry_config?.retryDelay || DEFAULT_RETRY_DELAY, }, }); // Create execution log entry const executionLog = this.executionLogRepository.create({ schedule_id: schedule.id, workflow_id: schedule.workflow_id, job_id: job.id.toString(), execution_status: 'PENDING', triggered_by: 'MANUAL', triggered_by_user_id: loggedInUser.id, organization_id: schedule.organization_id, enterprise_id: schedule.enterprise_id, created_by: loggedInUser.id, created_date: new Date(), entity_type: 'WFEL', status: 'ACTIVE', }); const savedLog = await this.executionLogRepository.save(executionLog); this.logger.log(`Manual execution triggered for schedule ${scheduleId}, job ID: ${job.id}`); return { executionLogId: savedLog.id, jobId: job.id.toString(), }; } /** * 9. Get execution logs with filters and pagination */ async getExecutionLogs( getExecutionLogsDto: GetExecutionLogsDto, loggedInUser: UserData, ): Promise<{ data: WorkflowExecutionLog[]; total: number; page: number; size: number }> { const page = getExecutionLogsDto.page || 1; const size = getExecutionLogsDto.size || 10; const skip = (page - 1) * size; const whereConditions: any = { enterprise_id: getExecutionLogsDto.enterprise_id || loggedInUser.enterprise_id, }; if (getExecutionLogsDto.schedule_id) { whereConditions.schedule_id = getExecutionLogsDto.schedule_id; } if (getExecutionLogsDto.workflow_id) { whereConditions.workflow_id = getExecutionLogsDto.workflow_id; } if (getExecutionLogsDto.execution_status) { whereConditions.execution_status = getExecutionLogsDto.execution_status; } if (getExecutionLogsDto.execution_statuses && getExecutionLogsDto.execution_statuses.length > 0) { whereConditions.execution_status = In(getExecutionLogsDto.execution_statuses); } if (getExecutionLogsDto.triggered_by) { whereConditions.triggered_by = getExecutionLogsDto.triggered_by; } if (getExecutionLogsDto.triggered_by_user_id) { whereConditions.triggered_by_user_id = getExecutionLogsDto.triggered_by_user_id; } // Date range filter if (getExecutionLogsDto.start_date && getExecutionLogsDto.end_date) { whereConditions.created_date = Between( new Date(getExecutionLogsDto.start_date), new Date(getExecutionLogsDto.end_date), ); } const [data, total] = await this.executionLogRepository.findAndCount({ where: whereConditions, skip, take: size, order: { [getExecutionLogsDto.sortBy || 'created_date']: getExecutionLogsDto.sortOrder || 'DESC', }, }); return { data, total, page, size, }; } /** * Get execution statistics for a schedule */ async getExecutionStats(scheduleId: number): Promise { const schedule = await this.getScheduleById(scheduleId); const result = await this.executionLogRepository .createQueryBuilder('log') .select([ 'COUNT(*) AS total_executions', 'SUM(CASE WHEN log.execution_status = \'COMPLETED\' THEN 1 ELSE 0 END) AS successful_executions', 'SUM(CASE WHEN log.execution_status = \'FAILED\' THEN 1 ELSE 0 END) AS failed_executions', 'SUM(CASE WHEN log.execution_status = \'PENDING\' THEN 1 ELSE 0 END) AS pending_executions', 'SUM(CASE WHEN log.execution_status = \'RUNNING\' THEN 1 ELSE 0 END) AS running_executions', 'AVG(log.duration_ms) AS average_duration_ms', 'SUM(log.total_records) AS total_records_processed', ]) .where('log.schedule_id = :scheduleId', { scheduleId }) .getRawOne(); const successRate = result.total_executions > 0 ? (result.successful_executions / result.total_executions) * 100 : 0; return { schedule_id: scheduleId, schedule_name: schedule.name, total_executions: parseInt(result.total_executions), successful_executions: parseInt(result.successful_executions), failed_executions: parseInt(result.failed_executions), pending_executions: parseInt(result.pending_executions), running_executions: parseInt(result.running_executions), average_duration_ms: parseFloat(result.average_duration_ms) || 0, total_records_processed: parseInt(result.total_records_processed) || 0, success_rate_percentage: parseFloat(successRate.toFixed(2)), }; } /** * Private helper: Schedule a job in Bull queue */ private async scheduleJob(schedule: ScheduledWorkflow, loggedInUser: UserData): Promise { const jobData: ScheduleJobData = { id:loggedInUser.id, schedule_id: schedule.id, workflow_id: schedule.workflow_id, workflow_name: schedule.workflow_name, organization_id: schedule.organization_id, enterprise_id: schedule.enterprise_id, level_id: schedule.level_id, level_type: schedule.level_type, appcode: schedule.appcode, createdBy: loggedInUser.id, triggeredBy: 'SCHEDULE', triggeredAt: new Date(), metadata: schedule.metadata || {}, }; const job = await this.scheduleQueue.add(EXECUTE_SCHEDULED_WORKFLOW_JOB, jobData, { repeat: { cron: schedule.cron_expression, tz: schedule.timezone, startDate: schedule.start_date || undefined, endDate: schedule.end_date || undefined, }, attempts: schedule.retry_config?.maxRetries || DEFAULT_MAX_RETRIES, backoff: { type: 'exponential', delay: schedule.retry_config?.retryDelay || DEFAULT_RETRY_DELAY, }, }); schedule.job_id = job.id.toString(); await this.scheduledWorkflowRepository.save(schedule); this.logger.log(`Job scheduled with ID: ${job.id} for schedule ${schedule.id}`); } /** * Private helper: Remove a job from Bull queue */ private async removeJob(jobId: string): Promise { try { const job = await this.scheduleQueue.getJob(jobId); if (job) { await job.remove(); this.logger.log(`Job removed: ${jobId}`); } } catch (error) { this.logger.warn(`Failed to remove job ${jobId}: ${error.message}`); } } /** * Private helper: Validate cron expression */ private validateCronExpression(cronExpression: string, timezone: string): void { try { cronParser.CronExpressionParser.parse(cronExpression, { tz: timezone }); } catch (error: any) { throw new BadRequestException( `Invalid cron expression: ${cronExpression}. Error: ${error.message}`, ); } } /** * Private helper: Calculate next execution time */ private calculateNextExecution(cronExpression: string, timezone: string): Date | null { try { const interval = cronParser.CronExpressionParser.parse(cronExpression, { tz: timezone }); return interval.next().toDate(); } catch (error: any) { this.logger.error(`Failed to calculate next execution: ${error.message}`); return null; } } }