diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts index abddc47152..8d3e1c4a8a 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/constants/successful-inputs-by-field-metadata-type.constant.ts @@ -186,7 +186,7 @@ export const successfulInputsByFieldMetadataType: { { input: { dateTimeField: null }, expectedOutput: { dateTimeField: null } }, { input: { dateTimeField: '2025-01-13T10:30:00.000Z' }, - expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000Z' }, + expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' }, }, { input: { dateTimeField: '2025-01-13T10:30:00Z' }, @@ -194,83 +194,83 @@ export const successfulInputsByFieldMetadataType: { }, { input: { dateTimeField: '2025-01-13T10:30:00.000+02:00' }, - expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000+02:00' }, + expectedOutput: { dateTimeField: '2025-01-13T08:30:00Z' }, }, { input: { dateTimeField: '2025-01-13T10:30:00+02:00' }, - expectedOutput: { dateTimeField: '2025-01-13T10:30:00+02:00' }, + expectedOutput: { dateTimeField: '2025-01-13T08:30:00Z' }, }, { input: { dateTimeField: '2025-01-13T10:30:00.000' }, - expectedOutput: { dateTimeField: '2025-01-13T10:30:00.000' }, + expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' }, }, { input: { dateTimeField: '2025-01-13T10:30:00' }, - expectedOutput: { dateTimeField: '2025-01-13T10:30:00' }, + expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' }, }, { input: { dateTimeField: '2025-01-13 10:30:00.000' }, - expectedOutput: { dateTimeField: '2025-01-13 10:30:00.000' }, + expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' }, }, { input: { dateTimeField: '2025-01-13 10:30:00' }, - expectedOutput: { dateTimeField: '2025-01-13 10:30:00' }, + expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' }, }, { input: { dateTimeField: '2025-01-13 10:30' }, - expectedOutput: { dateTimeField: '2025-01-13 10:30' }, + expectedOutput: { dateTimeField: '2025-01-13T10:30:00Z' }, }, { input: { dateTimeField: '2025-01-13' }, - expectedOutput: { dateTimeField: '2025-01-13' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '20250113' }, - expectedOutput: { dateTimeField: '20250113' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '2025.01.13' }, - expectedOutput: { dateTimeField: '2025.01.13' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '2025/01/13' }, - expectedOutput: { dateTimeField: '2025/01/13' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '01-13-2025' }, - expectedOutput: { dateTimeField: '01-13-2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '01/13/2025' }, - expectedOutput: { dateTimeField: '01/13/2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '01.13.2025' }, - expectedOutput: { dateTimeField: '01.13.2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: 'January 13, 2025' }, - expectedOutput: { dateTimeField: 'January 13, 2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: 'Jan 13, 2025' }, - expectedOutput: { dateTimeField: 'Jan 13, 2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '13 January 2025' }, - expectedOutput: { dateTimeField: '13 January 2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '13 Jan 2025' }, - expectedOutput: { dateTimeField: '13 Jan 2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '13-Jan-2025' }, - expectedOutput: { dateTimeField: '13-Jan-2025' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, { input: { dateTimeField: '2025-Jan-13' }, - expectedOutput: { dateTimeField: '2025-Jan-13' }, + expectedOutput: { dateTimeField: '2025-01-13T00:00:00Z' }, }, ], [FieldMetadataType.BOOLEAN]: [ diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-date-time-field-or-throw.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-date-time-field-or-throw.util.spec.ts index 5e39c99d7c..7985239724 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-date-time-field-or-throw.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-date-time-field-or-throw.util.spec.ts @@ -2,102 +2,122 @@ import { validateDateTimeFieldOrThrow } from 'src/engine/api/common/common-args- import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; describe('validateDateTimeFieldOrThrow', () => { - describe('valid inputs', () => { + describe('valid inputs (normalized to a canonical instant)', () => { it('should return null when value is null', () => { const result = validateDateTimeFieldOrThrow(null, 'testField'); expect(result).toBeNull(); }); - it('should return the value when it is a valid ISO datetime string with Z timezone', () => { - const datetimeString = '2024-01-15T10:30:00Z'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should keep the instant for an ISO datetime string with Z timezone', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15T10:30:00Z', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid ISO datetime string with milliseconds and Z timezone', () => { - const datetimeString = '2024-01-15T10:30:00.000Z'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should drop a zero millisecond fraction', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15T10:30:00.000Z', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid ISO datetime string with timezone offset', () => { - const datetimeString = '2024-01-15T10:30:00+02:00'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should convert a timezone offset to its UTC instant', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15T10:30:00+02:00', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T08:30:00Z'); }); - it('should return the value when it is a valid ISO datetime string with milliseconds and timezone offset', () => { - const datetimeString = '2024-01-15T10:30:00.000+02:00'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should convert a timezone offset with milliseconds to its UTC instant', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15T10:30:00.000+02:00', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T08:30:00Z'); }); - it('should return the value when it is a valid ISO datetime string without timezone', () => { - const datetimeString = '2024-01-15T10:30:00'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should interpret a zoneless ISO datetime as UTC', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15T10:30:00', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid ISO datetime string with milliseconds without timezone', () => { - const datetimeString = '2024-01-15T10:30:00.000'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should interpret a zoneless ISO datetime with milliseconds as UTC', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15T10:30:00.000', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid datetime with space separator', () => { - const datetimeString = '2024-01-15 10:30:00'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should normalize a datetime with a space separator', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15 10:30:00', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid datetime with space separator and milliseconds', () => { - const datetimeString = '2024-01-15 10:30:00.000'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should normalize a datetime with a space separator and milliseconds', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15 10:30:00.000', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid datetime with space separator without seconds', () => { - const datetimeString = '2024-01-15 10:30'; - const result = validateDateTimeFieldOrThrow(datetimeString, 'testField'); + it('should normalize a datetime with a space separator without seconds', () => { + const result = validateDateTimeFieldOrThrow( + '2024-01-15 10:30', + 'testField', + ); - expect(result).toBe(datetimeString); + expect(result).toBe('2024-01-15T10:30:00Z'); }); - it('should return the value when it is a valid ISO date string (will be treated as midnight)', () => { - const dateString = '2024-01-15'; - const result = validateDateTimeFieldOrThrow(dateString, 'testField'); + it('should normalize a date-only value to midnight UTC', () => { + const result = validateDateTimeFieldOrThrow('2024-01-15', 'testField'); - expect(result).toBe(dateString); + expect(result).toBe('2024-01-15T00:00:00Z'); }); - it('should return the value when it is a valid ISO compact date string', () => { - const dateString = '20240115'; - const result = validateDateTimeFieldOrThrow(dateString, 'testField'); + it('should normalize a compact date-only value to midnight UTC', () => { + const result = validateDateTimeFieldOrThrow('20240115', 'testField'); - expect(result).toBe(dateString); + expect(result).toBe('2024-01-15T00:00:00Z'); }); - it('should return the value when it is a valid date with full month name', () => { - const dateString = 'January 15, 2024'; - const result = validateDateTimeFieldOrThrow(dateString, 'testField'); + it('should normalize a date with a full month name to midnight UTC', () => { + const result = validateDateTimeFieldOrThrow( + 'January 15, 2024', + 'testField', + ); - expect(result).toBe(dateString); + expect(result).toBe('2024-01-15T00:00:00Z'); }); - it('should return the value when it is a Date object', () => { - const dateObject = new Date('2024-01-15T10:30:00Z'); - const result = validateDateTimeFieldOrThrow(dateObject, 'testField'); + it('should normalize a Date object to its instant', () => { + const result = validateDateTimeFieldOrThrow( + new Date('2024-01-15T10:30:00Z'), + 'testField', + ); - expect(result).toBe(dateObject); + expect(result).toBe('2024-01-15T10:30:00Z'); }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-field-or-throw.util.ts index 5aaa136902..20a80ed07f 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-field-or-throw.util.ts @@ -3,34 +3,13 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isDate, isNull, isString } from '@sniptt/guards'; import { isValid, parse } from 'date-fns'; +import { ACCEPTED_DATE_FORMATS } from 'twenty-shared/utils'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; -const ACCEPTED_DATE_FORMATS = [ - 'yyyy-MM-dd', - 'yyyyMMdd', - 'yyyy.MM.dd', - 'yyyy/MM/dd', - 'MM-dd-yyyy', - 'MM/dd/yyyy', - 'MM.dd.yyyy', - 'MMMM d, yyyy', - 'MMM d, yyyy', - 'd MMMM yyyy', - 'd MMM yyyy', - 'dd-MMM-yyyy', - 'yyyy-MMM-dd', - "yyyy-MM-dd'T'HH:mm:ss.SSSX", - "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss", - 'yyyy-MM-dd HH:mm:ss', - 'yyyy-MM-dd HH:mm:ss.SSS', -]; - const isValidDateFormat = (value: string): boolean => { for (const format of ACCEPTED_DATE_FORMATS) { const parsed = parse(value, format, new Date()); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts index 5237e55372..017c96a775 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts @@ -2,64 +2,19 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isDate, isNull, isString } from '@sniptt/guards'; -import { isValid, parse } from 'date-fns'; +import { isValid } from 'date-fns'; +import { Temporal } from 'temporal-polyfill'; +import { parseToInstantOrThrow } from 'twenty-shared/utils'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; -const ACCEPTED_DATE_TIME_FORMATS = [ - "yyyy-MM-dd'T'HH:mm:ss.SSSX", - "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mm:ss.SSSxxx", - "yyyy-MM-dd'T'HH:mm:ssxxx", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss", - 'yyyy-MM-dd HH:mm:ss.SSS', - 'yyyy-MM-dd HH:mm:ss', - 'yyyy-MM-dd HH:mm', - 'yyyy-MM-dd', - 'yyyyMMdd', - 'yyyy.MM.dd', - 'yyyy/MM/dd', - 'MM-dd-yyyy', - 'MM/dd/yyyy', - 'MM.dd.yyyy', - 'MMMM d, yyyy', - 'MMM d, yyyy', - 'd MMMM yyyy', - 'd MMM yyyy', - 'dd-MMM-yyyy', - 'yyyy-MMM-dd', -]; - -const isValidDateTimeFormat = (value: string): boolean => { - for (const format of ACCEPTED_DATE_TIME_FORMATS) { - const parsed = parse(value, format, new Date()); - - if (isValid(parsed)) { - return true; - } - } - - return false; -}; - -export const validateDateTimeFieldOrThrow = ( +const throwInvalidDateTimeFieldException = ( value: unknown, fieldName: string, -): unknown => { - if (isNull(value)) return null; - - if (isDate(value) && isValid(value)) { - return value; - } - - if (isString(value) && isValidDateTimeFormat(value)) { - return value; - } - +): never => { const inspectedValue = inspect(value); throw new CommonQueryRunnerException( @@ -70,3 +25,24 @@ export const validateDateTimeFieldOrThrow = ( }, ); }; + +export const validateDateTimeFieldOrThrow = ( + value: unknown, + fieldName: string, +): unknown => { + if (isNull(value)) return null; + + if (isDate(value) && isValid(value)) { + return Temporal.Instant.fromEpochMilliseconds(value.getTime()).toString(); + } + + if (isString(value)) { + try { + return parseToInstantOrThrow(value).toString(); + } catch { + throwInvalidDateTimeFieldException(value, fieldName); + } + } + + throwInvalidDateTimeFieldException(value, fieldName); +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-field-or-throw.util.ts index 6f0832ee6d..478b465af1 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-field-or-throw.util.ts @@ -3,34 +3,13 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isDate, isNull, isString } from '@sniptt/guards'; import { isValid, parse } from 'date-fns'; +import { ACCEPTED_DATE_FORMATS } from 'twenty-shared/utils'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; -const ACCEPTED_DATE_FORMATS = [ - 'yyyy-MM-dd', - 'yyyyMMdd', - 'yyyy.MM.dd', - 'yyyy/MM/dd', - 'MM-dd-yyyy', - 'MM/dd/yyyy', - 'MM.dd.yyyy', - 'MMMM d, yyyy', - 'MMM d, yyyy', - 'd MMMM yyyy', - 'd MMM yyyy', - 'dd-MMM-yyyy', - 'yyyy-MMM-dd', - "yyyy-MM-dd'T'HH:mm:ss.SSSX", - "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss", - 'yyyy-MM-dd HH:mm:ss', - 'yyyy-MM-dd HH:mm:ss.SSS', -]; - const isValidDateFormat = (value: string): boolean => { for (const format of ACCEPTED_DATE_FORMATS) { const parsed = parse(value, format, new Date()); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts index 0e570abc65..1e7e65174d 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/filter-arg-processor/validator-utils/validate-date-time-field-or-throw.util.ts @@ -3,37 +3,13 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isDate, isNull, isString } from '@sniptt/guards'; import { isValid, parse } from 'date-fns'; +import { ACCEPTED_DATE_TIME_FORMATS } from 'twenty-shared/utils'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; -const ACCEPTED_DATE_TIME_FORMATS = [ - "yyyy-MM-dd'T'HH:mm:ss.SSSX", - "yyyy-MM-dd'T'HH:mm:ssX", - "yyyy-MM-dd'T'HH:mm:ss.SSSxxx", - "yyyy-MM-dd'T'HH:mm:ssxxx", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss", - 'yyyy-MM-dd HH:mm:ss.SSS', - 'yyyy-MM-dd HH:mm:ss', - 'yyyy-MM-dd HH:mm', - 'yyyy-MM-dd', - 'yyyyMMdd', - 'yyyy.MM.dd', - 'yyyy/MM/dd', - 'MM-dd-yyyy', - 'MM/dd/yyyy', - 'MM.dd.yyyy', - 'MMMM d, yyyy', - 'MMM d, yyyy', - 'd MMMM yyyy', - 'd MMM yyyy', - 'dd-MMM-yyyy', - 'yyyy-MMM-dd', -]; - const isValidDateTimeFormat = (value: string): boolean => { for (const format of ACCEPTED_DATE_TIME_FORMATS) { const parsed = parse(value, format, new Date()); diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts index 9f7191a530..7cd644cebd 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts @@ -227,6 +227,16 @@ export const successfulCreateInputByFieldMetadataType: { ); }, }, + { + input: { + dateTimeField: '2026-05-07', + }, + validateInput: (record: Record) => { + const date = new Date(record.dateTimeField); + + return date.toISOString() === '2026-05-07T00:00:00.000Z'; + }, + }, ], [FieldMetadataType.BOOLEAN]: [ { diff --git a/packages/twenty-shared/src/utils/date/__tests__/parseToInstantOrThrow.test.ts b/packages/twenty-shared/src/utils/date/__tests__/parseToInstantOrThrow.test.ts new file mode 100644 index 0000000000..4d695d6ff7 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/__tests__/parseToInstantOrThrow.test.ts @@ -0,0 +1,26 @@ +import { parseToInstantOrThrow } from '../parseToInstantOrThrow'; + +describe('parseToInstantOrThrow', () => { + it.each([ + ['2024-01-15T10:30:00Z', '2024-01-15T10:30:00Z'], + ['2024-01-15T10:30:00.000Z', '2024-01-15T10:30:00Z'], + ['2024-01-15T10:30:00+02:00', '2024-01-15T08:30:00Z'], + ['2024-01-15T10:30:00', '2024-01-15T10:30:00Z'], + ['2024-01-15 10:30:00', '2024-01-15T10:30:00Z'], + ['2024-01-15 10:30', '2024-01-15T10:30:00Z'], + ['2024-01-15', '2024-01-15T00:00:00Z'], + ['20240115', '2024-01-15T00:00:00Z'], + ['01/15/2024', '2024-01-15T00:00:00Z'], + ['01-15-2024', '2024-01-15T00:00:00Z'], + ['January 15, 2024', '2024-01-15T00:00:00Z'], + ])('should parse %s to the instant %s', (input, expected) => { + expect(parseToInstantOrThrow(input).toString()).toBe(expected); + }); + + it.each([['2024'], ['2024-01'], ['not-a-date'], ['']])( + 'should throw for the unparseable value %s', + (input) => { + expect(() => parseToInstantOrThrow(input)).toThrow(); + }, + ); +}); diff --git a/packages/twenty-shared/src/utils/date/dateInputFormats.ts b/packages/twenty-shared/src/utils/date/dateInputFormats.ts new file mode 100644 index 0000000000..f44f546bc4 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/dateInputFormats.ts @@ -0,0 +1,40 @@ +export const NON_ISO_DATE_FORMATS = [ + 'yyyy.MM.dd', + 'yyyy/MM/dd', + 'MM-dd-yyyy', + 'MM/dd/yyyy', + 'MM.dd.yyyy', + 'MMMM d, yyyy', + 'MMM d, yyyy', + 'd MMMM yyyy', + 'd MMM yyyy', + 'dd-MMM-yyyy', + 'yyyy-MMM-dd', +]; + +export const ACCEPTED_DATE_FORMATS = [ + 'yyyy-MM-dd', + 'yyyyMMdd', + ...NON_ISO_DATE_FORMATS, + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ssX", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + 'yyyy-MM-dd HH:mm:ss', + 'yyyy-MM-dd HH:mm:ss.SSS', +]; + +export const ACCEPTED_DATE_TIME_FORMATS = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ssX", + "yyyy-MM-dd'T'HH:mm:ss.SSSxxx", + "yyyy-MM-dd'T'HH:mm:ssxxx", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + 'yyyy-MM-dd HH:mm:ss.SSS', + 'yyyy-MM-dd HH:mm:ss', + 'yyyy-MM-dd HH:mm', + 'yyyy-MM-dd', + 'yyyyMMdd', + ...NON_ISO_DATE_FORMATS, +]; diff --git a/packages/twenty-shared/src/utils/date/parseToInstantOrThrow.ts b/packages/twenty-shared/src/utils/date/parseToInstantOrThrow.ts new file mode 100644 index 0000000000..1e13f6bb9c --- /dev/null +++ b/packages/twenty-shared/src/utils/date/parseToInstantOrThrow.ts @@ -0,0 +1,44 @@ +import { isValid, parse } from 'date-fns'; +import { Temporal } from 'temporal-polyfill'; + +import { NON_ISO_DATE_FORMATS } from '@/utils/date/dateInputFormats'; +import { turnJSDateToPlainDate } from '@/utils/date/turnJSDateToPlainDate'; +import { isDefined } from '@/utils/validation'; + +const getIsoInstant = (stringDateTime: string): Temporal.Instant | null => { + try { + return Temporal.Instant.from(stringDateTime); + } catch { + try { + return Temporal.PlainDateTime.from(stringDateTime) + .toZonedDateTime('UTC') + .toInstant(); + } catch { + return null; + } + } +}; + +export const parseToInstantOrThrow = ( + stringDateTime: string, +): Temporal.Instant => { + const isoInstant = getIsoInstant(stringDateTime); + + if (isDefined(isoInstant)) { + return isoInstant; + } + + for (const format of NON_ISO_DATE_FORMATS) { + const parsedDate = parse(stringDateTime, format, new Date()); + + if (isValid(parsedDate)) { + return turnJSDateToPlainDate(parsedDate) + .toZonedDateTime('UTC') + .toInstant(); + } + } + + throw new Error( + `Cannot parse date-time string as Instant: "${stringDateTime}"`, + ); +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 5402a801c2..22aeda3b9a 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -29,6 +29,11 @@ export { interpolateCommandMenuItemTemplate } from './command-menu-items/interpo export { resolveObjectMetadataLabel } from './command-menu-items/resolveObjectMetadataLabel'; export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty'; export { computeDiffBetweenObjects } from './compute-diff-between-objects'; +export { + NON_ISO_DATE_FORMATS, + ACCEPTED_DATE_FORMATS, + ACCEPTED_DATE_TIME_FORMATS, +} from './date/dateInputFormats'; export { isDateWithoutTime } from './date/isDateWithoutTime'; export { isPlainDateAfter } from './date/isPlainDateAfter'; export { isPlainDateBefore } from './date/isPlainDateBefore'; @@ -36,6 +41,7 @@ export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual'; export { isPlainDateInSameMonth } from './date/isPlainDateInSameMonth'; export { isPlainDateInWeekend } from './date/isPlainDateInWeekend'; export { isSamePlainDate } from './date/isSamePlainDate'; +export { parseToInstantOrThrow } from './date/parseToInstantOrThrow'; export { parseToPlainDateOrThrow } from './date/parseToPlainDateOrThrow'; export { sortPlainDate } from './date/sortPlainDate'; export { turnJSDateToPlainDate } from './date/turnJSDateToPlainDate';