fix(schedule): fix for custom cron (#699)

* fix: added cronExpression field and fixed formatting

* fix: modified the test.ts file #699

* added additional validation

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Waleed Latif <walif6@gmail.com>
This commit is contained in:
Adam Gough
2025-07-15 20:42:34 -07:00
committed by GitHub
parent b13f339327
commit 19ca9c78b4
3 changed files with 146 additions and 8 deletions

View File

@@ -10,6 +10,7 @@ import {
generateCronExpression,
getScheduleTimeValues,
getSubBlockValue,
validateCronExpression,
} from '@/lib/schedules/utils'
import { db } from '@/db'
import { workflowSchedule } from '@/db/schema'
@@ -192,6 +193,18 @@ export async function POST(req: NextRequest) {
cronExpression = generateCronExpression(defaultScheduleType, scheduleValues)
// Additional validation for custom cron expressions
if (defaultScheduleType === 'custom' && cronExpression) {
const validation = validateCronExpression(cronExpression)
if (!validation.isValid) {
logger.error(`[${requestId}] Invalid cron expression: ${validation.error}`)
return NextResponse.json(
{ error: `Invalid cron expression: ${validation.error}` },
{ status: 400 }
)
}
}
nextRunAt = calculateNextRunTime(defaultScheduleType, scheduleValues)
logger.debug(

View File

@@ -11,6 +11,7 @@ import {
getSubBlockValue,
parseCronToHumanReadable,
parseTimeString,
validateCronExpression,
} from '@/lib/schedules/utils'
describe('Schedule Utilities', () => {
@@ -102,6 +103,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [12, 0],
monthlyDay: 15,
monthlyTime: [14, 30],
cronExpression: null,
})
})
@@ -127,6 +129,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0], // Default
monthlyDay: 1, // Default
monthlyTime: [9, 0], // Default
cronExpression: null,
})
})
})
@@ -143,6 +146,7 @@ describe('Schedule Utilities', () => {
monthlyDay: 15,
monthlyTime: [14, 30] as [number, number],
timezone: 'UTC',
cronExpression: null,
}
// Minutes (every 15 minutes)
@@ -196,6 +200,7 @@ describe('Schedule Utilities', () => {
monthlyDay: 15,
monthlyTime: [14, 30] as [number, number],
timezone: 'UTC',
cronExpression: null,
}
expect(generateCronExpression('minutes', standardScheduleValues)).toBe('*/15 * * * *')
@@ -230,6 +235,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -254,6 +260,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -275,6 +282,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('hourly', scheduleValues)
@@ -297,6 +305,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('daily', scheduleValues)
@@ -320,6 +329,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [10, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('weekly', scheduleValues)
@@ -342,6 +352,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 15,
monthlyTime: [14, 30] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('monthly', scheduleValues)
@@ -366,6 +377,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
// Last ran 10 minutes ago
@@ -393,6 +405,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -413,6 +426,7 @@ describe('Schedule Utilities', () => {
weeklyTime: [9, 0] as [number, number],
monthlyDay: 1,
monthlyTime: [9, 0] as [number, number],
cronExpression: null,
}
const nextRun = calculateNextRunTime('minutes', scheduleValues)
@@ -423,6 +437,50 @@ describe('Schedule Utilities', () => {
})
})
describe('validateCronExpression', () => {
it.concurrent('should validate correct cron expressions', () => {
expect(validateCronExpression('0 9 * * *')).toEqual({
isValid: true,
nextRun: expect.any(Date),
})
expect(validateCronExpression('*/15 * * * *')).toEqual({
isValid: true,
nextRun: expect.any(Date),
})
expect(validateCronExpression('30 14 15 * *')).toEqual({
isValid: true,
nextRun: expect.any(Date),
})
})
it.concurrent('should reject invalid cron expressions', () => {
expect(validateCronExpression('invalid')).toEqual({
isValid: false,
error: expect.stringContaining('invalid'),
})
expect(validateCronExpression('60 * * * *')).toEqual({
isValid: false,
error: expect.any(String),
})
expect(validateCronExpression('')).toEqual({
isValid: false,
error: 'Cron expression cannot be empty',
})
expect(validateCronExpression(' ')).toEqual({
isValid: false,
error: 'Cron expression cannot be empty',
})
})
it.concurrent('should detect impossible cron expressions', () => {
// This would be February 31st - impossible date
expect(validateCronExpression('0 0 31 2 *')).toEqual({
isValid: false,
error: 'Cron expression produces no future occurrences',
})
})
})
describe('parseCronToHumanReadable', () => {
it.concurrent('should parse common cron patterns', () => {
expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute')

View File

@@ -1,8 +1,49 @@
import { Cron } from 'croner'
import { createLogger } from '@/lib/logs/console-logger'
import { formatDateTime } from '@/lib/utils'
const logger = createLogger('ScheduleUtils')
/**
* Validates a cron expression and returns validation results
* @param cronExpression - The cron expression to validate
* @returns Validation result with isValid flag, error message, and next run date
*/
export function validateCronExpression(cronExpression: string): {
isValid: boolean
error?: string
nextRun?: Date
} {
if (!cronExpression?.trim()) {
return {
isValid: false,
error: 'Cron expression cannot be empty',
}
}
try {
const cron = new Cron(cronExpression)
const nextRun = cron.nextRun()
if (!nextRun) {
return {
isValid: false,
error: 'Cron expression produces no future occurrences',
}
}
return {
isValid: true,
nextRun,
}
} catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : 'Invalid cron expression syntax',
}
}
}
export interface SubBlockValue {
value: string
}
@@ -60,6 +101,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
weeklyTime: [number, number]
monthlyDay: number
monthlyTime: [number, number]
cronExpression: string | null
timezone: string
} {
// Extract schedule time (common field that can override others)
@@ -92,6 +134,16 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
const monthlyDay = Number.parseInt(monthlyDayStr) || 1
const monthlyTime = parseTimeString(getSubBlockValue(starterBlock, 'monthlyTime'))
const cronExpression = getSubBlockValue(starterBlock, 'cronExpression') || null
// Validate cron expression if provided
if (cronExpression) {
const validation = validateCronExpression(cronExpression)
if (!validation.isValid) {
throw new Error(`Invalid cron expression: ${validation.error}`)
}
}
return {
scheduleTime,
scheduleStartAt,
@@ -103,6 +155,7 @@ export function getScheduleTimeValues(starterBlock: BlockState): {
weeklyTime,
monthlyDay,
monthlyTime,
cronExpression,
}
}
@@ -242,14 +295,10 @@ export function generateCronExpression(
}
case 'custom': {
const cronExpression = getSubBlockValue(
scheduleValues as unknown as BlockState,
'cronExpression'
)
if (!cronExpression) {
throw new Error('No cron expression provided for custom schedule')
if (!scheduleValues.cronExpression?.trim()) {
throw new Error('Custom schedule requires a valid cron expression')
}
return cronExpression
return scheduleValues.cronExpression
}
default:
@@ -573,11 +622,29 @@ export const parseCronToHumanReadable = (cronExpression: string): string => {
'November',
'December',
]
if (month.includes(',')) {
const monthNames = month.split(',').map((m) => months[Number.parseInt(m, 10) - 1])
description += `on day ${dayOfMonth} of ${monthNames.join(', ')}`
} else if (month.includes('/')) {
// Handle interval patterns like */3, 1/3, etc.
const interval = month.split('/')[1]
description += `on day ${dayOfMonth} every ${interval} months`
} else if (month.includes('-')) {
// Handle range patterns like 1-6
const [start, end] = month.split('-').map((m) => Number.parseInt(m, 10))
const startMonth = months[start - 1]
const endMonth = months[end - 1]
description += `on day ${dayOfMonth} from ${startMonth} to ${endMonth}`
} else {
description += `on day ${dayOfMonth} of ${months[Number.parseInt(month, 10) - 1]}`
// Handle specific month numbers
const monthIndex = Number.parseInt(month, 10) - 1
const monthName = months[monthIndex]
if (monthName) {
description += `on day ${dayOfMonth} of ${monthName}`
} else {
description += `on day ${dayOfMonth} of month ${month}`
}
}
} else if (dayOfWeek !== '*') {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']