From 77e592502c655732550cd3431564a46a4b957d52 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Tue, 9 Dec 2025 00:09:11 +0530 Subject: [PATCH] fix: improve CRON schedule validation and display (#16360) Fixes multiple issues with CRON schedule input validation and execution time display. ### Issues Fixed 1. **UTC label placement** - Added "UTC" suffix to specific times (e.g., "at 09:30 UTC") but not to interval descriptions (e.g., "every hour") 2. **Upcoming execution time calculation** - Fixed incorrect execution times for malformed CRON expressions by implementing auto-correction ### Changes - Created `normalizeCronExpression` utility to standardize cron expressions before parsing - Updated `formatTime` to support optional UTC suffix - Enhanced `getHoursDescription` to append UTC to specific times - Added comprehensive test coverage (102 tests passing) ### Before - `"1 /3 * * *"` showed daily executions at same time (incorrect) - `"9 * * *"` showed same time repeated 3 times (incorrect) - No UTC labels on schedule descriptions (confusing) ### After - All malformed expressions auto-corrected and show correct execution times - UTC labels clearly indicate timezone for specific times - User-friendly error messages for truly invalid patterns Closes #15870 --- .../components/CronExpressionHelper.tsx | 29 +++++- .../WorkflowEditTriggerCronForm.tsx | 9 +- .../__tests__/cronstrueComparison.test.ts | 20 ++--- .../__tests__/describeCronExpression.test.ts | 50 ++++++----- .../__tests__/getHoursDescription.test.ts | 26 +++--- .../__tests__/parseCronExpression.test.ts | 88 +++++++++++++++++-- .../descriptors/getHoursDescription.ts | 42 +++++++-- ...alculateNextExecutionsForMinuteInterval.ts | 34 +++++++ .../utils/normalizeCronExpression.ts | 19 ++++ .../utils/normalizeWhitespace.ts | 3 + .../utils/parseCronExpression.ts | 11 ++- .../utils/format/__tests__/formatTime.test.ts | 67 +++++++++++--- .../src/utils/format/formatTime.ts | 26 ++++-- .../assert-version-can-be-activated.util.ts | 13 ++- .../workflow/schemas/cron-trigger-schema.ts | 2 +- 15 files changed, 350 insertions(+), 89 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/calculateNextExecutionsForMinuteInterval.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeCronExpression.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeWhitespace.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx index 0816fbf1bf..39b1d631f3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx @@ -1,7 +1,9 @@ import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; import { InputHint } from '@/ui/input/components/InputHint'; import type { WorkflowCronTrigger } from '@/workflow/types/Workflow'; +import { calculateNextExecutionsForMinuteInterval } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/calculateNextExecutionsForMinuteInterval'; import { convertScheduleToCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/convertScheduleToCronExpression'; +import { normalizeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeCronExpression'; import { getTriggerScheduleDescription } from '@/workflow/workflow-trigger/utils/getTriggerScheduleDescription'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; @@ -10,9 +12,27 @@ import { useRecoilValue } from 'recoil'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; -const getNextExecutions = (cronExpression: string): Date[] => { +const getNextExecutions = ( + cronExpression: string, + trigger?: WorkflowCronTrigger, +): Date[] => { try { - const interval = CronExpressionParser.parse(cronExpression, { + const normalized = normalizeCronExpression(cronExpression); + + /* For MINUTES type with interval > 30, calculate manually + because cron's N pattern resets at hour boundaries and doesn't + represent true continuous intervals for values > 30 + */ + if ( + trigger?.settings.type === 'MINUTES' && + trigger.settings.schedule.minute > 30 + ) { + return calculateNextExecutionsForMinuteInterval( + trigger.settings.schedule.minute, + ); + } + + const interval = CronExpressionParser.parse(normalized, { tz: 'UTC', }); return interval.take(3).map((date) => date.toDate()); @@ -93,7 +113,8 @@ export const CronExpressionHelper = ({ let errorMessage = ''; try { - CronExpressionParser.parse(cronExpression); + const normalized = normalizeCronExpression(cronExpression); + CronExpressionParser.parse(normalized); } catch (error) { isValid = false; errorMessage = error instanceof Error ? error.message : t`Unknown error`; @@ -109,7 +130,7 @@ export const CronExpressionHelper = ({ ); } - const nextExecutions = getNextExecutions(cronExpression); + const nextExecutions = getNextExecutions(cronExpression, trigger); return ( diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx index 6b7c19f6e4..cc71b0d79e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx @@ -423,6 +423,13 @@ export const WorkflowEditTriggerCronForm = ({ return; } + if (newMinute > 60) { + setErrorMessages({ + MINUTES: t`Minute value cannot exceed 60. For intervals greater than 60 minutes, use the "Hours" trigger type or a custom cron expression`, + }); + return; + } + setErrorMessages((prev) => ({ ...prev, MINUTES: undefined, @@ -439,7 +446,7 @@ export const WorkflowEditTriggerCronForm = ({ }, }); }} - placeholder={t`Enter number greater than 1`} + placeholder={t`Enter number between 1 and 60`} readonly={triggerOptions.readonly} /> { it('should handle "0 23 * * 1-5" like cronstrue', () => { // cronstrue: "At 11:00 PM, Monday through Friday" (but we use 24h format) expect(describeCronExpression('0 23 * * 1-5')).toBe( - 'at 23:00 on weekdays', + 'at 23:00 UTC on weekdays', ); }); it('should handle "0 23 * * *" like cronstrue', () => { // cronstrue: "At 11:00 PM, every day" (but we use 24h format and simpler wording) - expect(describeCronExpression('0 23 * * *')).toBe('at 23:00'); + expect(describeCronExpression('0 23 * * *')).toBe('at 23:00 UTC'); }); it('should handle "23 12 * * 0#2" like cronstrue', () => { // cronstrue: "At 12:23 PM, on the second Sunday of the month" (but we use 24h format) expect(describeCronExpression('23 12 * * 0#2')).toBe( - 'at 12:23 on the second Sunday of the month', + 'at 12:23 UTC on the second Sunday of the month', ); }); it('should handle "23 14 * * 0#2" like cronstrue', () => { // cronstrue: "At 14:23, on the second Sunday of the month" expect(describeCronExpression('23 14 * * 0#2')).toBe( - 'at 14:23 on the second Sunday of the month', + 'at 14:23 UTC on the second Sunday of the month', ); }); @@ -45,25 +45,25 @@ describe('cronstrue comparison tests', () => { describe('additional complex patterns', () => { it('should handle business hours patterns', () => { expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe( - 'every 15 minutes between 09:00 and 17:00 on weekdays', + 'every 15 minutes between 09:00 UTC and 17:00 UTC on weekdays', ); }); it('should handle monthly patterns', () => { expect(describeCronExpression('0 9 1 */3 *')).toBe( - 'at 09:00 on the 1st of the month every 3 months', + 'at 09:00 UTC on the 1st of the month every 3 months', ); }); it('should handle last day patterns', () => { expect(describeCronExpression('0 23 L * *')).toBe( - 'at 23:00 on the last day of the month', + 'at 23:00 UTC on the last day of the month', ); }); it('should handle last Friday patterns', () => { expect(describeCronExpression('0 17 * * 5L')).toBe( - 'at 17:00 on the last Friday of the month', + 'at 17:00 UTC on the last Friday of the month', ); }); }); @@ -72,10 +72,10 @@ describe('cronstrue comparison tests', () => { it('should format in 12-hour when requested', () => { expect( describeCronExpression('0 14 * * *', { use24HourTimeFormat: false }), - ).toBe('at 2:00 PM'); + ).toBe('at 2:00 PM UTC'); expect( describeCronExpression('23 12 * * 0#2', { use24HourTimeFormat: false }), - ).toBe('at 12:23 PM on the second Sunday of the month'); + ).toBe('at 12:23 PM UTC on the second Sunday of the month'); }); }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts index 5c704c0161..8bdaffb7e8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts @@ -19,40 +19,40 @@ describe('describeCronExpression', () => { }); it('should describe daily at specific time', () => { - expect(describeCronExpression('30 14 * * *')).toBe('at 14:30'); + expect(describeCronExpression('30 14 * * *')).toBe('at 14:30 UTC'); }); it('should describe daily at midnight', () => { - expect(describeCronExpression('0 0 * * *')).toBe('at 00:00'); + expect(describeCronExpression('0 0 * * *')).toBe('at 00:00 UTC'); }); }); describe('day-specific expressions', () => { it('should describe every day', () => { - expect(describeCronExpression('0 9 * * *')).toBe('at 09:00'); + expect(describeCronExpression('0 9 * * *')).toBe('at 09:00 UTC'); }); it('should describe every 3 days', () => { expect(describeCronExpression('0 9 */3 * *')).toBe( - 'at 09:00 every 3 days', + 'at 09:00 UTC every 3 days', ); }); it('should describe weekdays', () => { expect(describeCronExpression('0 9 * * 1-5')).toBe( - 'at 09:00 on weekdays', + 'at 09:00 UTC on weekdays', ); }); it('should describe specific day of month', () => { expect(describeCronExpression('0 9 15 * *')).toBe( - 'at 09:00 on the 15th of the month', + 'at 09:00 UTC on the 15th of the month', ); }); it('should describe last day of month', () => { expect(describeCronExpression('0 9 L * *')).toBe( - 'at 09:00 on the last day of the month', + 'at 09:00 UTC on the last day of the month', ); }); }); @@ -60,25 +60,25 @@ describe('describeCronExpression', () => { describe('month-specific expressions', () => { it('should describe specific month', () => { expect(describeCronExpression('0 9 1 1 *')).toBe( - 'at 09:00 on the 1st of the month only in January', + 'at 09:00 UTC on the 1st of the month only in January', ); }); it('should describe multiple months', () => { expect(describeCronExpression('0 9 * 1,6,12 *')).toBe( - 'at 09:00 only in January, June and December', + 'at 09:00 UTC only in January, June and December', ); }); it('should describe month range', () => { expect(describeCronExpression('0 9 * 6-8 *')).toBe( - 'at 09:00 between June and August', + 'at 09:00 UTC between June and August', ); }); it('should describe every 3 months', () => { expect(describeCronExpression('0 9 1 */3 *')).toBe( - 'at 09:00 on the 1st of the month every 3 months', + 'at 09:00 UTC on the 1st of the month every 3 months', ); }); }); @@ -86,25 +86,25 @@ describe('describeCronExpression', () => { describe('complex expressions', () => { it('should describe business hours every 15 minutes on weekdays', () => { expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe( - 'every 15 minutes between 09:00 and 17:00 on weekdays', + 'every 15 minutes between 09:00 UTC and 17:00 UTC on weekdays', ); }); it('should describe first Monday of every month', () => { expect(describeCronExpression('0 9 * * 1#1')).toBe( - 'at 09:00 on the first Monday of the month', + 'at 09:00 UTC on the first Monday of the month', ); }); it('should describe last Friday of every month', () => { expect(describeCronExpression('0 17 * * 5L')).toBe( - 'at 17:00 on the last Friday of the month', + 'at 17:00 UTC on the last Friday of the month', ); }); it('should describe multiple specific times', () => { expect(describeCronExpression('0 9,12,15 * * *')).toBe( - 'at 09:00, 12:00 and 15:00', + 'at 09:00 UTC, 12:00 UTC and 15:00 UTC', ); }); @@ -116,7 +116,7 @@ describe('describeCronExpression', () => { it('should describe specific minutes on specific hours', () => { expect(describeCronExpression('30 9,14 * * *')).toBe( - 'at 09:30 and 14:30', + 'at 09:30 UTC and 14:30 UTC', ); }); }); @@ -124,13 +124,13 @@ describe('describeCronExpression', () => { describe('real-world complex expressions', () => { it('should describe business hours every 15 minutes on weekdays', () => { expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe( - 'every 15 minutes between 09:00 and 17:00 on weekdays', + 'every 15 minutes between 09:00 UTC and 17:00 UTC on weekdays', ); }); it('should describe quarterly reports', () => { expect(describeCronExpression('0 9 1 1,4,7,10 *')).toBe( - 'at 09:00 on the 1st of the month only in January, April, July and October', + 'at 09:00 UTC on the 1st of the month only in January, April, July and October', ); }); @@ -140,13 +140,15 @@ describe('describeCronExpression', () => { ); }); - it('should describe reduced format expressions', () => { - expect(describeCronExpression('9 * * *')).toBe('at 09:00'); + it('should describe 4-field format expressions', () => { + expect(describeCronExpression('9 * * *')).toBe('at 09:00 UTC'); expect(describeCronExpression('*/2 * * *')).toBe('every 2 hours'); expect(describeCronExpression('9 15 * *')).toBe( - 'at 09:00 on the 15th of the month', + 'at 09:00 UTC on the 15th of the month', + ); + expect(describeCronExpression('9 * * 1')).toBe( + 'at 09:00 UTC only on Monday', ); - expect(describeCronExpression('9 * * 1')).toBe('at 09:00 only on Monday'); }); }); @@ -174,7 +176,7 @@ describe('describeCronExpression', () => { it('should use 12-hour format when specified', () => { expect( describeCronExpression('0 14 * * *', { use24HourTimeFormat: false }), - ).toBe('at 2:00 PM'); + ).toBe('at 2:00 PM UTC'); }); it('should use 12-hour format for multiple times', () => { @@ -182,7 +184,7 @@ describe('describeCronExpression', () => { describeCronExpression('0 9,14,18 * * *', { use24HourTimeFormat: false, }), - ).toBe('at 9:00 AM, 2:00 PM and 6:00 PM'); + ).toBe('at 9:00 AM UTC, 2:00 PM UTC and 6:00 PM UTC'); }); }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts index de61ab7e32..52558989f2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts @@ -23,52 +23,52 @@ describe('getHoursDescription', () => { it('should handle range with step', () => { expect(getHoursDescription('9-17/2', '0', options24)).toBe( - 'every 2 hours, between 09:00 and 17:00', + 'every 2 hours, between 09:00 UTC and 17:00 UTC', ); }); it('should handle ranges', () => { expect(getHoursDescription('9-17', '0', options24)).toBe( - 'between 09:00 and 17:00', + 'between 09:00 UTC and 17:00 UTC', ); }); it('should handle lists', () => { expect(getHoursDescription('9,12', '0', options24)).toBe( - 'at 09:00 and 12:00', + 'at 09:00 UTC and 12:00 UTC', ); expect(getHoursDescription('9,12,15,18', '30', options24)).toBe( - 'at 09:30, 12:30, 15:30 and 18:30', + 'at 09:30 UTC, 12:30 UTC, 15:30 UTC and 18:30 UTC', ); }); it('should handle single values', () => { - expect(getHoursDescription('9', '30', options24)).toBe('at 09:30'); - expect(getHoursDescription('0', '0', options24)).toBe('at 00:00'); - expect(getHoursDescription('23', '59', options24)).toBe('at 23:59'); + expect(getHoursDescription('9', '30', options24)).toBe('at 09:30 UTC'); + expect(getHoursDescription('0', '0', options24)).toBe('at 00:00 UTC'); + expect(getHoursDescription('23', '59', options24)).toBe('at 23:59 UTC'); }); }); describe('12-hour format', () => { it('should format morning times', () => { - expect(getHoursDescription('9', '30', options12)).toBe('at 9:30 AM'); - expect(getHoursDescription('0', '0', options12)).toBe('at 12:00 AM'); + expect(getHoursDescription('9', '30', options12)).toBe('at 9:30 AM UTC'); + expect(getHoursDescription('0', '0', options12)).toBe('at 12:00 AM UTC'); }); it('should format afternoon times', () => { - expect(getHoursDescription('14', '30', options12)).toBe('at 2:30 PM'); - expect(getHoursDescription('12', '0', options12)).toBe('at 12:00 PM'); + expect(getHoursDescription('14', '30', options12)).toBe('at 2:30 PM UTC'); + expect(getHoursDescription('12', '0', options12)).toBe('at 12:00 PM UTC'); }); it('should format lists in 12-hour', () => { expect(getHoursDescription('9,14', '0', options12)).toBe( - 'at 9:00 AM and 2:00 PM', + 'at 9:00 AM UTC and 2:00 PM UTC', ); }); it('should format ranges in 12-hour', () => { expect(getHoursDescription('9-17', '0', options12)).toBe( - 'between 9:00 AM and 5:00 PM', + 'between 9:00 AM UTC and 5:00 PM UTC', ); }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts index b5faefb1a3..925bd3c01e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts @@ -2,11 +2,11 @@ import { parseCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-h describe('parseCronExpression', () => { it('should parse 4-field cron expression', () => { - const result = parseCronExpression('0 * * *'); + const result = parseCronExpression('9 * * *'); expect(result).toEqual({ seconds: '0', minutes: '0', - hours: '0', + hours: '9', dayOfMonth: '*', month: '*', dayOfWeek: '*', @@ -25,7 +25,7 @@ describe('parseCronExpression', () => { }); }); - it('should parse 6-field cron expression with seconds', () => { + it('should parse 6-field cron expression', () => { const result = parseCronExpression('15 30 14 * * 1'); expect(result).toEqual({ seconds: '15', @@ -37,9 +37,12 @@ describe('parseCronExpression', () => { }); }); - it('should throw error for invalid field count', () => { + it('should reject expressions with wrong field count', () => { expect(() => parseCronExpression('* *')).toThrow( - 'Invalid cron expression format. Expected 4, 5, or 6 fields, got 2', + 'Invalid cron expression. Expected 4-6 fields, got 2', + ); + expect(() => parseCronExpression('* * * * * * *')).toThrow( + 'Invalid cron expression. Expected 4-6 fields, got 7', ); }); @@ -66,4 +69,79 @@ describe('parseCronExpression', () => { dayOfWeek: '1-5', }); }); + + it('should auto-correct expressions with missing asterisk before slash', () => { + const result1 = parseCronExpression('1 /3 * * *'); + expect(result1).toEqual({ + seconds: '0', + minutes: '1', + hours: '*/3', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', + }); + + const result2 = parseCronExpression('* /5 * * *'); + expect(result2).toEqual({ + seconds: '0', + minutes: '*', + hours: '*/5', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', + }); + }); + + it('should auto-correct expressions starting with slash', () => { + const result1 = parseCronExpression('/3 * * *'); + expect(result1).toEqual({ + seconds: '0', + minutes: '0', + hours: '*/3', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', + }); + + const result2 = parseCronExpression('/3 * * * *'); + expect(result2).toEqual({ + seconds: '0', + minutes: '*/3', + hours: '*', + dayOfMonth: '*', + month: '*', + dayOfWeek: '*', + }); + }); + + it('should auto-correct step patterns in day field', () => { + const result = parseCronExpression('0 0 /7 * *'); + expect(result).toEqual({ + seconds: '0', + minutes: '0', + hours: '0', + dayOfMonth: '*/7', + month: '*', + dayOfWeek: '*', + }); + }); + + it('should handle extra spaces in expressions', () => { + const result = parseCronExpression('30 14 * * 1'); + expect(result).toEqual({ + seconds: '0', + minutes: '30', + hours: '14', + dayOfMonth: '*', + month: '*', + dayOfWeek: '1', + }); + }); + + it('should reject expressions with out-of-range values', () => { + expect(() => parseCronExpression('60 * * * *')).toThrow('Invalid cron'); + expect(() => parseCronExpression('0 25 * * *')).toThrow('Invalid cron'); + expect(() => parseCronExpression('0 0 32 * *')).toThrow('Invalid cron'); + expect(() => parseCronExpression('0 0 0 13 *')).toThrow('Invalid cron'); + }); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts index 2119235efc..8921224d2c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts @@ -37,8 +37,18 @@ export const getHoursDescription = ( if (range.includes('-')) { const [start, end] = range.split('-'); const stepNumStr = stepNum.toString(); - const startTime = formatCronTime(start, '0', use24Hour); - const endTime = formatCronTime(end, '0', use24Hour); + const startTime = formatCronTime({ + hour: start, + minute: '0', + use24HourFormat: use24Hour, + appendUTC: true, + }); + const endTime = formatCronTime({ + hour: end, + minute: '0', + use24HourFormat: use24Hour, + appendUTC: true, + }); return t`every ${stepNumStr} hours, between ${startTime} and ${endTime}`; } @@ -48,15 +58,30 @@ export const getHoursDescription = ( if (isNumericRange(hours) && hours.includes('-')) { const [start, end] = hours.split('-'); - const startTime = formatCronTime(start, '0', use24Hour); - const endTime = formatCronTime(end, '0', use24Hour); + const startTime = formatCronTime({ + hour: start, + minute: '0', + use24HourFormat: use24Hour, + appendUTC: true, + }); + const endTime = formatCronTime({ + hour: end, + minute: '0', + use24HourFormat: use24Hour, + appendUTC: true, + }); return t`between ${startTime} and ${endTime}`; } if (isListValue(hours)) { const values = hours.split(',').map((v) => v.trim()); const formattedTimes = values.map((hour) => - formatCronTime(hour, minutes || '0', use24Hour), + formatCronTime({ + hour, + minute: minutes || '0', + use24HourFormat: use24Hour, + appendUTC: true, + }), ); if (formattedTimes.length === 2) { @@ -71,7 +96,12 @@ export const getHoursDescription = ( const hourNum = parseInt(hours, 10); if (!isNaN(hourNum)) { - const formattedTime = formatCronTime(hours, minutes || '0', use24Hour); + const formattedTime = formatCronTime({ + hour: hours, + minute: minutes || '0', + use24HourFormat: use24Hour, + appendUTC: true, + }); return t`at ${formattedTime}`; } diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/calculateNextExecutionsForMinuteInterval.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/calculateNextExecutionsForMinuteInterval.ts new file mode 100644 index 0000000000..a0eb189997 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/calculateNextExecutionsForMinuteInterval.ts @@ -0,0 +1,34 @@ +export const calculateNextExecutionsForMinuteInterval = ( + intervalMinutes: number, + count = 3, +): Date[] => { + const now = new Date(); + + const currentMinutes = now.getUTCMinutes(); + const currentSeconds = now.getUTCSeconds(); + const minutesSinceHour = currentMinutes + currentSeconds / 60; + + const intervalsPassed = Math.floor(minutesSinceHour / intervalMinutes); + const nextInterval = intervalsPassed + 1; + const nextMinute = nextInterval * intervalMinutes; + + let nextExecution = new Date(now); + nextExecution.setUTCSeconds(0, 0); + + if (nextMinute < 60) { + nextExecution.setUTCMinutes(nextMinute); + } else { + nextExecution.setUTCHours(nextExecution.getUTCHours() + 1); + nextExecution.setUTCMinutes(nextMinute % 60); + } + + if (nextExecution.getTime() <= now.getTime()) { + nextExecution = new Date( + nextExecution.getTime() + intervalMinutes * 60 * 1000, + ); + } + + return Array.from({ length: count }, (_, i) => { + return new Date(nextExecution.getTime() + i * intervalMinutes * 60 * 1000); + }); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeCronExpression.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeCronExpression.ts new file mode 100644 index 0000000000..06f445044e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeCronExpression.ts @@ -0,0 +1,19 @@ +import { normalizeWhitespace } from './normalizeWhitespace'; + +export const normalizeCronExpression = (expression: string): string => { + let normalized = normalizeWhitespace(expression); + + normalized = normalized.replace(/(^|\s)\/(\d+)/g, '$1*/$2'); + + const parts = normalized.split(/\s+/); + + if (parts.length === 4) { + return `0 ${normalized}`; + } else if (parts.length === 5) { + return normalized; + } else if (parts.length === 6) { + return parts.slice(1).join(' '); + } + + return normalized; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeWhitespace.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeWhitespace.ts new file mode 100644 index 0000000000..8749116dd8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/normalizeWhitespace.ts @@ -0,0 +1,3 @@ +export const normalizeWhitespace = (expression: string): string => { + return expression.trim().replace(/\s+/g, ' '); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts index 8cbd2f4632..60c338ac3b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts @@ -1,6 +1,7 @@ import { type CronExpressionParts } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts'; import { CronExpressionParser } from 'cron-parser'; import { isDefined } from 'twenty-shared/utils'; +import { normalizeWhitespace } from './normalizeWhitespace'; export const parseCronExpression = ( expression: string, @@ -9,16 +10,20 @@ export const parseCronExpression = ( throw new Error('Cron expression is required'); } - const parts = expression.trim().split(/\s+/); + let normalized = normalizeWhitespace(expression); + + normalized = normalized.replace(/(^|\s)\/(\d+)/g, '$1*/$2'); + + const parts = normalized.split(/\s+/); if (parts.length < 4 || parts.length > 6) { throw new Error( - `Invalid cron expression format. Expected 4, 5, or 6 fields, got ${parts.length}`, + `Invalid cron expression. Expected 4-6 fields, got ${parts.length}`, ); } try { - CronExpressionParser.parse(expression, { tz: 'UTC' }); + CronExpressionParser.parse(normalized, { tz: 'UTC' }); // Handle different cron formats that cron-parser accepts if (parts.length === 4) { diff --git a/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts b/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts index 26c3ea504a..59d2ec26d2 100644 --- a/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts +++ b/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts @@ -2,23 +2,66 @@ import { formatTime } from '../formatTime'; describe('formatTime', () => { it('should format 24-hour time', () => { - expect(formatTime('9', '30', true)).toBe('09:30'); - expect(formatTime('14', '0', true)).toBe('14:00'); - expect(formatTime('0', '0', true)).toBe('00:00'); - expect(formatTime('23', '59', true)).toBe('23:59'); + expect(formatTime({ hour: '9', minute: '30', use24HourFormat: true })).toBe( + '09:30', + ); + expect(formatTime({ hour: '14', minute: '0', use24HourFormat: true })).toBe( + '14:00', + ); + expect(formatTime({ hour: '0', minute: '0', use24HourFormat: true })).toBe( + '00:00', + ); + expect( + formatTime({ hour: '23', minute: '59', use24HourFormat: true }), + ).toBe('23:59'); }); it('should format 12-hour time', () => { - expect(formatTime('9', '30', false)).toBe('9:30 AM'); - expect(formatTime('14', '0', false)).toBe('2:00 PM'); - expect(formatTime('0', '0', false)).toBe('12:00 AM'); - expect(formatTime('12', '0', false)).toBe('12:00 PM'); - expect(formatTime('23', '59', false)).toBe('11:59 PM'); + expect( + formatTime({ hour: '9', minute: '30', use24HourFormat: false }), + ).toBe('9:30 AM'); + expect( + formatTime({ hour: '14', minute: '0', use24HourFormat: false }), + ).toBe('2:00 PM'); + expect(formatTime({ hour: '0', minute: '0', use24HourFormat: false })).toBe( + '12:00 AM', + ); + expect( + formatTime({ hour: '12', minute: '0', use24HourFormat: false }), + ).toBe('12:00 PM'); + expect( + formatTime({ hour: '23', minute: '59', use24HourFormat: false }), + ).toBe('11:59 PM'); }); it('should handle invalid inputs', () => { - expect(formatTime('invalid', '30', true)).toBe(''); - expect(formatTime('9', 'invalid', true)).toBe(''); - expect(formatTime('', '', true)).toBe(''); + expect( + formatTime({ hour: 'invalid', minute: '30', use24HourFormat: true }), + ).toBe(''); + expect( + formatTime({ hour: '9', minute: 'invalid', use24HourFormat: true }), + ).toBe(''); + expect(formatTime({ hour: '', minute: '', use24HourFormat: true })).toBe( + '', + ); + }); + + it('should append UTC when specified', () => { + expect( + formatTime({ + hour: '9', + minute: '30', + use24HourFormat: true, + appendUTC: true, + }), + ).toBe('09:30 UTC'); + expect( + formatTime({ + hour: '14', + minute: '0', + use24HourFormat: false, + appendUTC: true, + }), + ).toBe('2:00 PM UTC'); }); }); diff --git a/packages/twenty-front/src/utils/format/formatTime.ts b/packages/twenty-front/src/utils/format/formatTime.ts index 898c0012ed..8fdb422118 100644 --- a/packages/twenty-front/src/utils/format/formatTime.ts +++ b/packages/twenty-front/src/utils/format/formatTime.ts @@ -1,10 +1,18 @@ import { isDefined } from 'twenty-shared/utils'; -export const formatTime = ( - hour: string, - minute: string, - use24HourFormat: boolean, -): string => { +type FormatTimeParams = { + hour: string; + minute: string; + use24HourFormat: boolean; + appendUTC?: boolean; +}; + +export const formatTime = ({ + hour, + minute, + use24HourFormat, + appendUTC = false, +}: FormatTimeParams): string => { if (!isDefined(hour) || !isDefined(minute)) { return ''; } @@ -16,12 +24,16 @@ export const formatTime = ( return ''; } + let formattedTime = ''; + if (use24HourFormat) { - return `${hourNum.toString().padStart(2, '0')}:${minuteNum.toString().padStart(2, '0')}`; + formattedTime = `${hourNum.toString().padStart(2, '0')}:${minuteNum.toString().padStart(2, '0')}`; } else { const period = hourNum >= 12 ? 'PM' : 'AM'; const displayHour = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum; - return `${displayHour}:${minuteNum.toString().padStart(2, '0')} ${period}`; + formattedTime = `${displayHour}:${minuteNum.toString().padStart(2, '0')} ${period}`; } + + return appendUTC ? `${formattedTime} UTC` : formattedTime; }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts index 9645e401ea..5de00192c7 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util.ts @@ -220,12 +220,19 @@ function assertCronTriggerSettingsAreValid(settings: any) { ); } - if (settings.schedule.minute <= 0) { + if (settings.schedule.minute <= 0 || settings.schedule.minute > 60) { + const errorMessage = + settings.schedule.minute <= 0 + ? msg`Invalid minute value. Should be integer greater than 1` + : msg`Minute value cannot exceed 60. For intervals greater than 60 minutes, use the "Hours" trigger type or a custom cron expression`; + throw new WorkflowTriggerException( - 'Invalid minute value. Should be integer greater than 1', + settings.schedule.minute <= 0 + ? 'Invalid minute value. Should be integer greater than 1' + : 'Invalid minute value. Cannot exceed 60. For intervals greater than 60 minutes, use the "Hours" trigger type or a custom cron expression', WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER, { - userFriendlyMessage: msg`Invalid minute value. Should be integer greater than 1`, + userFriendlyMessage: errorMessage, }, ); } diff --git a/packages/twenty-shared/src/workflow/schemas/cron-trigger-schema.ts b/packages/twenty-shared/src/workflow/schemas/cron-trigger-schema.ts index d6b2362ce7..dd5038117f 100644 --- a/packages/twenty-shared/src/workflow/schemas/cron-trigger-schema.ts +++ b/packages/twenty-shared/src/workflow/schemas/cron-trigger-schema.ts @@ -23,7 +23,7 @@ export const workflowCronTriggerSchema = baseTriggerSchema.extend({ }), z.object({ type: z.literal('MINUTES'), - schedule: z.object({ minute: z.number().min(1) }), + schedule: z.object({ minute: z.number().min(1).max(60) }), outputSchema: z.looseObject({}), }), z.object({