From 6bd0244045c258c1970908fd20e43c27e7604934 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 17 Jan 2025 16:19:49 +0100 Subject: [PATCH] Fix date type update (#9700) This PR fixes a problem with how TypeORM handles date without time. A date without time that is stored in PostgreSQL database as `date` type gets returned as an ISO string date with a timezone that can shift its date part in an unwanted way. In short DB stores `2025-01-01`, TypeORM query builder returns `2024-12-31T23:00:00Z` which gets parsed as `2024-12-31` on the front end field. We don't want to handle timezone here because we are manipulating a date without its time part, so this PR adds a step that counteracts what TypeORM does and returns `2025-01-01T00:00:00.000Z` so that the front can parse it correctly. @Weiko We might want to check other places of the backend where date types are returned by TypeORM, we might have the same problem, this PR only fixes it for updateOne resolver return. - Fixed date persist on frontend which was shifting the date to a different day due to timezone issue - Fixed date returned by the backend update logic, which was shifting the date by the timezone offset (so this PR adds back the offset so that it stays at 00:00:00Z time) --- .../ObjectFilterDropdownDateInput.tsx | 6 +- .../components/FormDateTimeFieldInput.tsx | 6 +- .../input/components/DateFieldInput.tsx | 8 ++- .../ui/field/input/components/DateInput.tsx | 10 +-- .../date/components/InternalDatePicker.tsx | 16 ++--- .../InternalDatePicker.stories.tsx | 10 +-- .../twenty-orm/utils/format-result.util.ts | 68 +++++++++++++++++-- .../twenty-server/src/utils/date/isDate.ts | 3 + .../src/utils/date/isValidDate.ts | 3 + 9 files changed, 97 insertions(+), 33 deletions(-) create mode 100644 packages/twenty-server/src/utils/date/isDate.ts create mode 100644 packages/twenty-server/src/utils/date/isValidDate.ts diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx index 5ddf33f6cb..f232445143 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx @@ -5,7 +5,7 @@ import { selectedFilterComponentState } from '@/object-record/object-filter-drop import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; import { useApplyRecordFilter } from '@/object-record/record-filter/hooks/useApplyRecordFilter'; -import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; +import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue'; @@ -104,14 +104,14 @@ export const ObjectFilterDropdownDateInput = () => { : undefined; return ( - ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx index bb80b458f2..9fd25f7f58 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/FormDateTimeFieldInput.tsx @@ -5,7 +5,7 @@ import { VariableChip } from '@/object-record/record-field/form-types/components import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { InputLabel } from '@/ui/input/components/InputLabel'; import { - InternalDatePicker, + DateTimePicker, MONTH_AND_YEAR_DROPDOWN_ID, MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID, @@ -351,11 +351,11 @@ export const FormDateTimeFieldInput = ({ - { + const handleClose = (newDate: Date | null) => { setInternalValue(newDate); onSubmit?.(newDate); }; @@ -104,16 +104,16 @@ export const DateInput = ({ return (
-
); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx index 69876727b6..d54d8a976a 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx @@ -270,7 +270,7 @@ const StyledButton = styled(MenuItemLeftContent)` justify-content: start; `; -type InternalDatePickerProps = { +type DateTimePickerProps = { isRelative?: boolean; hideHeaderInput?: boolean; date: Date | null; @@ -283,7 +283,7 @@ type InternalDatePickerProps = { start: Date; end: Date; }; - onMouseSelect?: (date: Date | null) => void; + onClose?: (date: Date | null) => void; onChange?: (date: Date | null) => void; onRelativeDateChange?: ( relativeDate: { @@ -300,10 +300,10 @@ type InternalDatePickerProps = { onClear?: () => void; }; -export const InternalDatePicker = ({ +export const DateTimePicker = ({ date, onChange, - onMouseSelect, + onClose, clearable = true, isDateTimeInput, onClear, @@ -312,7 +312,7 @@ export const InternalDatePicker = ({ onRelativeDateChange, highlightedDateRange, hideHeaderInput, -}: InternalDatePickerProps) => { +}: DateTimePickerProps) => { const internalDate = date ?? new Date(); const { timeZone } = useContext(UserContext); @@ -336,9 +336,9 @@ export const InternalDatePicker = ({ closeDropdown(); }; - const handleMouseSelect = (newDate: Date) => { + const handleClose = (newDate: Date) => { closeDropdowns(); - onMouseSelect?.(newDate); + onClose?.(newDate); }; const handleChangeMonth = (month: number) => { @@ -396,7 +396,7 @@ export const InternalDatePicker = ({ }) .toJSDate(); - handleMouseSelect?.(dateParsed); + handleClose?.(dateParsed); }; const dateWithoutTime = DateTime.fromJSDate(internalDate) diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx index 74d54be0fa..3bed375433 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx @@ -4,11 +4,11 @@ import { expect, userEvent, within } from '@storybook/test'; import { ComponentDecorator } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; -import { InternalDatePicker } from '../InternalDatePicker'; +import { DateTimePicker } from '../InternalDatePicker'; -const meta: Meta = { +const meta: Meta = { title: 'UI/Input/Internal/InternalDatePicker', - component: InternalDatePicker, + component: DateTimePicker, decorators: [ComponentDecorator], argTypes: { date: { control: 'date' }, @@ -17,7 +17,7 @@ const meta: Meta = { // eslint-disable-next-line react-hooks/rules-of-hooks const [, updateArgs] = useArgs(); return ( - updateArgs({ date: newDate })} /> @@ -27,7 +27,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts index 954712fdfa..956880cff5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts @@ -1,5 +1,7 @@ import { isPlainObject } from '@nestjs/common/utils/shared.utils'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; import { FieldMetadataType } from 'twenty-shared'; import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; @@ -12,10 +14,12 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met import { computeRelationType } from 'src/engine/twenty-orm/utils/compute-relation-type.util'; import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection'; import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util'; +import { isDate } from 'src/utils/date/isDate'; +import { isValidDate } from 'src/utils/date/isValidDate'; export function formatResult( data: any, - ObjectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps, objectMetadataMaps: ObjectMetadataMaps, ): T { if (!data) { @@ -24,7 +28,7 @@ export function formatResult( if (Array.isArray(data)) { return data.map((item) => - formatResult(item, ObjectMetadataItemWithFieldMaps, objectMetadataMaps), + formatResult(item, objectMetadataItemWithFieldMaps, objectMetadataMaps), ) as T; } @@ -32,12 +36,12 @@ export function formatResult( return data; } - if (!ObjectMetadataItemWithFieldMaps) { + if (!objectMetadataItemWithFieldMaps) { throw new Error('Object metadata is missing'); } const compositeFieldMetadataCollection = getCompositeFieldMetadataCollection( - ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldMaps, ); const compositeFieldMetadataMap = new Map( @@ -58,7 +62,7 @@ export function formatResult( ); const relationMetadataMap = new Map( - Object.values(ObjectMetadataItemWithFieldMaps.fieldsById) + Object.values(objectMetadataItemWithFieldMaps.fieldsById) .filter(({ type }) => isRelationFieldMetadataType(type)) .map((fieldMetadata) => [ fieldMetadata.name, @@ -76,7 +80,7 @@ export function formatResult( ); const newData: object = {}; const objectMetadaItemFieldsByName = - objectMetadataMaps.byId[ObjectMetadataItemWithFieldMaps.id]?.fieldsByName; + objectMetadataMaps.byId[objectMetadataItemWithFieldMaps.id]?.fieldsByName; for (const [key, value] of Object.entries(data)) { const compositePropertyArgs = compositeFieldMetadataMap.get(key); @@ -87,7 +91,7 @@ export function formatResult( if (isPlainObject(value)) { newData[key] = formatResult( value, - ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldMaps, objectMetadataMaps, ); } else if (objectMetadaItemFieldsByName[key]) { @@ -142,6 +146,56 @@ export function formatResult( newData[parentField][compositeProperty.name] = value; } + const dateFieldMetadataCollection = + objectMetadataItemWithFieldMaps.fields.filter( + (field) => field.type === FieldMetadataType.DATE, + ); + + // This is a temporary fix to handle a bug in the frontend where the date gets returned in the wrong timezone, + // thus returning the wrong date. + // + // In short, for example : + // - DB stores `2025-01-01` + // - TypeORM .returning() returns `2024-12-31T23:00:00.000Z` + // - we shift +1h (or whatever the timezone offset is on the server) + // - we return `2025-01-01T00:00:00.000Z` + // + // See this PR for more details: https://github.com/twentyhq/twenty/pull/9700 + const serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift = + new Date().getTimezoneOffset() * 60 * 1000; + + for (const dateFieldMetadata of dateFieldMetadataCollection) { + const rawUpdatedDate = newData[dateFieldMetadata.name] as + | string + | null + | undefined + | Date; + + if (!isDefined(rawUpdatedDate)) { + continue; + } + + if (isDate(rawUpdatedDate)) { + if (isValidDate(rawUpdatedDate)) { + const shiftedDate = new Date( + rawUpdatedDate.getTime() - + serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift, + ); + + newData[dateFieldMetadata.name] = shiftedDate; + } + } else if (isNonEmptyString(rawUpdatedDate)) { + const currentDate = new Date(newData[dateFieldMetadata.name]); + + const shiftedDate = new Date( + new Date(currentDate).getTime() - + serverOffsetInMillisecondsToCounterActTypeORMAutomaticTimezoneShift, + ); + + newData[dateFieldMetadata.name] = shiftedDate; + } + } + return newData as T; } diff --git a/packages/twenty-server/src/utils/date/isDate.ts b/packages/twenty-server/src/utils/date/isDate.ts new file mode 100644 index 0000000000..8c2b80f47f --- /dev/null +++ b/packages/twenty-server/src/utils/date/isDate.ts @@ -0,0 +1,3 @@ +export const isDate = (date: any): date is Date => { + return date instanceof Date; +}; diff --git a/packages/twenty-server/src/utils/date/isValidDate.ts b/packages/twenty-server/src/utils/date/isValidDate.ts new file mode 100644 index 0000000000..9f17752430 --- /dev/null +++ b/packages/twenty-server/src/utils/date/isValidDate.ts @@ -0,0 +1,3 @@ +export const isValidDate = (date: any): date is Date => { + return date instanceof Date && !isNaN(date.getTime()); +};