From 93990a599433a33aee344f639c4fa8108ebb0842 Mon Sep 17 00:00:00 2001 From: Fabio Formosa Date: Tue, 12 May 2026 22:53:11 +0200 Subject: [PATCH] #90 #91 #92 #93 supported all trigger types --- quartz-manager-frontend/src/app/app.module.ts | 10 +- .../src/app/model/calendar.model.ts | 24 + .../src/app/model/trigger-command.model.ts | 26 + .../src/app/model/trigger.model.ts | 10 + .../src/app/services/calendar.service.spec.ts | 36 ++ .../src/app/services/calendar.service.ts | 34 ++ .../src/app/services/index.ts | 5 +- .../src/app/services/job.service.spec.ts | 15 +- .../src/app/services/trigger.service.spec.ts | 11 + .../src/app/services/trigger.service.ts | 9 + .../app/views/manager/manager.component.html | 44 +- .../app/views/manager/manager.component.ts | 458 ++++++++++++++++-- .../api/controllers/CalendarController.java | 77 +++ .../api/controllers/TriggerController.java | 24 + .../advices/ExceptionHandlingController.java | 8 + .../quartzmanager/api/dto/CalendarDTO.java | 50 ++ .../api/dto/CalendarIncludedTimeDTO.java | 25 + .../quartzmanager/api/dto/CalendarType.java | 10 + .../api/dto/SimpleTriggerDTO.java | 6 +- .../api/dto/SimpleTriggerInputDTO.java | 5 +- .../quartzmanager/api/dto/TriggerDTO.java | 11 + .../api/dto/TriggerInputDTO.java | 60 +++ .../quartzmanager/api/dto/TriggerType.java | 8 + .../exceptions/CalendarNotFoundException.java | 7 + .../api/services/CalendarService.java | 233 +++++++++ .../api/services/TriggerService.java | 247 +++++++++- .../api/validators/JobTargetDTO.java | 8 + .../api/validators/ValidJobTarget.java | 18 + .../validators/ValidJobTargetValidator.java | 15 + .../controllers/CalendarControllerTest.java | 111 +++++ .../controllers/TriggerControllerTest.java | 46 ++ .../services/SimpleTriggerServiceTest.java | 4 +- 32 files changed, 1576 insertions(+), 79 deletions(-) create mode 100644 quartz-manager-frontend/src/app/model/calendar.model.ts create mode 100644 quartz-manager-frontend/src/app/model/trigger-command.model.ts create mode 100644 quartz-manager-frontend/src/app/services/calendar.service.spec.ts create mode 100644 quartz-manager-frontend/src/app/services/calendar.service.ts create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java create mode 100644 quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java diff --git a/quartz-manager-frontend/src/app/app.module.ts b/quartz-manager-frontend/src/app/app.module.ts index af88112..2277108 100644 --- a/quartz-manager-frontend/src/app/app.module.ts +++ b/quartz-manager-frontend/src/app/app.module.ts @@ -51,10 +51,11 @@ import { SchedulerService, ConfigService, getHtmlBaseUrl, - LogsRxWebsocketService, - ProgressRxWebsocketService, - TriggerService -} from './services'; + LogsRxWebsocketService, + ProgressRxWebsocketService, + TriggerService, + CalendarService +} from './services'; import { ForbiddenComponent } from './views/forbidden/forbidden.component'; import { APP_BASE_HREF } from '@angular/common'; import JobService from './services/job.service'; @@ -135,6 +136,7 @@ export function jwtOptionsFactory(apiService: ApiService) { SchedulerService, JobService, TriggerService, + CalendarService, ProgressRxWebsocketService, LogsRxWebsocketService, AuthService, diff --git a/quartz-manager-frontend/src/app/model/calendar.model.ts b/quartz-manager-frontend/src/app/model/calendar.model.ts new file mode 100644 index 0000000..b315cfb --- /dev/null +++ b/quartz-manager-frontend/src/app/model/calendar.model.ts @@ -0,0 +1,24 @@ +import {TriggerKey} from './triggerKey.model'; + +export type CalendarType = 'ANNUAL' | 'CRON' | 'DAILY' | 'HOLIDAY' | 'MONTHLY' | 'WEEKLY'; + +export class QuartzCalendar { + name: string; + type: CalendarType = 'WEEKLY'; + description: string; + cronExpression: string; + timeZone: string; + rangeStartingTime: string; + rangeEndingTime: string; + invertTimeRange: boolean; + excludedDaysOfWeek: number[]; + excludedDaysOfMonth: number[]; + excludedDates: Date[]; + triggerKeys: TriggerKey[]; +} + +export class CalendarIncludedTimeTest { + time: Date; + included: boolean; + nextIncludedTime: Date; +} diff --git a/quartz-manager-frontend/src/app/model/trigger-command.model.ts b/quartz-manager-frontend/src/app/model/trigger-command.model.ts new file mode 100644 index 0000000..65d5c7c --- /dev/null +++ b/quartz-manager-frontend/src/app/model/trigger-command.model.ts @@ -0,0 +1,26 @@ +import {JobKeyModel} from './jobKey.model'; + +export type TriggerType = 'SIMPLE' | 'CRON' | 'DAILY_TIME_INTERVAL' | 'CALENDAR_INTERVAL'; + +export class TriggerCommand { + triggerType: TriggerType = 'SIMPLE'; + jobClass: string; + jobKey: JobKeyModel; + startDate: Date; + endDate: Date; + description: string; + priority: number; + calendarName: string; + misfireInstruction: string; + jobDataMap: {[key: string]: unknown}; + repeatCount: number; + repeatInterval: number; + repeatIntervalUnit: string; + cronExpression: string; + timeZone: string; + startTimeOfDay: string; + endTimeOfDay: string; + daysOfWeek: number[]; + preserveHourOfDayAcrossDaylightSavings: boolean; + skipDayIfHourDoesNotExist: boolean; +} diff --git a/quartz-manager-frontend/src/app/model/trigger.model.ts b/quartz-manager-frontend/src/app/model/trigger.model.ts index 88a2212..a1a3c12 100644 --- a/quartz-manager-frontend/src/app/model/trigger.model.ts +++ b/quartz-manager-frontend/src/app/model/trigger.model.ts @@ -19,4 +19,14 @@ export class Trigger { jobDetailDTO: JobDetail = new JobDetail(); mayFireAgain: boolean; jobDataMap: {[key: string]: unknown}; + cronExpression: string; + timeZone: string; + repeatInterval: number; + repeatCount: number; + repeatIntervalUnit: string; + startTimeOfDay: string; + endTimeOfDay: string; + daysOfWeek: number[]; + preserveHourOfDayAcrossDaylightSavings: boolean; + skipDayIfHourDoesNotExist: boolean; } diff --git a/quartz-manager-frontend/src/app/services/calendar.service.spec.ts b/quartz-manager-frontend/src/app/services/calendar.service.spec.ts new file mode 100644 index 0000000..de4c6d2 --- /dev/null +++ b/quartz-manager-frontend/src/app/services/calendar.service.spec.ts @@ -0,0 +1,36 @@ +import {jest} from '@jest/globals'; +import {CalendarService} from './calendar.service'; + +describe('CalendarService', () => { + let apiService: any; + let calendarService: CalendarService; + + beforeEach(() => { + apiService = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn() + }; + calendarService = new CalendarService(apiService); + }); + + it('uses calendar registry endpoints', () => { + const calendar: any = {name: 'weekends', type: 'WEEKLY'}; + const time = new Date('2026-05-12T12:00:00.000Z'); + + calendarService.fetchCalendars(); + calendarService.getCalendar('weekends'); + calendarService.createCalendar('weekends', calendar); + calendarService.updateCalendar('weekends', calendar); + calendarService.deleteCalendar('weekends'); + calendarService.testIncludedTime('weekends', time); + + expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/calendars'); + expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/calendars/weekends'); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/calendars/weekends', calendar); + expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/calendars/weekends', calendar); + expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/calendars/weekends'); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/calendars/weekends/included-time-test', {time}); + }); +}); diff --git a/quartz-manager-frontend/src/app/services/calendar.service.ts b/quartz-manager-frontend/src/app/services/calendar.service.ts new file mode 100644 index 0000000..6f0767e --- /dev/null +++ b/quartz-manager-frontend/src/app/services/calendar.service.ts @@ -0,0 +1,34 @@ +import {Injectable} from '@angular/core'; +import {Observable} from 'rxjs'; +import {ApiService} from './api.service'; +import {CONTEXT_PATH, getBaseUrl} from './config.service'; +import {CalendarIncludedTimeTest, QuartzCalendar} from '../model/calendar.model'; + +@Injectable() +export class CalendarService { + constructor(private apiService: ApiService) {} + + fetchCalendars = (): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars`); + } + + getCalendar = (name: string): Observable => { + return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`); + } + + createCalendar = (name: string, calendar: QuartzCalendar): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar); + } + + updateCalendar = (name: string, calendar: QuartzCalendar): Observable => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`, calendar); + } + + deleteCalendar = (name: string): Observable => { + return this.apiService.delete(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}`); + } + + testIncludedTime = (name: string, time: Date): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/calendars/${name}/included-time-test`, {time}); + } +} diff --git a/quartz-manager-frontend/src/app/services/index.ts b/quartz-manager-frontend/src/app/services/index.ts index 81435d0..df9feec 100644 --- a/quartz-manager-frontend/src/app/services/index.ts +++ b/quartz-manager-frontend/src/app/services/index.ts @@ -5,7 +5,8 @@ export * from './auth.service'; export * from './scheduler.service'; export * from './progress.rx-websocket.service'; export * from './logs.rx-websocket.service'; -export * from './trigger.service' -export * from './job.service' +export * from './trigger.service' +export * from './calendar.service' +export * from './job.service' diff --git a/quartz-manager-frontend/src/app/services/job.service.spec.ts b/quartz-manager-frontend/src/app/services/job.service.spec.ts index 3f274d7..fc2dc4d 100644 --- a/quartz-manager-frontend/src/app/services/job.service.spec.ts +++ b/quartz-manager-frontend/src/app/services/job.service.spec.ts @@ -18,21 +18,28 @@ describe('JobService', () => { it('uses job class and scheduled job endpoints', () => { const job = new ScheduledJob(); + const command = { + jobClass: 'SampleJob', + description: '', + durable: true, + requestsRecovery: false, + jobDataMap: {} + }; job.jobKeyDTO = {group: 'DEFAULT', name: 'sampleJob'}; jobService.fetchJobs(); jobService.fetchScheduledJobs(); jobService.getScheduledJob('DEFAULT', 'sampleJob'); - jobService.createJob('DEFAULT', 'sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); - jobService.updateJob('DEFAULT', 'sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); + jobService.createJob('DEFAULT', 'sampleJob', command); + jobService.updateJob('DEFAULT', 'sampleJob', command); jobService.triggerJob(job); jobService.deleteJob(job); expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/job-classes'); expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs'); expect(apiService.get).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob'); - expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); - expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', {jobClass: 'SampleJob', description: '', durable: true, requestsRecovery: false, jobDataMap: {}}); + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', command); + expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob', command); expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob/trigger', {}); expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/jobs/DEFAULT/sampleJob'); }); diff --git a/quartz-manager-frontend/src/app/services/trigger.service.spec.ts b/quartz-manager-frontend/src/app/services/trigger.service.spec.ts index 648be7d..68d487b 100644 --- a/quartz-manager-frontend/src/app/services/trigger.service.spec.ts +++ b/quartz-manager-frontend/src/app/services/trigger.service.spec.ts @@ -10,6 +10,7 @@ describe('TriggerService', () => { apiService = { get: jest.fn(), post: jest.fn(), + put: jest.fn(), delete: jest.fn() }; triggerService = new TriggerService(apiService); @@ -28,4 +29,14 @@ describe('TriggerService', () => { expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger/resume', {}); expect(apiService.delete).toHaveBeenCalledWith('/quartz-manager/triggers/DEFAULT/sampleTrigger'); }); + + it('uses generic trigger create and update endpoints', () => { + const command: any = {triggerType: 'CRON', cronExpression: '0 0/5 * * * ?'}; + + triggerService.saveTrigger('OPS', 'cronTrigger', command); + triggerService.updateTrigger('OPS', 'cronTrigger', command); + + expect(apiService.post).toHaveBeenCalledWith('/quartz-manager/triggers/OPS/cronTrigger', command); + expect(apiService.put).toHaveBeenCalledWith('/quartz-manager/triggers/OPS/cronTrigger', command); + }); }); diff --git a/quartz-manager-frontend/src/app/services/trigger.service.ts b/quartz-manager-frontend/src/app/services/trigger.service.ts index f8329b8..21e385c 100644 --- a/quartz-manager-frontend/src/app/services/trigger.service.ts +++ b/quartz-manager-frontend/src/app/services/trigger.service.ts @@ -4,6 +4,7 @@ import {Observable} from 'rxjs'; import {Trigger} from '../model/trigger.model'; import {TriggerKey} from '../model/triggerKey.model'; import {CONTEXT_PATH, getBaseUrl} from './config.service'; +import {TriggerCommand} from '../model/trigger-command.model'; @Injectable() export class TriggerService { @@ -20,6 +21,14 @@ export class TriggerService { return this.apiService.get(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}`); } + saveTrigger = (group: string, name: string, config: TriggerCommand): Observable => { + return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config); + } + + updateTrigger = (group: string, name: string, config: TriggerCommand): Observable => { + return this.apiService.put(getBaseUrl() + `${CONTEXT_PATH}/triggers/${group || 'DEFAULT'}/${name}`, config); + } + pauseTrigger = (triggerKey: TriggerKey): Observable => { return this.apiService.post(getBaseUrl() + `${CONTEXT_PATH}/triggers/${triggerKey.group || 'DEFAULT'}/${triggerKey.name}/pause`, {}); } diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.html b/quartz-manager-frontend/src/app/views/manager/manager.component.html index b079af5..f6f791b 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.html +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.html @@ -245,17 +245,16 @@
-

Calendars

Quartz calendar registry, rule editing, trigger usage, and next included time testing are not exposed by the backend yet.

-
+

Calendars

Manage Quartz calendar exclusions and inspect which triggers are attached to each calendar.

+
-
-

Calendar Registry

ROADMAP
-
-
-

This UI is ready for WeeklyCalendar, HolidayCalendar, MonthlyCalendar, DailyCalendar, and CronCalendar once the API surface is added.

-
Mon
Tue
Wed
Thu
Fri
Sat
Sun
-
- +
+

Calendar Registry

{{ getCalendarRows().length }} / {{ calendars.length }} CALENDARS
+
+
@for (calendar of getCalendarRows(); track calendar.name) { } @empty { }
NameTypeDescriptionTriggers
{{ calendar.name }}{{ calendar.type }}{{ calendar.description || '-' }}{{ calendar.triggerKeys?.length || 0 }}
No calendars registered. Create one to exclude time windows from trigger firing.
+
@@ -328,7 +327,7 @@
- @if (wizardOpen || jobWizardOpen || detailDrawerOpen) { + @if (wizardOpen || jobWizardOpen || calendarWizardOpen || detailDrawerOpen) { } @@ -341,17 +340,16 @@ } + +
diff --git a/quartz-manager-frontend/src/app/views/manager/manager.component.ts b/quartz-manager-frontend/src/app/views/manager/manager.component.ts index 4c19a2a..1544e7e 100644 --- a/quartz-manager-frontend/src/app/views/manager/manager.component.ts +++ b/quartz-manager-frontend/src/app/views/manager/manager.component.ts @@ -2,13 +2,14 @@ import {Component, NgZone, OnDestroy, OnInit} from '@angular/core'; import {Subscription} from 'rxjs'; import {map} from 'rxjs/operators'; -import {SchedulerService, TriggerService} from '../../services'; +import {CalendarService, SchedulerService, TriggerService} from '../../services'; import JobService from '../../services/job.service'; import {LogsRxWebsocketService} from '../../services/logs.rx-websocket.service'; import {ProgressRxWebsocketService} from '../../services/progress.rx-websocket.service'; import {Scheduler} from '../../model/scheduler.model'; -import {SimpleTriggerCommand} from '../../model/simple-trigger.command'; -import {SimpleTrigger} from '../../model/simple-trigger.model'; +import {Trigger} from '../../model/trigger.model'; +import {TriggerCommand, TriggerType} from '../../model/trigger-command.model'; +import {CalendarType, QuartzCalendar} from '../../model/calendar.model'; import {ScheduledJob} from '../../model/scheduled-job.model'; import {ScheduledJobCommand} from '../../model/scheduled-job.command'; import {TriggerKey} from '../../model/triggerKey.model'; @@ -18,6 +19,7 @@ type ConsolePage = 'dashboard' | 'jobs' | 'triggers' | 'calendars' | 'executions type WizardMode = 'create' | 'edit'; type JobTargetType = 'stored' | 'class'; type JobDataMapType = 'string' | 'number' | 'boolean' | 'json' | 'null'; +type CalendarRuleMode = 'dates' | 'weekdays' | 'monthdays' | 'timeRange' | 'cron'; interface ConsoleLogRecord { time: Date; @@ -30,6 +32,7 @@ interface ConsoleLogRecord { interface TriggerDraft { triggerName: string; group: string; + triggerType: TriggerType; jobTargetType: JobTargetType; storedJobKey: string; jobClass: string; @@ -39,9 +42,32 @@ interface TriggerDraft { repeatIntervalUnit: string; repeatCount: number; misfireInstruction: string; + cronExpression: string; + timeZone: string; + startTimeOfDay: string; + endTimeOfDay: string; + daysOfWeek: number[]; + preserveHourOfDayAcrossDaylightSavings: boolean; + skipDayIfHourDoesNotExist: boolean; + calendarName: string; jobDataMapEntries: JobDataMapEntry[]; } +interface CalendarDraft { + name: string; + type: CalendarType; + description: string; + cronExpression: string; + timeZone: string; + rangeStartingTime: string; + rangeEndingTime: string; + invertTimeRange: boolean; + excludedDaysOfWeek: number[]; + excludedDaysOfMonth: number[]; + excludedDates: string[]; + includedTime: string; +} + interface JobDraft { name: string; group: string; @@ -73,9 +99,9 @@ export class ManagerComponent implements OnInit, OnDestroy { scheduler: Scheduler; schedulerLoading = false; triggerKeys: TriggerKey[] = []; - triggerDetailsByName: {[triggerName: string]: SimpleTrigger} = {}; + triggerDetailsByName: {[triggerName: string]: Trigger} = {}; selectedTriggerKey: TriggerKey; - selectedTrigger: SimpleTrigger; + selectedTrigger: Trigger; selectedJobClass: string; selectedScheduledJob: ScheduledJob; jobs: string[] = []; @@ -95,14 +121,23 @@ export class ManagerComponent implements OnInit, OnDestroy { wizardError: string; jobWizardError: string; triggerDraft: TriggerDraft = this.buildEmptyDraft(); + calendarDraft: CalendarDraft = this.buildEmptyCalendarDraft(); jobDraft: JobDraft = this.buildEmptyJobDraft(); jobWizardMode: WizardMode = 'create'; jobGroupFilter = 'ALL'; triggerGroupFilter = 'ALL'; jobSearch = ''; triggerSearch = ''; + calendarSearch = ''; + calendars: QuartzCalendar[] = []; + selectedCalendar: QuartzCalendar; + calendarWizardOpen = false; + calendarWizardMode: WizardMode = 'create'; + calendarWizardSubmitting = false; + calendarWizardError: string; + calendarIncludedTimeResult: string; - private readonly roadmapPages = new Set(['calendars', 'executions']); + private readonly roadmapPages = new Set(['executions']); private readonly subscriptions: Subscription[] = []; private logsSubscription: Subscription; private progressSubscription: Subscription; @@ -111,6 +146,7 @@ export class ManagerComponent implements OnInit, OnDestroy { constructor( private schedulerService: SchedulerService, private triggerService: TriggerService, + private calendarService: CalendarService, private jobService: JobService, private logsRxWebsocketService: LogsRxWebsocketService, private progressRxWebsocketService: ProgressRxWebsocketService, @@ -122,6 +158,7 @@ export class ManagerComponent implements OnInit, OnDestroy { this.fetchTriggers(); this.fetchJobs(); this.fetchScheduledJobs(); + this.fetchCalendars(); } ngOnDestroy() { @@ -189,10 +226,15 @@ export class ManagerComponent implements OnInit, OnDestroy { this.jobWizardOpen = false; } + closeCalendarWizardDrawer() { + this.calendarWizardOpen = false; + } + closeDrawers() { this.detailDrawerOpen = false; this.wizardOpen = false; this.jobWizardOpen = false; + this.calendarWizardOpen = false; } refreshScheduler() { @@ -294,10 +336,21 @@ export class ManagerComponent implements OnInit, OnDestroy { this.subscriptions.push(subscription); } + fetchCalendars() { + const subscription = this.calendarService.fetchCalendars().subscribe({ + next: calendars => { + this.calendars = calendars || []; + this.selectedCalendar = this.selectedCalendar || this.calendars[0]; + }, + error: () => this.operationError = 'Unable to load calendars.' + }); + this.subscriptions.push(subscription); + } + fetchTriggerDetails(triggerKeys: TriggerKey[]) { triggerKeys.forEach(triggerKey => { - const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name, this.getTriggerGroup(triggerKey)).subscribe({ - next: trigger => this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger as SimpleTrigger, + const subscription = this.triggerService.getTrigger(triggerKey).subscribe({ + next: trigger => this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger, error: () => { this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = null; } @@ -317,15 +370,15 @@ export class ManagerComponent implements OnInit, OnDestroy { this.triggerLoading = true; this.selectedTrigger = this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] || null; this.subscribeToTriggerTopics(this.selectedTriggerKey); - const subscription = this.schedulerService.getSimpleTriggerConfig(triggerKey.name, this.getTriggerGroup(triggerKey)).subscribe({ + const subscription = this.triggerService.getTrigger(triggerKey).subscribe({ next: trigger => { - this.selectedTrigger = trigger as SimpleTrigger; - this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger as SimpleTrigger; + this.selectedTrigger = trigger; + this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] = trigger; this.triggerLoading = false; }, error: () => { this.triggerLoading = false; - this.showRoadmapNotice('Only SimpleTrigger details are supported by the current backend'); + this.operationError = 'Unable to load trigger details.'; } }); this.subscriptions.push(subscription); @@ -452,6 +505,95 @@ export class ManagerComponent implements OnInit, OnDestroy { this.subscriptions.push(subscription); } + selectCalendar(calendar: QuartzCalendar) { + this.selectedCalendar = calendar; + this.openDetailDrawer(); + } + + openCreateCalendarWizard() { + this.calendarWizardMode = 'create'; + this.calendarWizardError = null; + this.calendarDraft = this.buildEmptyCalendarDraft(); + this.calendarWizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('calendars'); + this.calendarWizardOpen = true; + } + + openEditCalendarWizard() { + if (!this.selectedCalendar) { + return; + } + this.calendarWizardMode = 'edit'; + this.calendarWizardError = null; + this.calendarDraft = this.fromCalendarToDraft(this.selectedCalendar); + this.calendarWizardOpen = true; + this.detailDrawerOpen = false; + this.selectPage('calendars'); + this.calendarWizardOpen = true; + } + + submitCalendarWizard() { + this.calendarWizardError = null; + if (!this.canSubmitCalendar()) { + this.calendarWizardError = 'Calendar name, type, and rule fields are required.'; + return; + } + + const calendar = this.fromCalendarDraftToCommand(); + const name = this.calendarDraft.name.trim(); + this.calendarWizardSubmitting = true; + const request = this.calendarWizardMode === 'edit' + ? this.calendarService.updateCalendar(name, calendar) + : this.calendarService.createCalendar(name, calendar); + + const subscription = request.subscribe({ + next: savedCalendar => { + this.calendarWizardSubmitting = false; + this.upsertCalendar(savedCalendar); + this.selectedCalendar = savedCalendar; + this.calendarWizardOpen = false; + this.detailDrawerOpen = true; + this.operationNotice = this.calendarWizardMode === 'edit' ? 'Calendar updated.' : 'Calendar created.'; + }, + error: () => { + this.calendarWizardSubmitting = false; + this.calendarWizardError = 'Unable to save the calendar.'; + } + }); + this.subscriptions.push(subscription); + } + + deleteSelectedCalendar() { + if (!this.selectedCalendar || !window.confirm(`Delete calendar ${this.selectedCalendar.name}?`)) { + return; + } + const calendarName = this.selectedCalendar.name; + const subscription = this.calendarService.deleteCalendar(calendarName).subscribe({ + next: () => { + this.calendars = this.calendars.filter(calendar => calendar.name !== calendarName); + this.selectedCalendar = this.calendars[0]; + this.operationNotice = 'Calendar deleted.'; + }, + error: () => this.operationError = 'Unable to delete the selected calendar.' + }); + this.subscriptions.push(subscription); + } + + testSelectedCalendarTime() { + if (!this.selectedCalendar) { + return; + } + const testTime = this.fromDatetimeLocalValue(this.calendarDraft.includedTime) || new Date(); + const subscription = this.calendarService.testIncludedTime(this.selectedCalendar.name, testTime).subscribe({ + next: result => this.calendarIncludedTimeResult = result.included + ? 'Included at the tested time.' + : `Excluded. Next included time: ${this.formatDateTime(result.nextIncludedTime) || '-'}`, + error: () => this.operationError = 'Unable to test the selected calendar.' + }); + this.subscriptions.push(subscription); + } + pauseSelectedTrigger() { if (!this.selectedTriggerKey) { return; @@ -501,7 +643,7 @@ export class ManagerComponent implements OnInit, OnDestroy { this.resetWizard(); this.wizardOpen = true; this.detailDrawerOpen = false; - this.selectPage('dashboard'); + this.selectPage('triggers'); this.wizardOpen = true; } @@ -510,7 +652,7 @@ export class ManagerComponent implements OnInit, OnDestroy { this.selectTrigger(triggerKey, false); } if (!this.selectedTrigger && !this.selectedTriggerKey) { - this.showRoadmapNotice('Reschedule requires a SimpleTrigger loaded from the backend'); + this.showRoadmapNotice('Reschedule requires a trigger loaded from the backend'); return; } @@ -522,18 +664,29 @@ export class ManagerComponent implements OnInit, OnDestroy { this.triggerDraft = { triggerName: this.selectedTriggerKey.name, group: this.selectedTriggerKey.group || 'DEFAULT', + triggerType: this.getTriggerTypeValue(trigger), jobTargetType: trigger?.jobKeyDTO ? 'stored' : 'class', - storedJobKey: trigger?.jobKeyDTO ? this.getJobOptionValue(trigger.jobKeyDTO.group, trigger.jobKeyDTO.name) : this.getDefaultStoredJobKey(), + storedJobKey: trigger?.jobKeyDTO + ? this.getJobOptionValue(trigger.jobKeyDTO.group, trigger.jobKeyDTO.name) + : this.getDefaultStoredJobKey(), jobClass: trigger?.jobDetailDTO?.jobClassName || this.jobs[0] || '', startDate: this.toDatetimeLocalValue(trigger?.startTime), endDate: this.toDatetimeLocalValue(trigger?.endTime), repeatIntervalAmount: repeatInterval.amount, repeatIntervalUnit: repeatInterval.unit, repeatCount: trigger?.repeatCount ?? -1, - misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction), + misfireInstruction: this.getMisfireInstructionName(trigger?.misfireInstruction, this.getTriggerTypeValue(trigger)), + cronExpression: trigger?.cronExpression || '0 0/5 * * * ?', + timeZone: trigger?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone, + startTimeOfDay: trigger?.startTimeOfDay || '08:00:00', + endTimeOfDay: trigger?.endTimeOfDay || '18:00:00', + daysOfWeek: trigger?.daysOfWeek || [2, 3, 4, 5, 6], + preserveHourOfDayAcrossDaylightSavings: !!trigger?.preserveHourOfDayAcrossDaylightSavings, + skipDayIfHourDoesNotExist: !!trigger?.skipDayIfHourDoesNotExist, + calendarName: trigger?.calendarName || '', jobDataMapEntries: this.toJobDataMapEntries(trigger?.jobDataMap) }; - this.selectPage('dashboard'); + this.selectPage('triggers'); this.wizardOpen = true; } @@ -546,20 +699,32 @@ export class ManagerComponent implements OnInit, OnDestroy { submitTriggerWizard() { this.wizardError = null; if (!this.canSubmitTrigger()) { - this.wizardError = 'Trigger name, job class, misfire policy, and both repeat fields are required for the current backend.'; + this.wizardError = 'Trigger name, target job, type, and schedule fields are required.'; return; } - const command = new SimpleTriggerCommand(); - command.triggerName = this.triggerDraft.triggerName.trim(); - command.triggerGroup = this.triggerDraft.group || 'DEFAULT'; + const command = new TriggerCommand(); + command.triggerType = this.triggerDraft.triggerType; command.jobClass = this.triggerDraft.jobTargetType === 'class' ? this.triggerDraft.jobClass : null; command.jobKey = this.triggerDraft.jobTargetType === 'stored' ? this.parseJobOptionValue(this.triggerDraft.storedJobKey) : null; command.startDate = this.fromDatetimeLocalValue(this.triggerDraft.startDate); command.endDate = this.fromDatetimeLocalValue(this.triggerDraft.endDate); - command.repeatInterval = this.getRepeatIntervalMs(); - command.repeatCount = this.triggerDraft.repeatCount; + command.repeatInterval = this.getTriggerCommandRepeatInterval(); + command.repeatCount = this.triggerDraft.triggerType === 'SIMPLE' ? this.triggerDraft.repeatCount : null; + command.repeatIntervalUnit = this.getTriggerCommandRepeatIntervalUnit(); command.misfireInstruction = this.triggerDraft.misfireInstruction; + command.cronExpression = this.triggerDraft.triggerType === 'CRON' ? this.triggerDraft.cronExpression : null; + command.timeZone = this.triggerDraft.timeZone; + command.startTimeOfDay = this.triggerDraft.triggerType === 'DAILY_TIME_INTERVAL' ? this.triggerDraft.startTimeOfDay : null; + command.endTimeOfDay = this.triggerDraft.triggerType === 'DAILY_TIME_INTERVAL' ? this.triggerDraft.endTimeOfDay : null; + command.daysOfWeek = this.triggerDraft.triggerType === 'DAILY_TIME_INTERVAL' ? this.triggerDraft.daysOfWeek : null; + command.preserveHourOfDayAcrossDaylightSavings = this.triggerDraft.triggerType === 'CALENDAR_INTERVAL' + ? this.triggerDraft.preserveHourOfDayAcrossDaylightSavings + : null; + command.skipDayIfHourDoesNotExist = this.triggerDraft.triggerType === 'CALENDAR_INTERVAL' + ? this.triggerDraft.skipDayIfHourDoesNotExist + : null; + command.calendarName = this.triggerDraft.calendarName || null; try { command.jobDataMap = this.serializeJobDataMap(this.triggerDraft.jobDataMapEntries); } catch (err) { @@ -568,26 +733,28 @@ export class ManagerComponent implements OnInit, OnDestroy { } this.wizardSubmitting = true; + const group = this.triggerDraft.group || 'DEFAULT'; + const name = this.triggerDraft.triggerName.trim(); const request = this.wizardMode === 'edit' - ? this.schedulerService.updateSimpleTriggerConfig(command) - : this.schedulerService.saveSimpleTriggerConfig(command); + ? this.triggerService.updateTrigger(group, name, command) + : this.triggerService.saveTrigger(group, name, command); const subscription = request.subscribe({ next: trigger => { this.wizardSubmitting = false; - this.triggerDetailsByName[this.getTriggerDetailKey(trigger.triggerKeyDTO)] = trigger as SimpleTrigger; + this.triggerDetailsByName[this.getTriggerDetailKey(trigger.triggerKeyDTO)] = trigger; this.upsertTriggerKey(trigger.triggerKeyDTO); this.selectTrigger(trigger.triggerKeyDTO); this.wizardOpen = false; this.detailDrawerOpen = true; - this.operationNotice = this.wizardMode === 'edit' ? 'SimpleTrigger rescheduled.' : 'SimpleTrigger created.'; + this.operationNotice = this.wizardMode === 'edit' ? 'Trigger rescheduled.' : 'Trigger created.'; if (this.wizardMode === 'create') { this.resetWizard(); } }, error: () => { this.wizardSubmitting = false; - this.wizardError = 'Unable to save the SimpleTrigger with the current backend.'; + this.wizardError = 'Unable to save the trigger.'; } }); this.subscriptions.push(subscription); @@ -602,7 +769,7 @@ export class ManagerComponent implements OnInit, OnDestroy { } } - getTriggerDetail(triggerKey: TriggerKey): SimpleTrigger { + getTriggerDetail(triggerKey: TriggerKey): Trigger { return triggerKey?.name ? this.triggerDetailsByName[this.getTriggerDetailKey(triggerKey)] : null; } @@ -611,7 +778,7 @@ export class ManagerComponent implements OnInit, OnDestroy { } getTriggerType(triggerKey: TriggerKey): string { - return this.getTriggerDetail(triggerKey)?.type || 'SimpleTrigger'; + return this.getTriggerDetail(triggerKey)?.type || 'Trigger'; } getTriggerState(triggerKey: TriggerKey): string { @@ -779,32 +946,83 @@ export class ManagerComponent implements OnInit, OnDestroy { } getWizardTitle(): string { - return this.wizardMode === 'edit' ? 'Reschedule SimpleTrigger' : 'Create SimpleTrigger'; + return this.wizardMode === 'edit' ? 'Reschedule Trigger' : 'Create Trigger'; } getWizardCta(): string { - return this.wizardMode === 'edit' ? 'Save Reschedule' : 'Create SimpleTrigger'; + return this.wizardMode === 'edit' ? 'Save Reschedule' : 'Create Trigger'; } canSubmitTrigger(): boolean { const hasTarget = this.triggerDraft.jobTargetType === 'stored' ? !!this.triggerDraft.storedJobKey : !!this.triggerDraft.jobClass; - return !!( - this.triggerDraft.triggerName?.trim() + return !!(this.triggerDraft.triggerName?.trim() && hasTarget - && this.triggerDraft.misfireInstruction - && this.triggerDraft.repeatCount !== null - && this.triggerDraft.repeatCount !== undefined - && this.triggerDraft.repeatIntervalAmount - && this.triggerDraft.repeatIntervalUnit - ); + && this.triggerDraft.triggerType + && this.hasValidTriggerSchedule()); + } + + hasValidTriggerSchedule(): boolean { + switch (this.triggerDraft.triggerType) { + case 'CRON': return !!this.triggerDraft.cronExpression?.trim(); + case 'DAILY_TIME_INTERVAL': return !!(this.triggerDraft.repeatIntervalAmount + && this.triggerDraft.repeatIntervalUnit + && this.triggerDraft.startTimeOfDay + && this.triggerDraft.endTimeOfDay); + case 'CALENDAR_INTERVAL': return !!(this.triggerDraft.repeatIntervalAmount && this.triggerDraft.repeatIntervalUnit); + default: return this.triggerDraft.repeatCount !== null + && this.triggerDraft.repeatCount !== undefined + && !!this.triggerDraft.repeatIntervalAmount; + } } canSubmitJob(): boolean { return !!(this.jobDraft.name?.trim() && this.jobDraft.group?.trim() && this.jobDraft.jobClass); } + canSubmitCalendar(): boolean { + if (!this.calendarDraft.name?.trim() || !this.calendarDraft.type) { + return false; + } + switch (this.calendarDraft.type) { + case 'CRON': return !!this.calendarDraft.cronExpression?.trim(); + case 'DAILY': return !!(this.calendarDraft.rangeStartingTime && this.calendarDraft.rangeEndingTime); + default: return true; + } + } + + getCalendarRows(): QuartzCalendar[] { + const search = this.calendarSearch?.trim().toLowerCase(); + return (this.calendars || []).filter(calendar => !search || `${calendar.name} ${calendar.type}`.toLowerCase().includes(search)); + } + + getCalendarRuleMode(): CalendarRuleMode { + switch (this.calendarDraft.type) { + case 'WEEKLY': return 'weekdays'; + case 'MONTHLY': return 'monthdays'; + case 'DAILY': return 'timeRange'; + case 'CRON': return 'cron'; + default: return 'dates'; + } + } + + toggleCalendarWeekday(day: number) { + this.calendarDraft.excludedDaysOfWeek = this.toggleNumberValue(this.calendarDraft.excludedDaysOfWeek, day); + } + + toggleCalendarMonthday(day: number) { + this.calendarDraft.excludedDaysOfMonth = this.toggleNumberValue(this.calendarDraft.excludedDaysOfMonth, day); + } + + addCalendarDate() { + this.calendarDraft.excludedDates = [...(this.calendarDraft.excludedDates || []), this.toDatetimeLocalValue(new Date())]; + } + + removeCalendarDate(index: number) { + this.calendarDraft.excludedDates.splice(index, 1); + } + addJobDataMapEntry(entries: JobDataMapEntry[]) { entries.push({key: '', type: 'string', value: ''}); } @@ -814,6 +1032,9 @@ export class ManagerComponent implements OnInit, OnDestroy { } getFirePreview(): string[] { + if (this.triggerDraft.triggerType === 'CRON') { + return [`Cron expression: ${this.triggerDraft.cronExpression || '-'}`]; + } const start = this.fromDatetimeLocalValue(this.triggerDraft.startDate) || new Date(); const repeatInterval = this.getRepeatIntervalMs(); if (!repeatInterval || repeatInterval <= 0) { @@ -826,6 +1047,43 @@ export class ManagerComponent implements OnInit, OnDestroy { }); } + getTriggerTypeValue(trigger: Trigger): TriggerType { + const type = trigger?.type || ''; + if (type.includes('Cron')) { + return 'CRON'; + } + if (type.includes('DailyTimeInterval')) { + return 'DAILY_TIME_INTERVAL'; + } + if (type.includes('CalendarInterval')) { + return 'CALENDAR_INTERVAL'; + } + return 'SIMPLE'; + } + + selectTriggerType(triggerType: TriggerType) { + this.triggerDraft.triggerType = triggerType; + this.triggerDraft.misfireInstruction = this.getDefaultMisfireInstruction(triggerType); + } + + toggleDayOfWeek(day: number) { + const days = new Set(this.triggerDraft.daysOfWeek || []); + if (days.has(day)) { + days.delete(day); + } else { + days.add(day); + } + this.triggerDraft.daysOfWeek = Array.from(days).sort((first, second) => first - second); + } + + isDayOfWeekSelected(day: number): boolean { + return (this.triggerDraft.daysOfWeek || []).includes(day); + } + + getCalendarOptions(): string[] { + return this.calendars.map(calendar => calendar.name); + } + shortClassName(className: string): string { if (!className) { return null; @@ -927,6 +1185,11 @@ export class ManagerComponent implements OnInit, OnDestroy { this.scheduledJobs = [job, ...otherJobs]; } + private upsertCalendar(calendar: QuartzCalendar) { + const otherCalendars = this.calendars.filter(currentCalendar => currentCalendar.name !== calendar.name); + this.calendars = [calendar, ...otherCalendars]; + } + private sameTriggerKey(first: TriggerKey, second: TriggerKey): boolean { return first?.name === second?.name && this.getTriggerGroup(first) === this.getTriggerGroup(second); } @@ -943,6 +1206,7 @@ export class ManagerComponent implements OnInit, OnDestroy { return { triggerName: '', group: 'DEFAULT', + triggerType: 'SIMPLE', jobTargetType: this.scheduledJobs.length > 0 ? 'stored' : 'class', storedJobKey: this.getDefaultStoredJobKey(), jobClass: this.jobs[0] || '', @@ -952,10 +1216,35 @@ export class ManagerComponent implements OnInit, OnDestroy { repeatIntervalUnit: 'minutes', repeatCount: -1, misfireInstruction: 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT', + cronExpression: '0 0/5 * * * ?', + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + startTimeOfDay: '08:00:00', + endTimeOfDay: '18:00:00', + daysOfWeek: [2, 3, 4, 5, 6], + preserveHourOfDayAcrossDaylightSavings: false, + skipDayIfHourDoesNotExist: false, + calendarName: '', jobDataMapEntries: [] }; } + private buildEmptyCalendarDraft(): CalendarDraft { + return { + name: '', + type: 'WEEKLY', + description: '', + cronExpression: '0 0 0 ? * SAT,SUN', + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + rangeStartingTime: '22:00:00', + rangeEndingTime: '06:00:00', + invertTimeRange: false, + excludedDaysOfWeek: [1, 7], + excludedDaysOfMonth: [], + excludedDates: [], + includedTime: this.toDatetimeLocalValue(new Date()) + }; + } + private buildEmptyJobDraft(): JobDraft { return { name: '', @@ -982,8 +1271,53 @@ export class ManagerComponent implements OnInit, OnDestroy { return name ? {group: group || 'DEFAULT', name} : null; } + private fromCalendarToDraft(calendar: QuartzCalendar): CalendarDraft { + return { + name: calendar.name, + type: calendar.type, + description: calendar.description || '', + cronExpression: calendar.cronExpression || '0 0 0 ? * SAT,SUN', + timeZone: calendar.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone, + rangeStartingTime: calendar.rangeStartingTime || '22:00:00', + rangeEndingTime: calendar.rangeEndingTime || '06:00:00', + invertTimeRange: !!calendar.invertTimeRange, + excludedDaysOfWeek: calendar.excludedDaysOfWeek || [], + excludedDaysOfMonth: calendar.excludedDaysOfMonth || [], + excludedDates: (calendar.excludedDates || []).map(date => this.toDatetimeLocalValue(date)), + includedTime: this.toDatetimeLocalValue(new Date()) + }; + } + + private fromCalendarDraftToCommand(): QuartzCalendar { + const calendar = new QuartzCalendar(); + calendar.name = this.calendarDraft.name.trim(); + calendar.type = this.calendarDraft.type; + calendar.description = this.calendarDraft.description; + calendar.cronExpression = this.calendarDraft.type === 'CRON' ? this.calendarDraft.cronExpression : null; + calendar.timeZone = this.calendarDraft.timeZone; + calendar.rangeStartingTime = this.calendarDraft.type === 'DAILY' ? this.calendarDraft.rangeStartingTime : null; + calendar.rangeEndingTime = this.calendarDraft.type === 'DAILY' ? this.calendarDraft.rangeEndingTime : null; + calendar.invertTimeRange = this.calendarDraft.type === 'DAILY' ? this.calendarDraft.invertTimeRange : null; + calendar.excludedDaysOfWeek = this.calendarDraft.type === 'WEEKLY' ? this.calendarDraft.excludedDaysOfWeek : null; + calendar.excludedDaysOfMonth = this.calendarDraft.type === 'MONTHLY' ? this.calendarDraft.excludedDaysOfMonth : null; + calendar.excludedDates = ['ANNUAL', 'HOLIDAY'].includes(this.calendarDraft.type) + ? (this.calendarDraft.excludedDates || []).map(value => this.fromDatetimeLocalValue(value)).filter(Boolean) + : null; + return calendar; + } + + private toggleNumberValue(values: number[], value: number): number[] { + const set = new Set(values || []); + if (set.has(value)) { + set.delete(value); + } else { + set.add(value); + } + return Array.from(set).sort((first, second) => first - second); + } + private getUniqueGroups(groups: string[]): string[] { - return Array.from(new Set((groups || []).filter(Boolean))).sort(); + return Array.from(new Set((groups || []).filter(Boolean))).sort((first, second) => first.localeCompare(second)); } private toJobDataMapEntries(jobDataMap: {[key: string]: unknown}): JobDataMapEntry[] { @@ -1052,7 +1386,7 @@ export class ManagerComponent implements OnInit, OnDestroy { try { return JSON.parse(entry.value || 'null'); } catch (err) { - throw new Error(`JobDataMap key "${entry.key}" contains invalid JSON.`); + throw new Error(`JobDataMap key "${entry.key}" contains invalid JSON: ${this.getErrorMessage(err, 'Invalid JSON')}`); } case 'null': return null; @@ -1080,6 +1414,30 @@ export class ManagerComponent implements OnInit, OnDestroy { } } + private getTriggerCommandRepeatInterval(): number { + if (this.triggerDraft.triggerType === 'SIMPLE') { + return this.getRepeatIntervalMs(); + } + return Number(this.triggerDraft.repeatIntervalAmount || 0); + } + + private getTriggerCommandRepeatIntervalUnit(): string { + if (this.triggerDraft.triggerType === 'SIMPLE' || this.triggerDraft.triggerType === 'CRON') { + return null; + } + const unit = this.triggerDraft.repeatIntervalUnit || 'minutes'; + switch (unit) { + case 'seconds': return 'SECOND'; + case 'minutes': return 'MINUTE'; + case 'hours': return 'HOUR'; + case 'days': return 'DAY'; + case 'weeks': return 'WEEK'; + case 'months': return 'MONTH'; + case 'years': return 'YEAR'; + default: return unit.toUpperCase(); + } + } + private splitRepeatInterval(milliseconds: number): {amount: number; unit: string} { if (milliseconds && milliseconds % 86400000 === 0) { return {amount: milliseconds / 86400000, unit: 'days'}; @@ -1116,7 +1474,15 @@ export class ManagerComponent implements OnInit, OnDestroy { return offsetDate.toISOString().slice(0, 16); } - private getMisfireInstructionName(misfireInstruction: number): string { + private getMisfireInstructionName(misfireInstruction: number, triggerType: TriggerType = 'SIMPLE'): string { + if (triggerType !== 'SIMPLE') { + switch (misfireInstruction) { + case 1: return 'IGNORE_MISFIRES'; + case 2: return 'FIRE_AND_PROCEED'; + case 3: return 'DO_NOTHING'; + default: return 'FIRE_AND_PROCEED'; + } + } switch (misfireInstruction) { case 1: return 'MISFIRE_INSTRUCTION_FIRE_NOW'; case 2: return 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT'; @@ -1127,6 +1493,12 @@ export class ManagerComponent implements OnInit, OnDestroy { } } + private getDefaultMisfireInstruction(triggerType: TriggerType): string { + return triggerType === 'SIMPLE' + ? 'MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT' + : 'FIRE_AND_PROCEED'; + } + private getPageTitle(page: ConsolePage): string { switch (page) { case 'calendars': return 'Quartz calendars'; diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java new file mode 100644 index 0000000..6e606c6 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/CalendarController.java @@ -0,0 +1,77 @@ +package it.fabioformosa.quartzmanager.api.controllers; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import it.fabioformosa.quartzmanager.api.dto.CalendarDTO; +import it.fabioformosa.quartzmanager.api.dto.CalendarIncludedTimeDTO; +import it.fabioformosa.quartzmanager.api.services.CalendarService; +import jakarta.validation.Valid; +import org.quartz.SchedulerException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.text.ParseException; +import java.util.List; + +import static it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA; +import static it.fabioformosa.quartzmanager.api.common.config.QuartzManagerPaths.QUARTZ_MANAGER_BASE_CONTEXT_PATH; + +@RequestMapping(CalendarController.CALENDAR_CONTROLLER_BASE_URL) +@SecurityRequirement(name = QUARTZ_MANAGER_SEC_OAS_SCHEMA) +@RestController +public class CalendarController { + + protected static final String CALENDAR_CONTROLLER_BASE_URL = QUARTZ_MANAGER_BASE_CONTEXT_PATH + "/calendars"; + + private final CalendarService calendarService; + + public CalendarController(CalendarService calendarService) { + this.calendarService = calendarService; + } + + @GetMapping + @Operation(summary = "Get a list of calendars") + public List listCalendars() throws SchedulerException { + return calendarService.fetchCalendars(); + } + + @GetMapping("/{name}") + @Operation(summary = "Get calendar details") + public CalendarDTO getCalendar(@PathVariable String name) throws SchedulerException { + return calendarService.getCalendar(name); + } + + @PostMapping("/{name}") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a calendar") + public CalendarDTO postCalendar(@PathVariable String name, @Valid @RequestBody CalendarDTO calendarDTO) throws SchedulerException, ParseException { + return calendarService.addCalendar(name, calendarDTO); + } + + @PutMapping("/{name}") + @Operation(summary = "Update a calendar") + public CalendarDTO putCalendar(@PathVariable String name, @Valid @RequestBody CalendarDTO calendarDTO) throws SchedulerException, ParseException { + return calendarService.updateCalendar(name, calendarDTO); + } + + @DeleteMapping("/{name}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a calendar") + public void deleteCalendar(@PathVariable String name) throws SchedulerException { + calendarService.deleteCalendar(name); + } + + @PostMapping("/{name}/included-time-test") + @Operation(summary = "Test if a time is included by a calendar") + public CalendarIncludedTimeDTO testIncludedTime(@PathVariable String name, @Valid @RequestBody CalendarIncludedTimeDTO input) throws SchedulerException { + return calendarService.testIncludedTime(name, input); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java index 2e42602..b4b8d87 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/TriggerController.java @@ -8,8 +8,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; import it.fabioformosa.quartzmanager.api.dto.TriggerDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerInputDTO; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; import it.fabioformosa.quartzmanager.api.services.TriggerService; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.quartz.SchedulerException; import org.springframework.http.HttpStatus; @@ -17,10 +19,13 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import java.text.ParseException; import java.util.List; import static it.fabioformosa.quartzmanager.api.common.config.OpenAPIConfigConsts.QUARTZ_MANAGER_SEC_OAS_SCHEMA; @@ -63,6 +68,25 @@ public class TriggerController { return triggerService.getTrigger(group, name); } + @PostMapping("/{group}/{name}") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Schedule a new trigger") + public TriggerDTO postTrigger(@PathVariable String group, @PathVariable String name, @Valid @RequestBody TriggerInputDTO triggerInputDTO) throws SchedulerException, ClassNotFoundException, ParseException { + log.info("TRIGGER - CREATING a trigger {} {}", name, triggerInputDTO); + TriggerDTO newTriggerDTO = triggerService.scheduleTrigger(group, name, triggerInputDTO); + log.info("TRIGGER - CREATED a trigger {}", newTriggerDTO); + return newTriggerDTO; + } + + @PutMapping("/{group}/{name}") + @Operation(summary = "Reschedule a trigger") + public TriggerDTO rescheduleTrigger(@PathVariable String group, @PathVariable String name, @Valid @RequestBody TriggerInputDTO triggerInputDTO) throws SchedulerException, TriggerNotFoundException, ParseException { + log.info("TRIGGER - RESCHEDULING the trigger {} {}", name, triggerInputDTO); + TriggerDTO triggerDTO = triggerService.rescheduleTrigger(group, name, triggerInputDTO); + log.info("TRIGGER - RESCHEDULED the trigger {}", triggerDTO); + return triggerDTO; + } + @PostMapping("/{group}/{name}/pause") @ResponseStatus(HttpStatus.NO_CONTENT) @Operation(summary = "Pause a trigger") diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java index 579d128..2360a0a 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/controllers/advices/ExceptionHandlingController.java @@ -1,6 +1,7 @@ package it.fabioformosa.quartzmanager.api.controllers.advices; import it.fabioformosa.quartzmanager.api.exceptions.ExceptionResponse; +import it.fabioformosa.quartzmanager.api.exceptions.CalendarNotFoundException; import it.fabioformosa.quartzmanager.api.exceptions.JobNotFoundException; import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; @@ -37,6 +38,13 @@ public class ExceptionHandlingController { return ExceptionResponse.builder().errorCode(HttpStatus.NOT_FOUND.toString()).errorMessage(ex.getMessage()).build(); } + @ExceptionHandler(CalendarNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + @ResponseBody + public ExceptionResponse calendarNotFound(CalendarNotFoundException ex){ + return ExceptionResponse.builder().errorCode(HttpStatus.NOT_FOUND.toString()).errorMessage(ex.getMessage()).build(); + } + @ExceptionHandler(UnsupportedTriggerTypeException.class) public ResponseEntity unsupportedTriggerType(UnsupportedTriggerTypeException ex) { ExceptionResponse response = ExceptionResponse.builder() diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java new file mode 100644 index 0000000..bb05e82 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarDTO.java @@ -0,0 +1,50 @@ +package it.fabioformosa.quartzmanager.api.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Data +public class CalendarDTO { + private String name; + + @NotNull + private CalendarType type; + + private String description; + private String cronExpression; + private String timeZone; + private String rangeStartingTime; + private String rangeEndingTime; + private Boolean invertTimeRange; + private Set excludedDaysOfWeek; + private Set excludedDaysOfMonth; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private List excludedDates; + + private List triggerKeys; + + public List getExcludedDatesOrEmpty() { + return excludedDates == null ? Collections.emptyList() : excludedDates; + } + + public Set getExcludedDaysOfWeekOrEmpty() { + return excludedDaysOfWeek == null ? Collections.emptySet() : excludedDaysOfWeek; + } + + public Set getExcludedDaysOfMonthOrEmpty() { + return excludedDaysOfMonth == null ? Collections.emptySet() : excludedDaysOfMonth; + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java new file mode 100644 index 0000000..6091caf --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarIncludedTimeDTO.java @@ -0,0 +1,25 @@ +package it.fabioformosa.quartzmanager.api.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Data +public class CalendarIncludedTimeDTO { + @NotNull + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private Date time; + + private Boolean included; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private Date nextIncludedTime; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java new file mode 100644 index 0000000..a3f682b --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/CalendarType.java @@ -0,0 +1,10 @@ +package it.fabioformosa.quartzmanager.api.dto; + +public enum CalendarType { + ANNUAL, + CRON, + DAILY, + HOLIDAY, + MONTHLY, + WEEKLY +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java index 1fa500b..8330da5 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerDTO.java @@ -9,8 +9,8 @@ import lombok.experimental.SuperBuilder; @SuperBuilder public class SimpleTriggerDTO extends TriggerDTO{ - private int repeatCount; - private long repeatInterval; - private int timesTriggered; + private Integer repeatCount; + private Long repeatInterval; + private Integer timesTriggered; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java index 2ffb228..52fb952 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/SimpleTriggerInputDTO.java @@ -1,6 +1,8 @@ package it.fabioformosa.quartzmanager.api.dto; import it.fabioformosa.quartzmanager.api.validators.ValidTriggerRepetition; +import it.fabioformosa.quartzmanager.api.validators.JobTargetDTO; +import it.fabioformosa.quartzmanager.api.validators.ValidJobTarget; import lombok.*; import lombok.experimental.SuperBuilder; import jakarta.annotation.Nullable; @@ -8,13 +10,14 @@ import jakarta.validation.constraints.Positive; import java.util.Map; @ValidTriggerRepetition +@ValidJobTarget @SuperBuilder @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(callSuper = true) @Data @ToString(callSuper = true) -public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerRepetitionDTO { +public class SimpleTriggerInputDTO extends TriggerCommandDTO implements TriggerRepetitionDTO, JobTargetDTO { private Integer repeatCount; @Positive diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java index eb645c5..27a80f2 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerDTO.java @@ -7,6 +7,7 @@ import lombok.experimental.SuperBuilder; import org.quartz.JobDataMap; import java.util.Date; +import java.util.Set; @AllArgsConstructor @NoArgsConstructor @@ -29,4 +30,14 @@ public class TriggerDTO { private JobDetailDTO jobDetailDTO; private boolean mayFireAgain; private JobDataMap jobDataMap; + private String cronExpression; + private String timeZone; + private Long repeatInterval; + private Integer repeatCount; + private String repeatIntervalUnit; + private String startTimeOfDay; + private String endTimeOfDay; + private Set daysOfWeek; + private Boolean preserveHourOfDayAcrossDaylightSavings; + private Boolean skipDayIfHourDoesNotExist; } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java new file mode 100644 index 0000000..d32a02f --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerInputDTO.java @@ -0,0 +1,60 @@ +package it.fabioformosa.quartzmanager.api.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import it.fabioformosa.quartzmanager.api.validators.JobTargetDTO; +import it.fabioformosa.quartzmanager.api.validators.ValidJobTarget; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.util.Map; +import java.util.Set; + +@Builder +@ValidJobTarget +@NoArgsConstructor +@AllArgsConstructor +@Data +public class TriggerInputDTO implements JobTargetDTO { + @NotNull + private TriggerType triggerType; + + @Nullable + private String jobClass; + + @Nullable + private JobKeyDTO jobKey; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private Date startDate; + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + private Date endDate; + + private String description; + private Integer priority; + private String calendarName; + private String misfireInstruction; + + @Nullable + private Map jobDataMap; + + private Integer repeatCount; + + @Positive + private Long repeatInterval; + + private String repeatIntervalUnit; + private String cronExpression; + private String timeZone; + private String startTimeOfDay; + private String endTimeOfDay; + private Set daysOfWeek; + private Boolean preserveHourOfDayAcrossDaylightSavings; + private Boolean skipDayIfHourDoesNotExist; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java new file mode 100644 index 0000000..e03ef82 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/dto/TriggerType.java @@ -0,0 +1,8 @@ +package it.fabioformosa.quartzmanager.api.dto; + +public enum TriggerType { + SIMPLE, + CRON, + DAILY_TIME_INTERVAL, + CALENDAR_INTERVAL +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java new file mode 100644 index 0000000..779deff --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/exceptions/CalendarNotFoundException.java @@ -0,0 +1,7 @@ +package it.fabioformosa.quartzmanager.api.exceptions; + +public class CalendarNotFoundException extends RuntimeException { + public CalendarNotFoundException(String name) { + super("Calendar " + name + " not found!"); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java new file mode 100644 index 0000000..5202dab --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/CalendarService.java @@ -0,0 +1,233 @@ +package it.fabioformosa.quartzmanager.api.services; + +import it.fabioformosa.quartzmanager.api.dto.CalendarDTO; +import it.fabioformosa.quartzmanager.api.dto.CalendarIncludedTimeDTO; +import it.fabioformosa.quartzmanager.api.dto.CalendarType; +import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; +import it.fabioformosa.quartzmanager.api.exceptions.CalendarNotFoundException; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; +import org.quartz.Calendar; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.calendar.AnnualCalendar; +import org.quartz.impl.calendar.CronCalendar; +import org.quartz.impl.calendar.DailyCalendar; +import org.quartz.impl.calendar.HolidayCalendar; +import org.quartz.impl.calendar.MonthlyCalendar; +import org.quartz.impl.calendar.WeeklyCalendar; +import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.TimeZone; +import java.util.TreeSet; + +@Service +public class CalendarService { + + private final Scheduler scheduler; + + public CalendarService(@Qualifier("quartzManagerScheduler") Scheduler scheduler) { + this.scheduler = scheduler; + } + + public List fetchCalendars() throws SchedulerException { + return scheduler.getCalendarNames().stream() + .map(this::getCalendarUnchecked) + .toList(); + } + + public CalendarDTO getCalendar(String name) throws SchedulerException { + Calendar calendar = scheduler.getCalendar(name); + if (calendar == null) + throw new CalendarNotFoundException(name); + return toDTO(name, calendar); + } + + public CalendarDTO addCalendar(String name, CalendarDTO calendarDTO) throws SchedulerException, ParseException { + if (scheduler.getCalendar(name) != null) + throw new ResourceConflictException("Calendar " + name + " already exists"); + Calendar calendar = buildCalendar(calendarDTO); + scheduler.addCalendar(name, calendar, false, false); + return toDTO(name, calendar); + } + + public CalendarDTO updateCalendar(String name, CalendarDTO calendarDTO) throws SchedulerException, ParseException { + if (scheduler.getCalendar(name) == null) + throw new CalendarNotFoundException(name); + Calendar calendar = buildCalendar(calendarDTO); + scheduler.addCalendar(name, calendar, true, true); + return toDTO(name, calendar); + } + + public void deleteCalendar(String name) throws SchedulerException { + if (!scheduler.deleteCalendar(name)) + throw new CalendarNotFoundException(name); + } + + public CalendarIncludedTimeDTO testIncludedTime(String name, CalendarIncludedTimeDTO input) throws SchedulerException { + Calendar calendar = scheduler.getCalendar(name); + if (calendar == null) + throw new CalendarNotFoundException(name); + long timestamp = input.getTime().getTime(); + return CalendarIncludedTimeDTO.builder() + .time(input.getTime()) + .included(calendar.isTimeIncluded(timestamp)) + .nextIncludedTime(new Date(calendar.getNextIncludedTime(timestamp))) + .build(); + } + + private CalendarDTO getCalendarUnchecked(String name) { + try { + return getCalendar(name); + } catch (SchedulerException ex) { + throw new IllegalStateException(ex); + } + } + + private Calendar buildCalendar(CalendarDTO calendarDTO) throws ParseException { + Calendar calendar = switch (calendarDTO.getType()) { + case ANNUAL -> buildAnnualCalendar(calendarDTO); + case CRON -> buildCronCalendar(calendarDTO); + case DAILY -> buildDailyCalendar(calendarDTO); + case HOLIDAY -> buildHolidayCalendar(calendarDTO); + case MONTHLY -> buildMonthlyCalendar(calendarDTO); + case WEEKLY -> buildWeeklyCalendar(calendarDTO); + }; + calendar.setDescription(calendarDTO.getDescription()); + return calendar; + } + + private AnnualCalendar buildAnnualCalendar(CalendarDTO calendarDTO) { + AnnualCalendar calendar = new AnnualCalendar(); + for (Date excludedDate : calendarDTO.getExcludedDatesOrEmpty()) { + java.util.Calendar excludedDay = java.util.Calendar.getInstance(); + excludedDay.setTime(excludedDate); + calendar.setDayExcluded(excludedDay, true); + } + return calendar; + } + + private CronCalendar buildCronCalendar(CalendarDTO calendarDTO) throws ParseException { + CronCalendar calendar = new CronCalendar(calendarDTO.getCronExpression()); + if (calendarDTO.getTimeZone() != null && !calendarDTO.getTimeZone().isBlank()) + calendar.setTimeZone(TimeZone.getTimeZone(calendarDTO.getTimeZone())); + return calendar; + } + + private DailyCalendar buildDailyCalendar(CalendarDTO calendarDTO) { + DailyCalendar calendar = new DailyCalendar(calendarDTO.getRangeStartingTime(), calendarDTO.getRangeEndingTime()); + calendar.setInvertTimeRange(Boolean.TRUE.equals(calendarDTO.getInvertTimeRange())); + return calendar; + } + + private HolidayCalendar buildHolidayCalendar(CalendarDTO calendarDTO) { + HolidayCalendar calendar = new HolidayCalendar(); + for (Date excludedDate : calendarDTO.getExcludedDatesOrEmpty()) + calendar.addExcludedDate(excludedDate); + return calendar; + } + + private MonthlyCalendar buildMonthlyCalendar(CalendarDTO calendarDTO) { + MonthlyCalendar calendar = new MonthlyCalendar(); + for (Integer day : calendarDTO.getExcludedDaysOfMonthOrEmpty()) + calendar.setDayExcluded(day, true); + return calendar; + } + + private WeeklyCalendar buildWeeklyCalendar(CalendarDTO calendarDTO) { + WeeklyCalendar calendar = new WeeklyCalendar(); + for (Integer day : calendarDTO.getExcludedDaysOfWeekOrEmpty()) + calendar.setDayExcluded(day, true); + return calendar; + } + + private CalendarDTO toDTO(String name, Calendar calendar) throws SchedulerException { + CalendarDTO calendarDTO = CalendarDTO.builder() + .name(name) + .description(calendar.getDescription()) + .triggerKeys(findTriggerKeys(name)) + .build(); + + if (calendar instanceof AnnualCalendar annualCalendar) + enrichAnnualCalendar(calendarDTO, annualCalendar); + else if (calendar instanceof CronCalendar cronCalendar) + enrichCronCalendar(calendarDTO, cronCalendar); + else if (calendar instanceof DailyCalendar dailyCalendar) + enrichDailyCalendar(calendarDTO, dailyCalendar); + else if (calendar instanceof HolidayCalendar holidayCalendar) + enrichHolidayCalendar(calendarDTO, holidayCalendar); + else if (calendar instanceof MonthlyCalendar monthlyCalendar) + enrichMonthlyCalendar(calendarDTO, monthlyCalendar); + else if (calendar instanceof WeeklyCalendar weeklyCalendar) + enrichWeeklyCalendar(calendarDTO, weeklyCalendar); + return calendarDTO; + } + + private void enrichAnnualCalendar(CalendarDTO calendarDTO, AnnualCalendar calendar) { + calendarDTO.setType(CalendarType.ANNUAL); + calendarDTO.setExcludedDates(calendar.getDaysExcluded().stream().map(java.util.Calendar::getTime).toList()); + } + + private void enrichCronCalendar(CalendarDTO calendarDTO, CronCalendar calendar) { + calendarDTO.setType(CalendarType.CRON); + calendarDTO.setCronExpression(calendar.getCronExpression().getCronExpression()); + calendarDTO.setTimeZone(calendar.getTimeZone().getID()); + } + + private void enrichDailyCalendar(CalendarDTO calendarDTO, DailyCalendar calendar) { + calendarDTO.setType(CalendarType.DAILY); + long now = System.currentTimeMillis(); + calendarDTO.setRangeStartingTime(formatTime(calendar.getTimeRangeStartingTimeInMillis(now))); + calendarDTO.setRangeEndingTime(formatTime(calendar.getTimeRangeEndingTimeInMillis(now))); + calendarDTO.setInvertTimeRange(calendar.getInvertTimeRange()); + } + + private void enrichHolidayCalendar(CalendarDTO calendarDTO, HolidayCalendar calendar) { + calendarDTO.setType(CalendarType.HOLIDAY); + calendarDTO.setExcludedDates(new ArrayList<>(calendar.getExcludedDates())); + } + + private void enrichMonthlyCalendar(CalendarDTO calendarDTO, MonthlyCalendar calendar) { + calendarDTO.setType(CalendarType.MONTHLY); + Set excludedDays = new TreeSet<>(); + for (int day = 1; day <= 31; day++) { + if (calendar.isDayExcluded(day)) + excludedDays.add(day); + } + calendarDTO.setExcludedDaysOfMonth(excludedDays); + } + + private void enrichWeeklyCalendar(CalendarDTO calendarDTO, WeeklyCalendar calendar) { + calendarDTO.setType(CalendarType.WEEKLY); + Set excludedDays = new TreeSet<>(); + for (int day = java.util.Calendar.SUNDAY; day <= java.util.Calendar.SATURDAY; day++) { + if (calendar.isDayExcluded(day)) + excludedDays.add(day); + } + calendarDTO.setExcludedDaysOfWeek(excludedDays); + } + + private List findTriggerKeys(String calendarName) throws SchedulerException { + List triggerKeys = new ArrayList<>(); + for (TriggerKey triggerKey : scheduler.getTriggerKeys(GroupMatcher.anyTriggerGroup())) { + Trigger trigger = scheduler.getTrigger(triggerKey); + if (trigger != null && calendarName.equals(trigger.getCalendarName())) + triggerKeys.add(TriggerKeyDTO.builder().name(triggerKey.getName()).group(triggerKey.getGroup()).build()); + } + return triggerKeys; + } + + private String formatTime(long timeInMillis) { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.setTimeInMillis(timeInMillis); + return String.format("%02d:%02d:%02d", calendar.get(java.util.Calendar.HOUR_OF_DAY), calendar.get(java.util.Calendar.MINUTE), calendar.get(java.util.Calendar.SECOND)); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java index 272ceed..b5400af 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/services/TriggerService.java @@ -1,25 +1,51 @@ package it.fabioformosa.quartzmanager.api.services; +import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; +import it.fabioformosa.quartzmanager.api.dto.MisfireInstruction; import it.fabioformosa.quartzmanager.api.dto.TriggerDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerInputDTO; import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerType; +import it.fabioformosa.quartzmanager.api.exceptions.ResourceConflictException; import it.fabioformosa.quartzmanager.api.exceptions.TriggerNotFoundException; +import org.quartz.CalendarIntervalScheduleBuilder; +import org.quartz.CalendarIntervalTrigger; +import org.quartz.CronScheduleBuilder; +import org.quartz.CronTrigger; +import org.quartz.DailyTimeIntervalScheduleBuilder; +import org.quartz.DailyTimeIntervalTrigger; +import org.quartz.DateBuilder; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDataMap; +import org.quartz.JobDetail; +import org.quartz.JobKey; +import org.quartz.ScheduleBuilder; import org.quartz.Scheduler; import org.quartz.SchedulerException; +import org.quartz.SimpleScheduleBuilder; +import org.quartz.SimpleTrigger; +import org.quartz.TimeOfDay; import org.quartz.Trigger; +import org.quartz.TriggerBuilder; import org.quartz.TriggerKey; import org.quartz.impl.matchers.GroupMatcher; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.convert.ConversionService; import org.springframework.stereotype.Service; +import java.text.ParseException; import java.util.List; import java.util.Set; +import java.util.TimeZone; @Service public class TriggerService { - private Scheduler scheduler; - private ConversionService conversionService; + private static final int DEFAULT_PRIORITY = Trigger.DEFAULT_PRIORITY; + + private final Scheduler scheduler; + private final ConversionService conversionService; public TriggerService(@Qualifier("quartzManagerScheduler") Scheduler scheduler, ConversionService conversionService) { this.scheduler = scheduler; @@ -38,11 +64,47 @@ public class TriggerService { Trigger trigger = scheduler.getTrigger(triggerKey); if (trigger == null) throw new TriggerNotFoundException(group, name); - TriggerDTO triggerDTO = conversionService.convert(trigger, TriggerDTO.class); + TriggerDTO triggerDTO = convertTrigger(trigger); triggerDTO.setState(scheduler.getTriggerState(triggerKey).name()); return triggerDTO; } + public TriggerDTO scheduleTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws SchedulerException, ClassNotFoundException, ParseException { + TriggerKey triggerKey = TriggerKey.triggerKey(name, group); + if (scheduler.checkExists(triggerKey)) + throw new ResourceConflictException("Trigger " + triggerKey + " already exists"); + + Trigger newTrigger = buildTrigger(group, name, triggerInputDTO); + JobKey jobKey = getJobKey(triggerInputDTO); + if (jobKey != null) { + if (!scheduler.checkExists(jobKey)) + throw new ResourceConflictException("Job " + jobKey + " does not exist"); + scheduler.scheduleJob(newTrigger.getTriggerBuilder().forJob(jobKey).build()); + } + else { + JobDetail jobDetail = JobBuilder.newJob() + .ofType(Class.forName(triggerInputDTO.getJobClass()).asSubclass(Job.class)) + .storeDurably(false) + .build(); + scheduler.scheduleJob(jobDetail, newTrigger); + } + + return convertTrigger(newTrigger); + } + + public TriggerDTO rescheduleTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws SchedulerException, TriggerNotFoundException, ParseException { + TriggerKey triggerKey = TriggerKey.triggerKey(name, group); + Trigger existingTrigger = scheduler.getTrigger(triggerKey); + if (existingTrigger == null) + throw new TriggerNotFoundException(group, name); + + Trigger newTrigger = buildTrigger(group, name, triggerInputDTO).getTriggerBuilder() + .forJob(existingTrigger.getJobKey()) + .build(); + scheduler.rescheduleJob(triggerKey, newTrigger); + return convertTrigger(newTrigger); + } + public void pauseTrigger(String group, String name) throws SchedulerException, TriggerNotFoundException { TriggerKey triggerKey = requireTrigger(group, name); scheduler.pauseTrigger(triggerKey); @@ -65,4 +127,183 @@ public class TriggerService { return triggerKey; } + private Trigger buildTrigger(String group, String name, TriggerInputDTO triggerInputDTO) throws ParseException { + TriggerBuilder triggerBuilder = TriggerBuilder.newTrigger() + .withIdentity(name, group) + .withPriority(triggerInputDTO.getPriority() == null ? DEFAULT_PRIORITY : triggerInputDTO.getPriority()); + + if (triggerInputDTO.getStartDate() != null) + triggerBuilder.startAt(triggerInputDTO.getStartDate()); + if (triggerInputDTO.getEndDate() != null) + triggerBuilder.endAt(triggerInputDTO.getEndDate()); + if (triggerInputDTO.getDescription() != null) + triggerBuilder.withDescription(triggerInputDTO.getDescription()); + if (triggerInputDTO.getCalendarName() != null && !triggerInputDTO.getCalendarName().isBlank()) + triggerBuilder.modifiedByCalendar(triggerInputDTO.getCalendarName()); + if (triggerInputDTO.getJobDataMap() != null) + triggerBuilder.usingJobData(new JobDataMap(triggerInputDTO.getJobDataMap())); + + return triggerBuilder.withSchedule(buildSchedule(triggerInputDTO)).build(); + } + + private ScheduleBuilder buildSchedule(TriggerInputDTO triggerInputDTO) throws ParseException { + TriggerType triggerType = triggerInputDTO.getTriggerType() == null ? TriggerType.SIMPLE : triggerInputDTO.getTriggerType(); + return switch (triggerType) { + case SIMPLE -> buildSimpleSchedule(triggerInputDTO); + case CRON -> buildCronSchedule(triggerInputDTO); + case DAILY_TIME_INTERVAL -> buildDailyTimeIntervalSchedule(triggerInputDTO); + case CALENDAR_INTERVAL -> buildCalendarIntervalSchedule(triggerInputDTO); + }; + } + + private SimpleScheduleBuilder buildSimpleSchedule(TriggerInputDTO triggerInputDTO) { + SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule(); + if (triggerInputDTO.getRepeatInterval() != null) + scheduleBuilder.withIntervalInMilliseconds(triggerInputDTO.getRepeatInterval()); + if (triggerInputDTO.getRepeatCount() != null) + scheduleBuilder.withRepeatCount(triggerInputDTO.getRepeatCount()); + + MisfireInstruction misfireInstruction = parseSimpleMisfireInstruction(triggerInputDTO.getMisfireInstruction()); + switch (misfireInstruction) { + case MISFIRE_INSTRUCTION_FIRE_NOW -> scheduleBuilder.withMisfireHandlingInstructionFireNow(); + case MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNowWithExistingCount(); + case MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_REMAINING_REPEAT_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNowWithRemainingCount(); + case MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNextWithRemainingCount(); + case MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_EXISTING_COUNT -> scheduleBuilder.withMisfireHandlingInstructionNextWithExistingCount(); + } + return scheduleBuilder; + } + + private CronScheduleBuilder buildCronSchedule(TriggerInputDTO triggerInputDTO) throws ParseException { + CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronScheduleNonvalidatedExpression(triggerInputDTO.getCronExpression()); + if (triggerInputDTO.getTimeZone() != null && !triggerInputDTO.getTimeZone().isBlank()) + scheduleBuilder.inTimeZone(TimeZone.getTimeZone(triggerInputDTO.getTimeZone())); + return applyCronMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction()); + } + + private DailyTimeIntervalScheduleBuilder buildDailyTimeIntervalSchedule(TriggerInputDTO triggerInputDTO) { + DailyTimeIntervalScheduleBuilder scheduleBuilder = DailyTimeIntervalScheduleBuilder.dailyTimeIntervalSchedule() + .withInterval(Math.toIntExact(triggerInputDTO.getRepeatInterval()), parseIntervalUnit(triggerInputDTO.getRepeatIntervalUnit(), DateBuilder.IntervalUnit.MINUTE)); + if (triggerInputDTO.getStartTimeOfDay() != null && !triggerInputDTO.getStartTimeOfDay().isBlank()) + scheduleBuilder.startingDailyAt(parseTimeOfDay(triggerInputDTO.getStartTimeOfDay())); + if (triggerInputDTO.getEndTimeOfDay() != null && !triggerInputDTO.getEndTimeOfDay().isBlank()) + scheduleBuilder.endingDailyAt(parseTimeOfDay(triggerInputDTO.getEndTimeOfDay())); + if (triggerInputDTO.getDaysOfWeek() != null && !triggerInputDTO.getDaysOfWeek().isEmpty()) + scheduleBuilder.onDaysOfTheWeek(triggerInputDTO.getDaysOfWeek()); + return applyDailyMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction()); + } + + private CalendarIntervalScheduleBuilder buildCalendarIntervalSchedule(TriggerInputDTO triggerInputDTO) { + CalendarIntervalScheduleBuilder scheduleBuilder = CalendarIntervalScheduleBuilder.calendarIntervalSchedule() + .withInterval(Math.toIntExact(triggerInputDTO.getRepeatInterval()), parseIntervalUnit(triggerInputDTO.getRepeatIntervalUnit(), DateBuilder.IntervalUnit.DAY)); + if (Boolean.TRUE.equals(triggerInputDTO.getPreserveHourOfDayAcrossDaylightSavings())) + scheduleBuilder.preserveHourOfDayAcrossDaylightSavings(true); + if (Boolean.TRUE.equals(triggerInputDTO.getSkipDayIfHourDoesNotExist())) + scheduleBuilder.skipDayIfHourDoesNotExist(true); + if (triggerInputDTO.getTimeZone() != null && !triggerInputDTO.getTimeZone().isBlank()) + scheduleBuilder.inTimeZone(TimeZone.getTimeZone(triggerInputDTO.getTimeZone())); + return applyCalendarIntervalMisfireInstruction(scheduleBuilder, triggerInputDTO.getMisfireInstruction()); + } + + private JobKey getJobKey(TriggerInputDTO triggerInputDTO) { + JobKeyDTO jobKeyDTO = triggerInputDTO.getJobKey(); + if (jobKeyDTO == null) + return null; + return JobKey.jobKey(jobKeyDTO.getName(), jobKeyDTO.getGroup()); + } + + private TriggerDTO convertTrigger(Trigger trigger) { + TriggerDTO triggerDTO = conversionService.convert(trigger, TriggerDTO.class); + if (triggerDTO == null) + triggerDTO = new TriggerDTO(); + if (trigger instanceof SimpleTrigger simpleTrigger) + enrichSimpleTrigger(triggerDTO, simpleTrigger); + else if (trigger instanceof CronTrigger cronTrigger) + enrichCronTrigger(triggerDTO, cronTrigger); + else if (trigger instanceof DailyTimeIntervalTrigger dailyTimeIntervalTrigger) + enrichDailyTimeIntervalTrigger(triggerDTO, dailyTimeIntervalTrigger); + else if (trigger instanceof CalendarIntervalTrigger calendarIntervalTrigger) + enrichCalendarIntervalTrigger(triggerDTO, calendarIntervalTrigger); + return triggerDTO; + } + + private void enrichSimpleTrigger(TriggerDTO triggerDTO, SimpleTrigger simpleTrigger) { + triggerDTO.setRepeatCount(simpleTrigger.getRepeatCount()); + triggerDTO.setRepeatInterval(simpleTrigger.getRepeatInterval()); + } + + private void enrichCronTrigger(TriggerDTO triggerDTO, CronTrigger cronTrigger) { + triggerDTO.setCronExpression(cronTrigger.getCronExpression()); + triggerDTO.setTimeZone(cronTrigger.getTimeZone().getID()); + } + + private void enrichDailyTimeIntervalTrigger(TriggerDTO triggerDTO, DailyTimeIntervalTrigger dailyTrigger) { + triggerDTO.setRepeatCount(dailyTrigger.getRepeatCount()); + triggerDTO.setRepeatInterval((long) dailyTrigger.getRepeatInterval()); + triggerDTO.setRepeatIntervalUnit(dailyTrigger.getRepeatIntervalUnit().name()); + triggerDTO.setStartTimeOfDay(formatTimeOfDay(dailyTrigger.getStartTimeOfDay())); + triggerDTO.setEndTimeOfDay(formatTimeOfDay(dailyTrigger.getEndTimeOfDay())); + triggerDTO.setDaysOfWeek(dailyTrigger.getDaysOfWeek()); + } + + private void enrichCalendarIntervalTrigger(TriggerDTO triggerDTO, CalendarIntervalTrigger calendarTrigger) { + triggerDTO.setRepeatInterval((long) calendarTrigger.getRepeatInterval()); + triggerDTO.setRepeatIntervalUnit(calendarTrigger.getRepeatIntervalUnit().name()); + triggerDTO.setPreserveHourOfDayAcrossDaylightSavings(calendarTrigger.isPreserveHourOfDayAcrossDaylightSavings()); + triggerDTO.setSkipDayIfHourDoesNotExist(calendarTrigger.isSkipDayIfHourDoesNotExist()); + triggerDTO.setTimeZone(calendarTrigger.getTimeZone().getID()); + } + + private MisfireInstruction parseSimpleMisfireInstruction(String misfireInstruction) { + if (misfireInstruction == null || misfireInstruction.isBlank()) + return MisfireInstruction.MISFIRE_INSTRUCTION_RESCHEDULE_NOW_WITH_EXISTING_REPEAT_COUNT; + return MisfireInstruction.valueOf(misfireInstruction); + } + + private CronScheduleBuilder applyCronMisfireInstruction(CronScheduleBuilder scheduleBuilder, String misfireInstruction) { + return switch (normalizeMisfireInstruction(misfireInstruction)) { + case "DO_NOTHING" -> scheduleBuilder.withMisfireHandlingInstructionDoNothing(); + case "IGNORE_MISFIRES" -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires(); + default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed(); + }; + } + + private DailyTimeIntervalScheduleBuilder applyDailyMisfireInstruction(DailyTimeIntervalScheduleBuilder scheduleBuilder, String misfireInstruction) { + return switch (normalizeMisfireInstruction(misfireInstruction)) { + case "DO_NOTHING" -> scheduleBuilder.withMisfireHandlingInstructionDoNothing(); + case "IGNORE_MISFIRES" -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires(); + default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed(); + }; + } + + private CalendarIntervalScheduleBuilder applyCalendarIntervalMisfireInstruction(CalendarIntervalScheduleBuilder scheduleBuilder, String misfireInstruction) { + return switch (normalizeMisfireInstruction(misfireInstruction)) { + case "DO_NOTHING" -> scheduleBuilder.withMisfireHandlingInstructionDoNothing(); + case "IGNORE_MISFIRES" -> scheduleBuilder.withMisfireHandlingInstructionIgnoreMisfires(); + default -> scheduleBuilder.withMisfireHandlingInstructionFireAndProceed(); + }; + } + + private String normalizeMisfireInstruction(String misfireInstruction) { + return misfireInstruction == null || misfireInstruction.isBlank() ? "FIRE_AND_PROCEED" : misfireInstruction; + } + + private DateBuilder.IntervalUnit parseIntervalUnit(String intervalUnit, DateBuilder.IntervalUnit defaultUnit) { + return intervalUnit == null || intervalUnit.isBlank() ? defaultUnit : DateBuilder.IntervalUnit.valueOf(intervalUnit); + } + + private TimeOfDay parseTimeOfDay(String timeOfDay) { + String[] parts = timeOfDay.split(":"); + int hour = Integer.parseInt(parts[0]); + int minute = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int second = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + return TimeOfDay.hourMinuteAndSecondOfDay(hour, minute, second); + } + + private String formatTimeOfDay(TimeOfDay timeOfDay) { + if (timeOfDay == null) + return null; + return String.format("%02d:%02d:%02d", timeOfDay.getHour(), timeOfDay.getMinute(), timeOfDay.getSecond()); + } + } diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java new file mode 100644 index 0000000..c3be0e1 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/JobTargetDTO.java @@ -0,0 +1,8 @@ +package it.fabioformosa.quartzmanager.api.validators; + +import it.fabioformosa.quartzmanager.api.dto.JobKeyDTO; + +public interface JobTargetDTO { + String getJobClass(); + JobKeyDTO getJobKey(); +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java new file mode 100644 index 0000000..9aa5bd5 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTarget.java @@ -0,0 +1,18 @@ +package it.fabioformosa.quartzmanager.api.validators; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = ValidJobTargetValidator.class) +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidJobTarget { + String message() default "Either jobClass or jobKey must be set"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java new file mode 100644 index 0000000..85efa97 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/main/java/it/fabioformosa/quartzmanager/api/validators/ValidJobTargetValidator.java @@ -0,0 +1,15 @@ +package it.fabioformosa.quartzmanager.api.validators; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidJobTargetValidator implements ConstraintValidator { + @Override + public boolean isValid(JobTargetDTO jobTargetDTO, ConstraintValidatorContext constraintValidatorContext) { + if (jobTargetDTO == null) + return true; + boolean hasJobClass = jobTargetDTO.getJobClass() != null && !jobTargetDTO.getJobClass().isBlank(); + boolean hasJobKey = jobTargetDTO.getJobKey() != null && jobTargetDTO.getJobKey().getName() != null && !jobTargetDTO.getJobKey().getName().isBlank(); + return hasJobClass || hasJobKey; + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java new file mode 100644 index 0000000..ba4f3c2 --- /dev/null +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/CalendarControllerTest.java @@ -0,0 +1,111 @@ +package it.fabioformosa.quartzmanager.api.controllers; + +import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests; +import it.fabioformosa.quartzmanager.api.controllers.utils.TestUtils; +import it.fabioformosa.quartzmanager.api.dto.CalendarDTO; +import it.fabioformosa.quartzmanager.api.dto.CalendarIncludedTimeDTO; +import it.fabioformosa.quartzmanager.api.dto.CalendarType; +import it.fabioformosa.quartzmanager.api.services.CalendarService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.Date; +import java.util.List; +import java.util.Set; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.mockito.ArgumentMatchers.any; + +@ContextConfiguration(classes = {QuartManagerApplicationTests.class}) +@WebMvcTest(controllers = CalendarController.class, properties = { + "quartz-manager.jobClassPackages=it.fabioformosa.quartzmanager.jobs" +}) +class CalendarControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CalendarService calendarService; + + @AfterEach + void cleanUp(){ + Mockito.reset(calendarService); + } + + @Test + void whenListCalendarsIsCalled_thenCalendarsAreReturned() throws Exception { + List calendars = List.of(CalendarDTO.builder().name("weekends").type(CalendarType.WEEKLY).excludedDaysOfWeek(Set.of(1, 7)).build()); + Mockito.when(calendarService.fetchCalendars()).thenReturn(calendars); + + mockMvc.perform(get(CalendarController.CALENDAR_CONTROLLER_BASE_URL).contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendars))); + } + + @Test + void whenGetCalendarIsCalled_thenCalendarIsReturned() throws Exception { + CalendarDTO calendarDTO = CalendarDTO.builder().name("cron").type(CalendarType.CRON).cronExpression("0 0 0 ? * SAT,SUN").build(); + Mockito.when(calendarService.getCalendar("cron")).thenReturn(calendarDTO); + + mockMvc.perform(get(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/cron").contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendarDTO))); + } + + @Test + void givenACalendarDTO_whenPosted_thenCalendarIsCreated() throws Exception { + CalendarDTO calendarDTO = CalendarDTO.builder().name("holidays").type(CalendarType.HOLIDAY).excludedDates(List.of(new Date())).build(); + Mockito.when(calendarService.addCalendar(Mockito.eq("holidays"), any())).thenReturn(calendarDTO); + + mockMvc.perform(post(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/holidays") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(calendarDTO))) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendarDTO))); + } + + @Test + void givenACalendarDTO_whenPut_thenCalendarIsUpdated() throws Exception { + CalendarDTO calendarDTO = CalendarDTO.builder().name("month-end").type(CalendarType.MONTHLY).excludedDaysOfMonth(Set.of(31)).build(); + Mockito.when(calendarService.updateCalendar("month-end", calendarDTO)).thenReturn(calendarDTO); + + mockMvc.perform(put(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/month-end") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(calendarDTO))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(calendarDTO))); + } + + @Test + void whenDeleteCalendarIsCalled_thenNoContentIsReturned() throws Exception { + mockMvc.perform(delete(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/weekends").contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().isNoContent()); + + Mockito.verify(calendarService).deleteCalendar("weekends"); + } + + @Test + void whenIncludedTimeIsTested_thenResultIsReturned() throws Exception { + CalendarIncludedTimeDTO input = CalendarIncludedTimeDTO.builder().time(new Date()).build(); + CalendarIncludedTimeDTO result = CalendarIncludedTimeDTO.builder().time(input.getTime()).included(true).nextIncludedTime(input.getTime()).build(); + Mockito.when(calendarService.testIncludedTime(Mockito.eq("weekends"), any())).thenReturn(result); + + mockMvc.perform(post(CalendarController.CALENDAR_CONTROLLER_BASE_URL + "/weekends/included-time-test") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(input))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(result))); + } +} diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java index 27eec80..4661650 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/controllers/TriggerControllerTest.java @@ -3,7 +3,9 @@ package it.fabioformosa.quartzmanager.api.controllers; import it.fabioformosa.quartzmanager.api.QuartManagerApplicationTests; import it.fabioformosa.quartzmanager.api.controllers.utils.TestUtils; import it.fabioformosa.quartzmanager.api.dto.TriggerDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerInputDTO; import it.fabioformosa.quartzmanager.api.dto.TriggerKeyDTO; +import it.fabioformosa.quartzmanager.api.dto.TriggerType; import it.fabioformosa.quartzmanager.api.services.TriggerService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -21,6 +23,7 @@ import java.util.List; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; @ContextConfiguration(classes = {QuartManagerApplicationTests.class}) @WebMvcTest(controllers = TriggerController.class, properties = { @@ -63,6 +66,49 @@ class TriggerControllerTest { .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerDTO))); } + @Test + void givenATriggerInputDTO_whenPosted_thenTriggerIsCreated() throws Exception { + TriggerInputDTO triggerInputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.CRON) + .jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob") + .cronExpression("0 0/5 * * * ?") + .misfireInstruction("FIRE_AND_PROCEED") + .build(); + TriggerDTO triggerDTO = TriggerDTO.builder() + .triggerKeyDTO(TriggerKeyDTO.builder().name("cronTrigger").group("DEFAULT").build()) + .type("CronTriggerImpl") + .build(); + Mockito.when(triggerService.scheduleTrigger("DEFAULT", "cronTrigger", triggerInputDTO)).thenReturn(triggerDTO); + + mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/cronTrigger") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(triggerInputDTO))) + .andExpect(MockMvcResultMatchers.status().isCreated()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerDTO))); + } + + @Test + void givenATriggerInputDTO_whenPut_thenTriggerIsRescheduled() throws Exception { + TriggerInputDTO triggerInputDTO = TriggerInputDTO.builder() + .triggerType(TriggerType.CALENDAR_INTERVAL) + .jobClass("it.fabioformosa.quartzmanager.api.jobs.SampleJob") + .repeatInterval(2L) + .repeatIntervalUnit("DAY") + .misfireInstruction("DO_NOTHING") + .build(); + TriggerDTO triggerDTO = TriggerDTO.builder() + .triggerKeyDTO(TriggerKeyDTO.builder().name("calendarTrigger").group("DEFAULT").build()) + .type("CalendarIntervalTriggerImpl") + .build(); + Mockito.when(triggerService.rescheduleTrigger("DEFAULT", "calendarTrigger", triggerInputDTO)).thenReturn(triggerDTO); + + mockMvc.perform(put(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/calendarTrigger") + .contentType(MediaType.APPLICATION_JSON) + .content(TestUtils.toJson(triggerInputDTO))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.content().json(TestUtils.toJson(triggerDTO))); + } + @Test void whenPauseTriggerIsCalled_thenNoContentIsReturned() throws Exception { mockMvc.perform(post(TriggerController.TRIGGER_CONTROLLER_BASE_URL + "/DEFAULT/sampleTrigger/pause").contentType(MediaType.APPLICATION_JSON)) diff --git a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java index 5b0d4ff..167d993 100644 --- a/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java +++ b/quartz-manager-parent/quartz-manager-starter-api/src/test/java/it/fabioformosa/quartzmanager/api/services/SimpleTriggerServiceTest.java @@ -72,7 +72,7 @@ class SimpleTriggerServiceTest { SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder() .startTime(triggerInputDTO.getStartDate()) - .repeatInterval(1000) + .repeatInterval(1000L) .repeatCount(10) .mayFireAgain(true) .finalFireTime(triggerInputDTO.getEndDate()) @@ -110,7 +110,7 @@ class SimpleTriggerServiceTest { SimpleTriggerDTO expectedTriggerDTO = SimpleTriggerDTO.builder() .startTime(triggerInputDTO.getStartDate()) - .repeatInterval(1000) + .repeatInterval(1000L) .repeatCount(10) .mayFireAgain(true) .finalFireTime(triggerInputDTO.getEndDate())