diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRecordInfo.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRecordInfo.tsx index fcf35e7eeed..96375595fd7 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRecordInfo.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRecordInfo.tsx @@ -1,3 +1,4 @@ +import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState'; import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState'; import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; @@ -11,15 +12,18 @@ import { recordStoreIdentifierFamilySelector } from '@/object-record/record-stor import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { Trans } from '@lingui/react/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { Avatar } from 'twenty-ui/display'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { + FeatureFlagKey, + FieldMetadataType, +} from '~/generated-metadata/graphql'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout'; -import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; export const CommandMenuRecordInfo = ({ commandMenuPageInstanceId, @@ -51,10 +55,15 @@ export const CommandMenuRecordInfo = ({ }), ); + const isFilesFieldMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + ); + const recordIdentifier = useRecoilValue( recordStoreIdentifierFamilySelector({ recordId: objectRecordId, allowRequestsToTwentyIcons, + isFilesFieldMigrated, }), ); @@ -80,7 +89,6 @@ export const CommandMenuRecordInfo = ({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ objectNameSingular, - objectRecordId, }); const fieldDefinition = { diff --git a/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx index e6403db11a5..330c04d4434 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/PreComputedChipGeneratorsProvider.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators'; -import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated/graphql'; export const PreComputedChipGeneratorsProvider = ({ children, @@ -13,13 +15,17 @@ export const PreComputedChipGeneratorsProvider = ({ const allowRequestsToTwentyIcons = useRecoilValue( allowRequestsToTwentyIconsState, ); + const isFilesFieldMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + ); const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } = useMemo(() => { return getRecordChipGenerators( objectMetadataItems, allowRequestsToTwentyIcons, + isFilesFieldMigrated, ); - }, [allowRequestsToTwentyIcons, objectMetadataItems]); + }, [allowRequestsToTwentyIcons, isFilesFieldMigrated, objectMetadataItems]); return ( <> diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts index 6b3ffc4f52e..e72f40fe91f 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getAvatarUrl.ts @@ -3,19 +3,21 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { REACT_APP_SERVER_BASE_URL } from '~/config'; -import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue'; +import { isNonEmptyString } from '@sniptt/guards'; import { getImageAbsoluteURI, getLogoUrlFromDomainName, isDefined, } from 'twenty-shared/utils'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue'; export const getAvatarUrl = ( objectNameSingular: string, record: ObjectRecord, imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined, allowRequestsToTwentyIcons?: boolean | undefined, + isFilesFieldMigrated?: boolean | undefined, ) => { if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) { return record.avatarUrl ?? undefined; @@ -31,7 +33,11 @@ export const getAvatarUrl = ( } if (objectNameSingular === CoreObjectNameSingular.Person) { - return isDefined(record.avatarUrl) + if (isFilesFieldMigrated === true) { + return record.avatarFile?.[0]?.url ?? ''; + } + + return isNonEmptyString(record.avatarUrl) ? getImageAbsoluteURI({ imageUrl: record.avatarUrl, baseUrl: REACT_APP_SERVER_BASE_URL, diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts index 82a4c53093c..0bac6a73315 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getImageIdentifierFieldMetadataItem.ts @@ -7,10 +7,12 @@ export const getImageIdentifierFieldMetadataItem = ( ObjectMetadataItem, 'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular' >, + isFilesFieldMigrated?: boolean, ): FieldMetadataItem | undefined => objectMetadataItem.fields.find((fieldMetadataItem) => isImageIdentifierField({ fieldMetadataItem, objectMetadataItem, + isFilesFieldMigrated, }), ); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index 29411b9b849..ce45d4af806 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -12,6 +12,7 @@ export const getObjectRecordIdentifier = ({ objectMetadataItem, record, allowRequestsToTwentyIcons, + isFilesFieldMigrated, }: { objectMetadataItem: Pick< ObjectMetadataItem, @@ -22,6 +23,7 @@ export const getObjectRecordIdentifier = ({ >; record: ObjectRecord; allowRequestsToTwentyIcons: boolean; + isFilesFieldMigrated?: boolean; }): ObjectRecordIdentifier => { const labelIdentifierFieldMetadataItem = getLabelIdentifierFieldMetadataItem(objectMetadataItem); @@ -43,6 +45,7 @@ export const getObjectRecordIdentifier = ({ record, imageIdentifierFieldMetadata, allowRequestsToTwentyIcons, + isFilesFieldMigrated, ); const linkToShowPage = getLinkToShowPage( diff --git a/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts b/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts index 3c6ef4ee19f..4607676a208 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/isImageIdentifierField.ts @@ -5,12 +5,14 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI export const isImageIdentifierField = ({ fieldMetadataItem, objectMetadataItem, + isFilesFieldMigrated, }: { fieldMetadataItem: Pick; objectMetadataItem: Pick< ObjectMetadataItem, 'imageIdentifierFieldMetadataId' | 'nameSingular' >; + isFilesFieldMigrated?: boolean; }) => { if ( objectMetadataItem.nameSingular === CoreObjectNameSingular.Company && @@ -19,6 +21,13 @@ export const isImageIdentifierField = ({ return true; } + if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Person) { + if (isFilesFieldMigrated === true) { + return fieldMetadataItem.name === 'avatarFile'; + } + return fieldMetadataItem.name === 'avatarUrl'; + } + return ( fieldMetadataItem.id === objectMetadataItem.imageIdentifierFieldMetadataId ); diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts index 0df0513cc01..41bdef5ab5a 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields.ts @@ -12,11 +12,14 @@ export const buildIdentifierGqlFields = ( | 'imageIdentifierFieldMetadataId' | 'nameSingular' >, + isFilesFieldMigrated?: boolean, ): RecordGqlFields => { const labelIdentifierField = getLabelIdentifierFieldMetadataItem(objectMetadata); - const imageIdentifierField = - getImageIdentifierFieldMetadataItem(objectMetadata); + const imageIdentifierField = getImageIdentifierFieldMetadataItem( + objectMetadata, + isFilesFieldMigrated, + ); return { id: true, diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts index fe6fe3c97dd..4cbc8d7875d 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields.ts @@ -27,6 +27,7 @@ export type GenerateDepthRecordGqlFieldsFromFields = { >[]; depth: 0 | 1; shouldOnlyLoadRelationIdentifiers?: boolean; + isFilesFieldMigrated?: boolean; }; export const generateDepthRecordGqlFieldsFromFields = ({ @@ -34,6 +35,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({ fields, depth, shouldOnlyLoadRelationIdentifiers = true, + isFilesFieldMigrated, }: GenerateDepthRecordGqlFieldsFromFields) => { const generatedRecordGqlFields: RecordGqlFields = fields.reduce( (recordGqlFields, fieldMetadata) => { @@ -83,6 +85,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({ const junctionGqlFields = generateJunctionRelationGqlFields({ fieldMetadataItem: fieldMetadata, objectMetadataItems, + isFilesFieldMigrated, }); if (isDefined(junctionGqlFields) && depth === 1) { @@ -97,7 +100,10 @@ export const generateDepthRecordGqlFieldsFromFields = ({ getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem); const imageIdentifierFieldMetadataItem = - getImageIdentifierFieldMetadataItem(targetObjectMetadataItem); + getImageIdentifierFieldMetadataItem( + targetObjectMetadataItem, + isFilesFieldMigrated, + ); const relationIdentifierSubGqlFields = { id: true, diff --git a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts index fee5a97fa39..9410a50b87c 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields.ts @@ -16,11 +16,13 @@ type JunctionFieldMetadataItem = Pick< type GenerateJunctionRelationGqlFieldsArgs = { fieldMetadataItem: JunctionFieldMetadataItem; objectMetadataItems: JunctionObjectMetadataItem[]; + isFilesFieldMigrated?: boolean; }; const buildRegularTargetFieldGqlFields = ( targetField: JunctionFieldMetadataItem, objectMetadataItems: JunctionObjectMetadataItem[], + isFilesFieldMigrated?: boolean, ): RecordGqlFields => { const targetObjectMetadata = objectMetadataItems.find( (item) => item.id === targetField.relation?.targetObjectMetadata.id, @@ -31,13 +33,17 @@ const buildRegularTargetFieldGqlFields = ( } return { - [targetField.name]: buildIdentifierGqlFields(targetObjectMetadata), + [targetField.name]: buildIdentifierGqlFields( + targetObjectMetadata, + isFilesFieldMigrated, + ), }; }; const buildMorphTargetFieldGqlFields = ( targetField: JunctionFieldMetadataItem, objectMetadataItems: JunctionObjectMetadataItem[], + isFilesFieldMigrated?: boolean, ): RecordGqlFields => { const morphRelations = targetField.morphRelations; @@ -63,7 +69,10 @@ const buildMorphTargetFieldGqlFields = ( targetObjectMetadataNamePlural: targetObjectMetadata.namePlural, }); - result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata); + result[computedFieldName] = buildIdentifierGqlFields( + targetObjectMetadata, + isFilesFieldMigrated, + ); } return result; @@ -72,17 +81,27 @@ const buildMorphTargetFieldGqlFields = ( const buildTargetFieldGqlFields = ( targetField: JunctionFieldMetadataItem, objectMetadataItems: JunctionObjectMetadataItem[], + isFilesFieldMigrated?: boolean, ): RecordGqlFields => { if (targetField.type === FieldMetadataType.MORPH_RELATION) { - return buildMorphTargetFieldGqlFields(targetField, objectMetadataItems); + return buildMorphTargetFieldGqlFields( + targetField, + objectMetadataItems, + isFilesFieldMigrated, + ); } - return buildRegularTargetFieldGqlFields(targetField, objectMetadataItems); + return buildRegularTargetFieldGqlFields( + targetField, + objectMetadataItems, + isFilesFieldMigrated, + ); }; // Generates GraphQL fields for a junction relation, including the nested target objects export const generateJunctionRelationGqlFields = ({ fieldMetadataItem, objectMetadataItems, + isFilesFieldMigrated, }: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => { const junctionConfig = getJunctionConfig({ settings: fieldMetadataItem.settings, @@ -100,13 +119,17 @@ export const generateJunctionRelationGqlFields = ({ const junctionTargetFields = targetFields.reduce( (acc, targetField) => ({ ...acc, - ...buildTargetFieldGqlFields(targetField, objectMetadataItems), + ...buildTargetFieldGqlFields( + targetField, + objectMetadataItems, + isFilesFieldMigrated, + ), }), {}, ); return { - ...buildIdentifierGqlFields(junctionObjectMetadata), + ...buildIdentifierGqlFields(junctionObjectMetadata, isFilesFieldMigrated), ...junctionTargetFields, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx index f82cef97548..37052b8017d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldList.tsx @@ -56,7 +56,6 @@ export const RecordFieldList = ({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ objectNameSingular, - objectRecordId, }); const isRecordReadOnly = useIsRecordReadOnly({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields.ts index 06895afd40f..7866c609f82 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields.ts @@ -9,7 +9,9 @@ import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/ import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; type UseRecordsFieldVisibleGqlFields = { objectMetadataItem: ObjectMetadataItem; @@ -29,6 +31,10 @@ export const useRecordsFieldVisibleGqlFields = ({ const { objectMetadataItems } = useObjectMetadataItems(); + const isFilesFieldMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + ); + const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({ objectMetadataItems, fields: visibleRecordFields.map( @@ -36,12 +42,15 @@ export const useRecordsFieldVisibleGqlFields = ({ fieldMetadataItemByFieldMetadataItemId[field.fieldMetadataItemId], ), depth: 1, + isFilesFieldMigrated, }); const labelIdentifierFieldMetadataItem = getLabelIdentifierFieldMetadataItem(objectMetadataItem); - const imageIdentifierFieldMetadataItem = - getImageIdentifierFieldMetadataItem(objectMetadataItem); + const imageIdentifierFieldMetadataItem = getImageIdentifierFieldMetadataItem( + objectMetadataItem, + isFilesFieldMigrated, + ); const hasPosition = hasObjectMetadataItemPositionField(objectMetadataItem); diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx index 125f558c1e3..24c2417b2ff 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/ObjectRecordShowPageBreadcrumb.tsx @@ -64,7 +64,6 @@ export const ObjectRecordShowPageBreadcrumb = ({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ objectNameSingular, - objectRecordId, }); const isLabelIdentifierReadOnly = useIsRecordFieldReadOnly({ diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx index 3a14f815fac..10ec9da8be5 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx @@ -1,8 +1,10 @@ +import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { usePersonAvatarUpload } from '@/object-record/record-show/hooks/usePersonAvatarUpload'; import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions'; import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; @@ -11,10 +13,11 @@ import { RecordTitleCell } from '@/object-record/record-title-cell/components/Re import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; +import { FeatureFlagKey } from '~/generated/graphql'; type SummaryCardProps = { objectNameSingular: string; @@ -41,12 +44,15 @@ export const SummaryCard = ({ const allowRequestsToTwentyIcons = useRecoilValue( allowRequestsToTwentyIconsState, ); + const isFilesFieldMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + ); - const { onUploadPicture, useUpdateOneObjectRecordMutation } = - useRecordShowContainerActions({ - objectNameSingular, - objectRecordId, - }); + const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ + objectNameSingular, + }); + + const { onUploadPicture } = usePersonAvatarUpload(objectRecordId); const isMobile = useIsMobile() || isInRightDrawer; @@ -54,6 +60,7 @@ export const SummaryCard = ({ recordStoreIdentifierFamilySelector({ recordId: objectRecordId, allowRequestsToTwentyIcons, + isFilesFieldMigrated, }), ); diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts new file mode 100644 index 00000000000..d92c1252249 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/usePersonAvatarUpload.ts @@ -0,0 +1,90 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { t } from '@lingui/core/macro'; +import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; +import { + FileFolder, + useUploadFilesFieldFileMutation, + useUploadImageMutation, +} from '~/generated-metadata/graphql'; +import { FeatureFlagKey, FieldMetadataType } from '~/generated/graphql'; + +export const usePersonAvatarUpload = (personRecordId: string) => { + const coreClient = useApolloCoreClient(); + const [uploadImage] = useUploadImageMutation(); + const [uploadFilesFieldFile] = useUploadFilesFieldFileMutation({ + client: coreClient, + }); + const { updateOneRecord } = useUpdateOneRecord(); + + const isFilesFieldMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + ); + + const { objectMetadataItem: personMetadata } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.Person, + }); + + const avatarFileFieldMetadataId = personMetadata.fields.find( + (field) => + field.type === FieldMetadataType.FILES && field.name === 'avatarFile', + )?.id; + + const onUploadPicture = async (file: File) => { + if (isFilesFieldMigrated) { + assertIsDefinedOrThrow( + avatarFileFieldMetadataId, + new Error(t`Avatar file field not found for person object`), + ); + + const result = await uploadFilesFieldFile({ + variables: { file, fieldMetadataId: avatarFileFieldMetadataId }, + }); + + const uploadedFile = result?.data?.uploadFilesFieldFile; + + if (!isDefined(uploadedFile)) { + return; + } + + await updateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Person, + idToUpdate: personRecordId, + updateOneRecordInput: { + avatarFile: [ + { + fileId: uploadedFile.id, + label: file.name, + }, + ], + }, + }); + } else { + const result = await uploadImage({ + variables: { + file, + fileFolder: FileFolder.PersonPicture, + }, + }); + + const avatarSignedFile = result?.data?.uploadImage; + + if (!avatarSignedFile) { + return; + } + + await updateOneRecord({ + objectNameSingular: CoreObjectNameSingular.Person, + idToUpdate: personRecordId, + updateOneRecordInput: { + avatarUrl: avatarSignedFile.path, + }, + }); + } + }; + + return { onUploadPicture }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerActions.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerActions.ts index 633282092a5..ed6462eee60 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerActions.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerActions.ts @@ -3,21 +3,14 @@ import { type RecordUpdateHook, type RecordUpdateHookParams, } from '@/object-record/record-field/ui/contexts/FieldContext'; -import { - FileFolder, - useUploadImageMutation, -} from '~/generated-metadata/graphql'; interface UseRecordShowContainerActionsProps { objectNameSingular: string; - objectRecordId: string; } export const useRecordShowContainerActions = ({ objectNameSingular, - objectRecordId, }: UseRecordShowContainerActionsProps) => { - const [uploadImage] = useUploadImageMutation(); const { updateOneRecord } = useUpdateOneRecord(); const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { @@ -32,35 +25,7 @@ export const useRecordShowContainerActions = ({ return [updateEntity, { loading: false }]; }; - const onUploadPicture = async (file: File) => { - if (objectNameSingular !== 'person') { - return; - } - - const result = await uploadImage({ - variables: { - file, - fileFolder: FileFolder.PersonPicture, - }, - }); - - const avatarSignedFile = result?.data?.uploadImage; - - if (!avatarSignedFile) { - return; - } - - await updateOneRecord({ - objectNameSingular, - idToUpdate: objectRecordId, - updateOneRecordInput: { - avatarUrl: avatarSignedFile.path, - }, - }); - }; - return { - onUploadPicture, useUpdateOneObjectRecordMutation, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreIdentifierSelector.ts b/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreIdentifierSelector.ts index 0c28fa6ea03..a524729f3e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreIdentifierSelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreIdentifierSelector.ts @@ -11,9 +11,11 @@ export const recordStoreIdentifierFamilySelector = selectorFamily({ ({ recordId, allowRequestsToTwentyIcons, + isFilesFieldMigrated, }: { recordId: string; allowRequestsToTwentyIcons: boolean; + isFilesFieldMigrated?: boolean; }) => ({ get }) => { const recordFromStore = get(recordStoreFamilyState(recordId)); @@ -35,6 +37,7 @@ export const recordStoreIdentifierFamilySelector = selectorFamily({ objectMetadataItem: objectMetadataItem, record: recordFromStore, allowRequestsToTwentyIcons, + isFilesFieldMigrated, }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts index 1184013180a..c83fd2e7f2b 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts @@ -17,6 +17,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export const getRecordChipGenerators = ( objectMetadataItems: ObjectMetadataItem[], allowRequestsToTwentyIcons?: boolean, + isFilesFieldMigrated?: boolean, ) => { const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName = {}; @@ -94,6 +95,7 @@ export const getRecordChipGenerators = ( record, imageIdentifierFieldMetadataToUse, allowRequestsToTwentyIcons, + isFilesFieldMigrated, ), avatarType, isLabelIdentifier, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx index 0a47d7857cb..a9bf9142436 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetActionFieldEdit.tsx @@ -51,7 +51,6 @@ export const WidgetActionFieldEdit = () => { const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ objectNameSingular: objectMetadataItem.nameSingular, - objectRecordId: targetRecord.id, }); const isRecordReadOnly = useIsRecordReadOnly({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx index 6d038f382d2..c4326aa9455 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx @@ -56,7 +56,6 @@ export const FieldWidgetDisplay = ({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ objectNameSingular: objectMetadataItem.nameSingular, - objectRecordId: recordId, }); const isRecordReadOnly = useIsRecordReadOnly({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx index 3a12e62bea5..b458f476571 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx @@ -76,7 +76,6 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => { const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ objectNameSingular: targetRecord.targetObjectNameSingular, - objectRecordId: targetRecord.id, }); const isRecordReadOnly = useIsRecordReadOnly({ diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index 6b52ffcc86d..2f7da824d10 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -142,6 +142,7 @@ "lodash.upperfirst": "4.3.1", "mailparser": "3.9.1", "microdiff": "1.4.0", + "mrmime": "^2.0.1", "ms": "2.1.3", "nest-commander": "^3.19.1", "node-ical": "^0.20.1", diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts new file mode 100644 index 00000000000..6fe5c7eec3b --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command.ts @@ -0,0 +1,298 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { Command } from 'nest-commander'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType, FileFolder } from 'twenty-shared/types'; +import { + extractFolderPathFilenameAndTypeOrThrow, + isDefined, +} from 'twenty-shared/utils'; +import { DataSource, Equal, IsNull, Not, Or, Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; + +@Command({ + name: 'upgrade:1-18:migrate-attachment-files', + description: + 'Migrate attachment files to file field: copy files and create file records', +}) +export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly featureFlagService: FeatureFlagService, + private readonly fileStorageService: FileStorageService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly fieldMetadataService: FieldMetadataService, + private readonly applicationService: ApplicationService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const isMigrated = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + workspaceId, + ); + + if (isMigrated) { + this.logger.log( + `Attachment files migration already completed for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Starting attachment files migration for workspace ${workspaceId}`, + ); + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const attachmentObjectMetadata = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier: STANDARD_OBJECTS.attachment.universalIdentifier, + }); + + if (!isDefined(attachmentObjectMetadata)) { + this.logger.warn( + `Attachment object metadata not found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + let attachmentFileflatFieldMetadata = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatFieldMetadataMaps, + universalIdentifier: + STANDARD_OBJECTS.attachment.fields.file.universalIdentifier, + }); + + if (!isDefined(attachmentFileflatFieldMetadata)) { + this.logger.log( + `File field metadata not found for attachments in workspace ${workspaceId}, creating it`, + ); + + if (!isDryRun) { + const attachmentFieldMetadatas = getFlatFieldsFromFlatObjectMetadata( + attachmentObjectMetadata, + flatFieldMetadataMaps, + ); + + const existingFieldWithSameName = attachmentFieldMetadatas.find( + (fieldMetadata) => fieldMetadata.name === 'file', + ); + + if (isDefined(existingFieldWithSameName)) { + this.logger.log( + `Found existing field with name 'file' (id: ${existingFieldWithSameName.id}), renaming it to 'old-file'`, + ); + + try { + await this.fieldMetadataService.updateOneField({ + updateFieldInput: { + id: existingFieldWithSameName.id, + name: 'oldFile', + label: 'Old File', + }, + workspaceId, + ownerFlatApplication: twentyStandardFlatApplication, + }); + } catch (error) { + this.logger.error( + `Failed to rename existing 'file' field to 'old-file' for workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + + this.logger.log( + `Renamed existing 'file' field to 'oldFile' for workspace ${workspaceId}`, + ); + } + + try { + attachmentFileflatFieldMetadata = + await this.fieldMetadataService.createOneField({ + createFieldInput: { + objectMetadataId: attachmentObjectMetadata.id, + type: FieldMetadataType.FILES, + name: 'file', + label: 'File', + description: 'Attachment file', + icon: 'IconFileUpload', + isNullable: true, + isUIReadOnly: true, + isSystem: true, + settings: { + maxNumberOfValues: 1, + }, + universalIdentifier: + STANDARD_OBJECTS.attachment.fields.file.universalIdentifier, + }, + workspaceId, + ownerFlatApplication: twentyStandardFlatApplication, + }); + } catch (error) { + this.logger.error( + `Failed to create file field metadata for attachments in workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + } + this.logger.log( + `Created file field metadata for attachments in workspace ${workspaceId}`, + ); + } + + if (!isDefined(attachmentFileflatFieldMetadata)) { + this.logger.error( + `File field metadata not found for attachments in workspace ${workspaceId}`, + ); + + return; + } + + const attachmentRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'attachment', + { shouldBypassPermissionChecks: true }, + ); + + const attachments = await attachmentRepository.find({ + where: { + fullPath: Not(IsNull()), + file: Or(IsNull(), Equal([])), + }, + select: ['id', 'name', 'fullPath'], + }); + + if (attachments.length === 0) { + this.logger.log(`No attachments to migrate for workspace ${workspaceId}`); + + return; + } + + this.logger.log( + `Found ${attachments.length} attachment(s) to migrate in workspace ${workspaceId}`, + ); + + const fileRepository = this.coreDataSource.getRepository(FileEntity); + + for (const attachment of attachments) { + if (!isNonEmptyString(attachment.fullPath)) { + this.logger.warn( + `Skipping attachment ${attachment.id} - invalid fullPath`, + ); + + continue; + } + + try { + const { type: fileExtension, filename } = + extractFolderPathFilenameAndTypeOrThrow(attachment.fullPath); + + const fileId = v4(); + const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`; + const newResourcePath = `${FileFolder.FilesField}/${attachmentFileflatFieldMetadata.universalIdentifier}/${newFilename}`; + + if (!isDryRun) { + await this.fileStorageService.copyLegacy({ + from: { + folderPath: `workspace-${workspaceId}`, + filename: attachment.fullPath, + }, + to: { + folderPath: `${workspaceId}/${twentyStandardFlatApplication.universalIdentifier}`, + filename: newResourcePath, + }, + }); + + const fileEntity = fileRepository.create({ + id: fileId, + path: newResourcePath, + workspaceId, + applicationId: twentyStandardFlatApplication.id, + size: -1, + settings: { + isTemporaryFile: true, + toDelete: false, + }, + }); + + await fileRepository.save(fileEntity); + + await attachmentRepository.update( + { id: attachment.id }, + { + file: [ + { + fileId: fileEntity.id, + label: attachment.name || filename, + }, + ], + }, + ); + } + + this.logger.log( + `Migrated attachment ${attachment.id} (${attachment.name})`, + ); + } catch (error) { + this.logger.error( + `Failed to migrate attachment ${attachment.id} in workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + } + + if (!isDryRun) { + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_FILES_FIELD_MIGRATED], + workspaceId, + ); + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Completed attachment files migration for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts new file mode 100644 index 00000000000..3a6da077652 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command.ts @@ -0,0 +1,296 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { Command } from 'nest-commander'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType, FileFolder } from 'twenty-shared/types'; +import { + assertIsDefinedOrThrow, + extractFolderPathFilenameAndTypeOrThrow, + isDefined, +} from 'twenty-shared/utils'; +import { DataSource, Equal, ILike, IsNull, Or, Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; + +@Command({ + name: 'upgrade:1-18:migrate-person-avatar-files', + description: + 'Migrate person avatarUrl files to file field: copy files and create file records', +}) +export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly featureFlagService: FeatureFlagService, + private readonly fileStorageService: FileStorageService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly fieldMetadataService: FieldMetadataService, + private readonly applicationService: ApplicationService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const isMigrated = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_FILES_FIELD_MIGRATED, + workspaceId, + ); + + if (isMigrated) { + this.logger.log( + `Person avatar files migration already completed for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${ + isDryRun ? '[DRY RUN] ' : '' + }Starting person avatar files migration for workspace ${workspaceId}`, + ); + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ]); + + const personObjectMetadata = + findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier: STANDARD_OBJECTS.person.universalIdentifier, + }); + + if (!isDefined(personObjectMetadata)) { + this.logger.warn( + `Person object metadata not found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + if (!isDefined(twentyStandardFlatApplication)) { + this.logger.error( + `Twenty standard application not found for workspace ${workspaceId}`, + ); + + return; + } + + let avatarFileFieldMetadata = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatFieldMetadataMaps, + universalIdentifier: + STANDARD_OBJECTS.person.fields.avatarFile.universalIdentifier, + }); + + if (!isDefined(avatarFileFieldMetadata)) { + this.logger.log( + `Avatar file field metadata not found for workspace ${workspaceId}, creating it`, + ); + + if (!isDryRun) { + const personFieldMetadatas = getFlatFieldsFromFlatObjectMetadata( + personObjectMetadata, + flatFieldMetadataMaps, + ); + + const existingFieldWithSameName = personFieldMetadatas.find( + (fieldMetadata) => fieldMetadata.name === 'avatarFile', + ); + + if (isDefined(existingFieldWithSameName)) { + this.logger.log( + `Found existing field with name 'avatarFile' (id: ${existingFieldWithSameName.id}), renaming it to 'old-avatarFile'`, + ); + + try { + await this.fieldMetadataService.updateOneField({ + updateFieldInput: { + id: existingFieldWithSameName.id, + name: 'oldAvatarFile', + label: 'Old Avatar File', + }, + workspaceId, + ownerFlatApplication: twentyStandardFlatApplication, + }); + } catch (error) { + this.logger.error( + `Failed to rename existing 'avatarFile' field to 'old-avatarFile' for workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + + this.logger.log( + `Renamed existing 'avatarFile' field to 'oldAvatarFile' for workspace ${workspaceId}`, + ); + } + + try { + avatarFileFieldMetadata = + await this.fieldMetadataService.createOneField({ + createFieldInput: { + objectMetadataId: personObjectMetadata.id, + type: FieldMetadataType.FILES, + name: 'avatarFile', + label: 'Avatar File', + description: "Contact's avatar file", + icon: 'IconFileUpload', + isSystem: true, + isNullable: true, + settings: { + maxNumberOfValues: 1, + }, + universalIdentifier: + STANDARD_OBJECTS.person.fields.avatarFile.universalIdentifier, + }, + workspaceId, + ownerFlatApplication: twentyStandardFlatApplication, + }); + } catch (error) { + this.logger.error( + `Failed to create avatarFile field metadata for workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + } + this.logger.log( + `Created avatarFile field metadata for workspace ${workspaceId}`, + ); + } + + if (!isDefined(avatarFileFieldMetadata)) { + this.logger.error( + `Avatar file field metadata not found for workspace ${workspaceId}`, + ); + + return; + } + + const personRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'person', + { shouldBypassPermissionChecks: true }, + ); + + const personsWithAvatars = await personRepository.find({ + where: { + avatarUrl: ILike(`%${FileFolder.PersonPicture}%`), + avatarFile: Or(IsNull(), Equal([])), + }, + select: ['id', 'avatarUrl'], + }); + + if (personsWithAvatars.length === 0) { + this.logger.log( + `No persons with avatarUrl found in workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Found ${personsWithAvatars.length} person(s) with avatarUrl containing 'people' folder in workspace ${workspaceId}`, + ); + + const fileRepository = this.coreDataSource.getRepository(FileEntity); + + for (const person of personsWithAvatars) { + assertIsDefinedOrThrow(person.avatarUrl); + + try { + const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow( + person.avatarUrl, + ); + + const fileId = v4(); + const newFileName = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`; + const newResourcePath = `${FileFolder.FilesField}/${avatarFileFieldMetadata.universalIdentifier}/${newFileName}`; + + if (!isDryRun) { + await this.fileStorageService.copyLegacy({ + from: { + folderPath: `workspace-${workspaceId}`, + filename: person.avatarUrl, + }, + to: { + folderPath: `${workspaceId}/${twentyStandardFlatApplication.universalIdentifier}`, + filename: newResourcePath, + }, + }); + + const fileEntity = fileRepository.create({ + id: fileId, + path: newResourcePath, + workspaceId, + applicationId: twentyStandardFlatApplication.id, + size: -1, + settings: { + isTemporaryFile: true, + toDelete: false, + }, + }); + + await fileRepository.save(fileEntity); + + await personRepository.update( + { id: person.id }, + { + avatarFile: [ + { + fileId: fileEntity.id, + label: newFileName, + }, + ], + }, + ); + } + + this.logger.log(`Migrated avatar for person ${person.id}`); + } catch (error) { + this.logger.error( + `Failed to migrate avatar for person ${person.id} in workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Completed person avatar files migration for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts new file mode 100644 index 00000000000..469ca1ad26a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command'; +import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; +import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + WorkspaceEntity, + FeatureFlagEntity, + PersonWorkspaceEntity, + FileEntity, + AttachmentWorkspaceEntity, + ]), + DataSourceModule, + FeatureFlagModule, + FileStorageModule.forRoot(), + WorkspaceCacheModule, + FieldMetadataModule, + ApplicationModule, + ], + providers: [MigratePersonAvatarFilesCommand, MigrateAttachmentFilesCommand], + exports: [MigratePersonAvatarFilesCommand, MigrateAttachmentFilesCommand], +}) +export class V1_18_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts index 5e96efb2977..20f05bdc836 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade-version-command.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { V1_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module'; +import { V1_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module'; import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; @@ -10,6 +11,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s imports: [ TypeOrmModule.forFeature([WorkspaceEntity]), V1_17_UpgradeVersionCommandModule, + V1_18_UpgradeVersionCommandModule, DataSourceModule, ], providers: [UpgradeCommand], diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 0aae8cff0ae..318e8918cdd 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -19,6 +19,7 @@ import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/comma import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command'; import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command'; import { MigrateWorkflowCodeStepsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-workflow-code-steps.command'; +import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -49,6 +50,9 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly makeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, protected readonly migrateWorkflowCodeStepsCommand: MigrateWorkflowCodeStepsCommand, protected readonly fixMorphRelationFieldNamesCommand: FixMorphRelationFieldNamesCommand, + + // 1.18 Commands + protected readonly migratePersonAvatarFilesCommand: MigratePersonAvatarFilesCommand, ) { super( workspaceRepository, @@ -74,9 +78,14 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.fixMorphRelationFieldNamesCommand, ]; + const commands_1180: VersionCommands = [ + this.migratePersonAvatarFilesCommand, + ]; + this.allCommands = { '1.16.0': commands_1160, '1.17.0': commands_1170, + '1.18.0': commands_1180, }; } diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770814914548-addMimeTypeToFileTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770814914548-addMimeTypeToFileTable.ts new file mode 100644 index 00000000000..240e99ea7ce --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770814914548-addMimeTypeToFileTable.ts @@ -0,0 +1,15 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddMimeTypeToFileTable1770814914548 implements MigrationInterface { + name = 'AddMimeTypeToFileTable1770814914548'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."file" ADD "mimeType" character varying NOT NULL DEFAULT 'application/octet-stream'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "core"."file" DROP COLUMN "mimeType"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index 35120926486..ed5b71e388c 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -114,6 +114,7 @@ export class FileStorageService { workspaceId, applicationId: application.id, id: fileId, + mimeType, size: typeof sourceFile === 'string' ? Buffer.byteLength(sourceFile) diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/interfaces/file-storage-exception.ts b/packages/twenty-server/src/engine/core-modules/file-storage/interfaces/file-storage-exception.ts index ec328df5cc4..874cfbb7095 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/interfaces/file-storage-exception.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/interfaces/file-storage-exception.ts @@ -7,12 +7,15 @@ import { CustomException } from 'src/utils/custom-exception'; export enum FileStorageExceptionCode { FILE_NOT_FOUND = 'FILE_NOT_FOUND', ACCESS_DENIED = 'ACCESS_DENIED', + INVALID_EXTENSION = 'INVALID_EXTENSION', } const getFileStorageExceptionUserFriendlyMessage = ( code: FileStorageExceptionCode, ) => { switch (code) { + case FileStorageExceptionCode.INVALID_EXTENSION: + return msg`Wrong extension for file.`; case FileStorageExceptionCode.FILE_NOT_FOUND: return msg`File not found.`; case FileStorageExceptionCode.ACCESS_DENIED: diff --git a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts index 18d7657ce42..27b400841a6 100644 --- a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts @@ -56,4 +56,11 @@ export class FileEntity extends WorkspaceRelatedEntity { @Column({ nullable: true, type: 'jsonb' }) settings: FileSettings | null; + + @Column({ + nullable: false, + type: 'varchar', + default: 'application/octet-stream', + }) + mimeType: string; } diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts index 7cc8d82e2c9..ce5de84f63c 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Readable } from 'stream'; import { msg } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; import { FileFolder } from 'twenty-shared/types'; import { Repository } from 'typeorm'; import { v4 } from 'uuid'; @@ -44,26 +45,23 @@ export class FilesFieldService { async uploadFile({ file, filename, - declaredMimeType, workspaceId, fieldMetadataId, }: { file: Buffer; filename: string; - declaredMimeType: string | undefined; workspaceId: string; fieldMetadataId: string; }): Promise { const { mimeType, ext } = await extractFileInfo({ file, - declaredMimeType, filename, }); const sanitizedFile = sanitizeFile({ file, ext, mimeType }); const fileId = v4(); - const name = `${fileId}${ext ? `.${ext}` : ''}`; + const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`; const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({ select: ['applicationId', 'universalIdentifier'], diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts index ae1db0a2a36..f7b5909473a 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts @@ -30,7 +30,7 @@ export class FilesFieldResolver { @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @Args({ name: 'file', type: () => GraphQLUpload }) - { createReadStream, filename, mimetype }: FileUpload, + { createReadStream, filename }: FileUpload, @Args({ name: 'fieldMetadataId', type: () => String, @@ -44,7 +44,6 @@ export class FilesFieldResolver { return await this.filesFieldService.uploadFile({ file: buffer, filename, - declaredMimeType: mimetype, workspaceId, fieldMetadataId, }); diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts index ae2708b202b..efdd025e5e0 100644 --- a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/extract-file-info.utils.spec.ts @@ -1,28 +1,35 @@ -import FileType from 'file-type'; - import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils'; -jest.mock('file-type', () => ({ - fromBuffer: jest.fn(), -})); +// Mock detectableMimeTypes to work around ESM/CommonJS interop issues in Jest +jest.mock('file-type', () => { + const actual = jest.requireActual('file-type'); + + return { + ...actual, + mimeTypes: actual.mimeTypes, + }; +}); describe('extractFileInfo', () => { - const mockBuffer = Buffer.from('test content'); + // Real PNG file header (magic numbers) + const pngBuffer = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, + ]); - beforeEach(() => { - jest.clearAllMocks(); - }); + // Real PDF file header + const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8'); - it('should use detected file type when available', async () => { - (FileType.fromBuffer as jest.Mock).mockResolvedValue({ - mime: 'image/png', - ext: 'png', - }); + // Plain text buffer (no magic numbers) + const textBuffer = Buffer.from('Hello, world!', 'utf-8'); + // Real ZIP file header (for testing docx, xlsx, etc.) + const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]); + + it('should detect PNG from buffer magic numbers', async () => { const result = await extractFileInfo({ - file: mockBuffer, - declaredMimeType: 'text/plain', - filename: 'test.txt', + file: pngBuffer, + filename: 'image.png', }); expect(result).toEqual({ @@ -31,31 +38,10 @@ describe('extractFileInfo', () => { }); }); - it('should fall back to declared values when file type is not detected', async () => { - (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); - + it('should detect PDF from buffer magic numbers', async () => { const result = await extractFileInfo({ - file: mockBuffer, - declaredMimeType: 'text/plain', - filename: 'test.txt', - }); - - expect(result).toEqual({ - mimeType: 'text/plain', - ext: 'txt', - }); - }); - - it('should handle missing declared mime type when file type is detected', async () => { - (FileType.fromBuffer as jest.Mock).mockResolvedValue({ - mime: 'application/pdf', - ext: 'pdf', - }); - - const result = await extractFileInfo({ - file: mockBuffer, - declaredMimeType: undefined, - filename: 'document', + file: pdfBuffer, + filename: 'document.pdf', }); expect(result).toEqual({ @@ -64,45 +50,133 @@ describe('extractFileInfo', () => { }); }); - it('should handle both mime type and extension being undefined', async () => { - (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); - + it('should use extension-based lookup for text files', async () => { const result = await extractFileInfo({ - file: mockBuffer, - declaredMimeType: undefined, + file: textBuffer, + filename: 'document.txt', + }); + + expect(result).toEqual({ + mimeType: 'text/plain', + ext: 'txt', + }); + }); + + it('should handle CSV files using extension', async () => { + const result = await extractFileInfo({ + file: textBuffer, + filename: 'data.csv', + }); + + expect(result).toEqual({ + mimeType: 'text/csv', + ext: 'csv', + }); + }); + + it('should handle JSON files using extension', async () => { + const result = await extractFileInfo({ + file: Buffer.from('{"key": "value"}'), + filename: 'config.json', + }); + + expect(result).toEqual({ + mimeType: 'application/json', + ext: 'json', + }); + }); + + it('should return application/octet-stream for unknown extensions', async () => { + const result = await extractFileInfo({ + file: textBuffer, + filename: 'file.unknown', + }); + + expect(result).toEqual({ + mimeType: 'application/octet-stream', + ext: 'unknown', + }); + }); + + it('should return application/octet-stream for files without extension', async () => { + const result = await extractFileInfo({ + file: textBuffer, filename: 'file-without-extension', }); expect(result).toEqual({ - mimeType: undefined, + mimeType: 'application/octet-stream', ext: '', }); }); - it('should handle filenames with multiple dots', async () => { - (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); - + it('should detect ZIP files from buffer', async () => { const result = await extractFileInfo({ - file: mockBuffer, - declaredMimeType: 'application/gzip', - filename: 'archive.tar.gz', + file: zipBuffer, + filename: 'archive.zip', }); expect(result).toEqual({ - mimeType: 'application/gzip', - ext: 'gz', + mimeType: 'application/zip', + ext: 'zip', }); }); - it('should call FileType.fromBuffer with the provided buffer', async () => { - (FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); + it('should throw error when PNG extension is used with non-PNG buffer', async () => { + await expect( + extractFileInfo({ + file: textBuffer, + filename: 'fake-image.png', + }), + ).rejects.toThrow( + "File content does not match its extension. The file has extension 'png' (expected mime type: image/png), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.", + ); + }); - await extractFileInfo({ - file: mockBuffer, - declaredMimeType: 'image/png', - filename: 'image.png', + it('should throw error when PDF extension is used with non-PDF buffer', async () => { + await expect( + extractFileInfo({ + file: textBuffer, + filename: 'fake-document.pdf', + }), + ).rejects.toThrow( + "File content does not match its extension. The file has extension 'pdf' (expected mime type: application/pdf), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.", + ); + }); + + it('should handle markdown files using extension', async () => { + const result = await extractFileInfo({ + file: Buffer.from('# Heading\n\nContent'), + filename: 'README.md', }); - expect(FileType.fromBuffer).toHaveBeenCalledWith(mockBuffer); + expect(result).toEqual({ + mimeType: 'text/markdown', + ext: 'md', + }); + }); + + it('should handle HTML files using extension', async () => { + const result = await extractFileInfo({ + file: Buffer.from('Test'), + filename: 'index.html', + }); + + expect(result).toEqual({ + mimeType: 'text/html', + ext: 'html', + }); + }); + + it('should prefer detected type over declared extension', async () => { + const result = await extractFileInfo({ + file: pngBuffer, + filename: 'image.txt', + }); + + expect(result).toEqual({ + mimeType: 'image/png', + ext: 'png', + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts b/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts index 297b79e7221..cb1c5a4e8cf 100644 --- a/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/file/utils/extract-file-info.utils.ts @@ -1,23 +1,57 @@ -import FileType from 'file-type'; +import { msg } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import FileType, { type MimeType } from 'file-type'; +import { lookup } from 'mrmime'; +import { isDefined } from 'twenty-shared/utils'; + +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils'; export const extractFileInfo = async ({ file, - declaredMimeType, filename, }: { file: Buffer; - declaredMimeType: string | undefined; filename: string; }) => { const { ext: declaredExt } = buildFileInfo(filename); - const detectedFileType = await FileType.fromBuffer(file); + const { ext: detectedExt, mime: detectedMime } = + (await FileType.fromBuffer(file)) ?? {}; - const mimeType = detectedFileType?.mime ?? declaredMimeType; + if (isDefined(detectedExt) && isDefined(detectedMime)) { + return { + mimeType: detectedMime, + ext: detectedExt, + }; + } - const ext = detectedFileType?.ext ?? declaredExt; + const ext = declaredExt; + + let mimeType: string = 'application/octet-stream'; + + if (isNonEmptyString(ext)) { + const mimeTypeFromExtension = lookup(ext); + + if ( + mimeTypeFromExtension && + FileType.mimeTypes.has(mimeTypeFromExtension as MimeType) + ) { + throw new FileStorageException( + `File content does not match its extension. The file has extension '${ext}' (expected mime type: ${mimeTypeFromExtension}), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.`, + FileStorageExceptionCode.INVALID_EXTENSION, + { + userFriendlyMessage: msg`The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.`, + }, + ); + } + + mimeType = mimeTypeFromExtension ?? 'application/octet-stream'; + } return { mimeType, diff --git a/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts b/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts index c1091507ef3..39a15f64f02 100644 --- a/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts +++ b/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts @@ -134,6 +134,14 @@ export class FilesFieldSync { return null; } + const isModifyingFilesField = filesFields.some((filesField) => + isDefined(updatePayload[filesField.name as keyof typeof updatePayload]), + ); + + if (!isModifyingFilesField) { + return null; + } + if (existingRecords.length !== 1) { throw new TwentyORMException( `Cannot update multiple records with files field at once`, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap index d65449c0414..541225ac25f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/__tests__/__snapshots__/get-standard-object-metadata-related-entity-ids.util.spec.ts.snap @@ -1144,25 +1144,28 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "person": { "fields": { "attachments": { - "id": "00000000-0000-0000-0000-000000000370", + "id": "00000000-0000-0000-0000-000000000371", + }, + "avatarFile": { + "id": "00000000-0000-0000-0000-000000000362", }, "avatarUrl": { "id": "00000000-0000-0000-0000-000000000361", }, "calendarEventParticipants": { - "id": "00000000-0000-0000-0000-000000000372", + "id": "00000000-0000-0000-0000-000000000373", }, "city": { "id": "00000000-0000-0000-0000-000000000360", }, "company": { - "id": "00000000-0000-0000-0000-000000000365", + "id": "00000000-0000-0000-0000-000000000366", }, "createdAt": { "id": "00000000-0000-0000-0000-000000000351", }, "createdBy": { - "id": "00000000-0000-0000-0000-000000000363", + "id": "00000000-0000-0000-0000-000000000364", }, "deletedAt": { "id": "00000000-0000-0000-0000-000000000353", @@ -1171,7 +1174,7 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "id": "00000000-0000-0000-0000-000000000355", }, "favorites": { - "id": "00000000-0000-0000-0000-000000000369", + "id": "00000000-0000-0000-0000-000000000370", }, "id": { "id": "00000000-0000-0000-0000-000000000350", @@ -1183,77 +1186,77 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "id": "00000000-0000-0000-0000-000000000356", }, "messageParticipants": { - "id": "00000000-0000-0000-0000-000000000371", + "id": "00000000-0000-0000-0000-000000000372", }, "name": { "id": "00000000-0000-0000-0000-000000000354", }, "noteTargets": { - "id": "00000000-0000-0000-0000-000000000368", + "id": "00000000-0000-0000-0000-000000000369", }, "phones": { "id": "00000000-0000-0000-0000-000000000359", }, "pointOfContactForOpportunities": { - "id": "00000000-0000-0000-0000-000000000366", - }, - "position": { - "id": "00000000-0000-0000-0000-000000000362", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000374", - }, - "taskTargets": { "id": "00000000-0000-0000-0000-000000000367", }, + "position": { + "id": "00000000-0000-0000-0000-000000000363", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000375", + }, + "taskTargets": { + "id": "00000000-0000-0000-0000-000000000368", + }, "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000373", + "id": "00000000-0000-0000-0000-000000000374", }, "updatedAt": { "id": "00000000-0000-0000-0000-000000000352", }, "updatedBy": { - "id": "00000000-0000-0000-0000-000000000364", + "id": "00000000-0000-0000-0000-000000000365", }, "xLink": { "id": "00000000-0000-0000-0000-000000000357", }, }, - "id": "00000000-0000-0000-0000-000000000386", + "id": "00000000-0000-0000-0000-000000000387", "views": { "allPeople": { - "id": "00000000-0000-0000-0000-000000000385", + "id": "00000000-0000-0000-0000-000000000386", "viewFields": { "city": { - "id": "00000000-0000-0000-0000-000000000381", - }, - "company": { - "id": "00000000-0000-0000-0000-000000000378", - }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000380", - }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000377", - }, - "emails": { - "id": "00000000-0000-0000-0000-000000000376", - }, - "jobTitle": { "id": "00000000-0000-0000-0000-000000000382", }, - "linkedinLink": { - "id": "00000000-0000-0000-0000-000000000383", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000375", - }, - "phones": { + "company": { "id": "00000000-0000-0000-0000-000000000379", }, - "xLink": { + "createdAt": { + "id": "00000000-0000-0000-0000-000000000381", + }, + "createdBy": { + "id": "00000000-0000-0000-0000-000000000378", + }, + "emails": { + "id": "00000000-0000-0000-0000-000000000377", + }, + "jobTitle": { + "id": "00000000-0000-0000-0000-000000000383", + }, + "linkedinLink": { "id": "00000000-0000-0000-0000-000000000384", }, + "name": { + "id": "00000000-0000-0000-0000-000000000376", + }, + "phones": { + "id": "00000000-0000-0000-0000-000000000380", + }, + "xLink": { + "id": "00000000-0000-0000-0000-000000000385", + }, }, "viewGroups": {}, }, @@ -1262,157 +1265,157 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "task": { "fields": { "assignee": { - "id": "00000000-0000-0000-0000-000000000400", - }, - "attachments": { - "id": "00000000-0000-0000-0000-000000000399", - }, - "bodyV2": { - "id": "00000000-0000-0000-0000-000000000393", - }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000388", - }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000396", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000390", - }, - "dueAt": { - "id": "00000000-0000-0000-0000-000000000394", - }, - "favorites": { - "id": "00000000-0000-0000-0000-000000000402", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000387", - }, - "position": { - "id": "00000000-0000-0000-0000-000000000391", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000403", - }, - "status": { - "id": "00000000-0000-0000-0000-000000000395", - }, - "taskTargets": { - "id": "00000000-0000-0000-0000-000000000398", - }, - "timelineActivities": { "id": "00000000-0000-0000-0000-000000000401", }, - "title": { - "id": "00000000-0000-0000-0000-000000000392", + "attachments": { + "id": "00000000-0000-0000-0000-000000000400", }, - "updatedAt": { + "bodyV2": { + "id": "00000000-0000-0000-0000-000000000394", + }, + "createdAt": { "id": "00000000-0000-0000-0000-000000000389", }, - "updatedBy": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000397", }, + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000391", + }, + "dueAt": { + "id": "00000000-0000-0000-0000-000000000395", + }, + "favorites": { + "id": "00000000-0000-0000-0000-000000000403", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000388", + }, + "position": { + "id": "00000000-0000-0000-0000-000000000392", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000404", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000396", + }, + "taskTargets": { + "id": "00000000-0000-0000-0000-000000000399", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000402", + }, + "title": { + "id": "00000000-0000-0000-0000-000000000393", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000390", + }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000398", + }, }, - "id": "00000000-0000-0000-0000-000000000434", + "id": "00000000-0000-0000-0000-000000000435", "views": { "allTasks": { - "id": "00000000-0000-0000-0000-000000000412", + "id": "00000000-0000-0000-0000-000000000413", "viewFields": { "assignee": { - "id": "00000000-0000-0000-0000-000000000409", - }, - "bodyV2": { "id": "00000000-0000-0000-0000-000000000410", }, - "createdAt": { + "bodyV2": { "id": "00000000-0000-0000-0000-000000000411", }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000407", + "createdAt": { + "id": "00000000-0000-0000-0000-000000000412", }, - "dueAt": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000408", }, - "status": { - "id": "00000000-0000-0000-0000-000000000405", + "dueAt": { + "id": "00000000-0000-0000-0000-000000000409", }, - "taskTargets": { + "status": { "id": "00000000-0000-0000-0000-000000000406", }, + "taskTargets": { + "id": "00000000-0000-0000-0000-000000000407", + }, "title": { - "id": "00000000-0000-0000-0000-000000000404", + "id": "00000000-0000-0000-0000-000000000405", }, }, "viewGroups": {}, }, "assignedToMe": { - "id": "00000000-0000-0000-0000-000000000424", + "id": "00000000-0000-0000-0000-000000000425", "viewFields": { "assignee": { - "id": "00000000-0000-0000-0000-000000000417", - }, - "bodyV2": { "id": "00000000-0000-0000-0000-000000000418", }, - "createdAt": { + "bodyV2": { "id": "00000000-0000-0000-0000-000000000419", }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000415", + "createdAt": { + "id": "00000000-0000-0000-0000-000000000420", }, - "dueAt": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000416", }, + "dueAt": { + "id": "00000000-0000-0000-0000-000000000417", + }, "taskTargets": { - "id": "00000000-0000-0000-0000-000000000414", + "id": "00000000-0000-0000-0000-000000000415", }, "title": { - "id": "00000000-0000-0000-0000-000000000413", + "id": "00000000-0000-0000-0000-000000000414", }, }, "viewGroups": { "done": { - "id": "00000000-0000-0000-0000-000000000422", - }, - "empty": { "id": "00000000-0000-0000-0000-000000000423", }, + "empty": { + "id": "00000000-0000-0000-0000-000000000424", + }, "inProgress": { - "id": "00000000-0000-0000-0000-000000000421", + "id": "00000000-0000-0000-0000-000000000422", }, "todo": { - "id": "00000000-0000-0000-0000-000000000420", + "id": "00000000-0000-0000-0000-000000000421", }, }, }, "byStatus": { - "id": "00000000-0000-0000-0000-000000000433", + "id": "00000000-0000-0000-0000-000000000434", "viewFields": { "assignee": { - "id": "00000000-0000-0000-0000-000000000428", - }, - "createdAt": { "id": "00000000-0000-0000-0000-000000000429", }, + "createdAt": { + "id": "00000000-0000-0000-0000-000000000430", + }, "dueAt": { - "id": "00000000-0000-0000-0000-000000000427", + "id": "00000000-0000-0000-0000-000000000428", }, "status": { - "id": "00000000-0000-0000-0000-000000000426", + "id": "00000000-0000-0000-0000-000000000427", }, "title": { - "id": "00000000-0000-0000-0000-000000000425", + "id": "00000000-0000-0000-0000-000000000426", }, }, "viewGroups": { "done": { - "id": "00000000-0000-0000-0000-000000000432", + "id": "00000000-0000-0000-0000-000000000433", }, "inProgress": { - "id": "00000000-0000-0000-0000-000000000431", + "id": "00000000-0000-0000-0000-000000000432", }, "todo": { - "id": "00000000-0000-0000-0000-000000000430", + "id": "00000000-0000-0000-0000-000000000431", }, }, }, @@ -1421,116 +1424,116 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "taskTarget": { "fields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000436", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000438", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000435", - }, - "targetCompany": { - "id": "00000000-0000-0000-0000-000000000441", - }, - "targetOpportunity": { - "id": "00000000-0000-0000-0000-000000000442", - }, - "targetPerson": { - "id": "00000000-0000-0000-0000-000000000440", - }, - "task": { - "id": "00000000-0000-0000-0000-000000000439", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000437", }, + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000439", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000436", + }, + "targetCompany": { + "id": "00000000-0000-0000-0000-000000000442", + }, + "targetOpportunity": { + "id": "00000000-0000-0000-0000-000000000443", + }, + "targetPerson": { + "id": "00000000-0000-0000-0000-000000000441", + }, + "task": { + "id": "00000000-0000-0000-0000-000000000440", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000438", + }, }, - "id": "00000000-0000-0000-0000-000000000443", + "id": "00000000-0000-0000-0000-000000000444", "views": undefined, }, "timelineActivity": { "fields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000445", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000447", - }, - "happensAt": { - "id": "00000000-0000-0000-0000-000000000448", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000444", - }, - "linkedObjectMetadataId": { - "id": "00000000-0000-0000-0000-000000000463", - }, - "linkedRecordCachedName": { - "id": "00000000-0000-0000-0000-000000000461", - }, - "linkedRecordId": { - "id": "00000000-0000-0000-0000-000000000462", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000449", - }, - "properties": { - "id": "00000000-0000-0000-0000-000000000450", - }, - "targetCompany": { - "id": "00000000-0000-0000-0000-000000000453", - }, - "targetDashboard": { - "id": "00000000-0000-0000-0000-000000000460", - }, - "targetNote": { - "id": "00000000-0000-0000-0000-000000000456", - }, - "targetOpportunity": { - "id": "00000000-0000-0000-0000-000000000454", - }, - "targetPerson": { - "id": "00000000-0000-0000-0000-000000000452", - }, - "targetTask": { - "id": "00000000-0000-0000-0000-000000000455", - }, - "targetWorkflow": { - "id": "00000000-0000-0000-0000-000000000457", - }, - "targetWorkflowRun": { - "id": "00000000-0000-0000-0000-000000000459", - }, - "targetWorkflowVersion": { - "id": "00000000-0000-0000-0000-000000000458", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000446", }, - "workspaceMember": { + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000448", + }, + "happensAt": { + "id": "00000000-0000-0000-0000-000000000449", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000445", + }, + "linkedObjectMetadataId": { + "id": "00000000-0000-0000-0000-000000000464", + }, + "linkedRecordCachedName": { + "id": "00000000-0000-0000-0000-000000000462", + }, + "linkedRecordId": { + "id": "00000000-0000-0000-0000-000000000463", + }, + "name": { + "id": "00000000-0000-0000-0000-000000000450", + }, + "properties": { "id": "00000000-0000-0000-0000-000000000451", }, + "targetCompany": { + "id": "00000000-0000-0000-0000-000000000454", + }, + "targetDashboard": { + "id": "00000000-0000-0000-0000-000000000461", + }, + "targetNote": { + "id": "00000000-0000-0000-0000-000000000457", + }, + "targetOpportunity": { + "id": "00000000-0000-0000-0000-000000000455", + }, + "targetPerson": { + "id": "00000000-0000-0000-0000-000000000453", + }, + "targetTask": { + "id": "00000000-0000-0000-0000-000000000456", + }, + "targetWorkflow": { + "id": "00000000-0000-0000-0000-000000000458", + }, + "targetWorkflowRun": { + "id": "00000000-0000-0000-0000-000000000460", + }, + "targetWorkflowVersion": { + "id": "00000000-0000-0000-0000-000000000459", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000447", + }, + "workspaceMember": { + "id": "00000000-0000-0000-0000-000000000452", + }, }, - "id": "00000000-0000-0000-0000-000000000470", + "id": "00000000-0000-0000-0000-000000000471", "views": { "allTimelineActivities": { - "id": "00000000-0000-0000-0000-000000000469", + "id": "00000000-0000-0000-0000-000000000470", "viewFields": { "happensAt": { - "id": "00000000-0000-0000-0000-000000000465", - }, - "linkedRecordCachedName": { - "id": "00000000-0000-0000-0000-000000000468", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000464", - }, - "properties": { "id": "00000000-0000-0000-0000-000000000466", }, - "workspaceMember": { + "linkedRecordCachedName": { + "id": "00000000-0000-0000-0000-000000000469", + }, + "name": { + "id": "00000000-0000-0000-0000-000000000465", + }, + "properties": { "id": "00000000-0000-0000-0000-000000000467", }, + "workspaceMember": { + "id": "00000000-0000-0000-0000-000000000468", + }, }, "viewGroups": {}, }, @@ -1539,79 +1542,79 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "workflow": { "fields": { "attachments": { - "id": "00000000-0000-0000-0000-000000000484", - }, - "automatedTriggers": { - "id": "00000000-0000-0000-0000-000000000481", - }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000472", - }, - "createdBy": { "id": "00000000-0000-0000-0000-000000000485", }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000474", - }, - "favorites": { + "automatedTriggers": { "id": "00000000-0000-0000-0000-000000000482", }, - "id": { - "id": "00000000-0000-0000-0000-000000000471", - }, - "lastPublishedVersionId": { - "id": "00000000-0000-0000-0000-000000000476", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000475", - }, - "position": { - "id": "00000000-0000-0000-0000-000000000478", - }, - "runs": { - "id": "00000000-0000-0000-0000-000000000480", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000487", - }, - "statuses": { - "id": "00000000-0000-0000-0000-000000000477", - }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000483", - }, - "updatedAt": { + "createdAt": { "id": "00000000-0000-0000-0000-000000000473", }, - "updatedBy": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000486", }, - "versions": { + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000475", + }, + "favorites": { + "id": "00000000-0000-0000-0000-000000000483", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000472", + }, + "lastPublishedVersionId": { + "id": "00000000-0000-0000-0000-000000000477", + }, + "name": { + "id": "00000000-0000-0000-0000-000000000476", + }, + "position": { "id": "00000000-0000-0000-0000-000000000479", }, + "runs": { + "id": "00000000-0000-0000-0000-000000000481", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000488", + }, + "statuses": { + "id": "00000000-0000-0000-0000-000000000478", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000484", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000474", + }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000487", + }, + "versions": { + "id": "00000000-0000-0000-0000-000000000480", + }, }, - "id": "00000000-0000-0000-0000-000000000495", + "id": "00000000-0000-0000-0000-000000000496", "views": { "allWorkflows": { - "id": "00000000-0000-0000-0000-000000000494", + "id": "00000000-0000-0000-0000-000000000495", "viewFields": { "createdBy": { - "id": "00000000-0000-0000-0000-000000000491", + "id": "00000000-0000-0000-0000-000000000492", }, "name": { - "id": "00000000-0000-0000-0000-000000000488", - }, - "runs": { - "id": "00000000-0000-0000-0000-000000000493", - }, - "statuses": { "id": "00000000-0000-0000-0000-000000000489", }, - "updatedAt": { + "runs": { + "id": "00000000-0000-0000-0000-000000000494", + }, + "statuses": { "id": "00000000-0000-0000-0000-000000000490", }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000491", + }, "versions": { - "id": "00000000-0000-0000-0000-000000000492", + "id": "00000000-0000-0000-0000-000000000493", }, }, "viewGroups": {}, @@ -1621,115 +1624,115 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "workflowAutomatedTrigger": { "fields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000497", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000499", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000496", - }, - "settings": { - "id": "00000000-0000-0000-0000-000000000501", - }, - "type": { - "id": "00000000-0000-0000-0000-000000000500", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000498", }, - "workflow": { + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000500", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000497", + }, + "settings": { "id": "00000000-0000-0000-0000-000000000502", }, + "type": { + "id": "00000000-0000-0000-0000-000000000501", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000499", + }, + "workflow": { + "id": "00000000-0000-0000-0000-000000000503", + }, }, - "id": "00000000-0000-0000-0000-000000000503", + "id": "00000000-0000-0000-0000-000000000504", "views": undefined, }, "workflowRun": { "fields": { "context": { - "id": "00000000-0000-0000-0000-000000000519", - }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000505", - }, - "createdBy": { - "id": "00000000-0000-0000-0000-000000000516", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000507", - }, - "endedAt": { - "id": "00000000-0000-0000-0000-000000000513", - }, - "enqueuedAt": { - "id": "00000000-0000-0000-0000-000000000511", - }, - "favorites": { - "id": "00000000-0000-0000-0000-000000000521", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000504", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000508", - }, - "output": { - "id": "00000000-0000-0000-0000-000000000518", - }, - "position": { - "id": "00000000-0000-0000-0000-000000000515", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000523", - }, - "startedAt": { - "id": "00000000-0000-0000-0000-000000000512", - }, - "state": { "id": "00000000-0000-0000-0000-000000000520", }, - "status": { - "id": "00000000-0000-0000-0000-000000000514", - }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000522", - }, - "updatedAt": { + "createdAt": { "id": "00000000-0000-0000-0000-000000000506", }, - "updatedBy": { + "createdBy": { "id": "00000000-0000-0000-0000-000000000517", }, - "workflow": { - "id": "00000000-0000-0000-0000-000000000510", + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000508", }, - "workflowVersion": { + "endedAt": { + "id": "00000000-0000-0000-0000-000000000514", + }, + "enqueuedAt": { + "id": "00000000-0000-0000-0000-000000000512", + }, + "favorites": { + "id": "00000000-0000-0000-0000-000000000522", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000505", + }, + "name": { "id": "00000000-0000-0000-0000-000000000509", }, + "output": { + "id": "00000000-0000-0000-0000-000000000519", + }, + "position": { + "id": "00000000-0000-0000-0000-000000000516", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000524", + }, + "startedAt": { + "id": "00000000-0000-0000-0000-000000000513", + }, + "state": { + "id": "00000000-0000-0000-0000-000000000521", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000515", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000523", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000507", + }, + "updatedBy": { + "id": "00000000-0000-0000-0000-000000000518", + }, + "workflow": { + "id": "00000000-0000-0000-0000-000000000511", + }, + "workflowVersion": { + "id": "00000000-0000-0000-0000-000000000510", + }, }, - "id": "00000000-0000-0000-0000-000000000531", + "id": "00000000-0000-0000-0000-000000000532", "views": { "allWorkflowRuns": { - "id": "00000000-0000-0000-0000-000000000530", + "id": "00000000-0000-0000-0000-000000000531", "viewFields": { "createdBy": { - "id": "00000000-0000-0000-0000-000000000528", + "id": "00000000-0000-0000-0000-000000000529", }, "name": { - "id": "00000000-0000-0000-0000-000000000524", - }, - "startedAt": { - "id": "00000000-0000-0000-0000-000000000527", - }, - "status": { - "id": "00000000-0000-0000-0000-000000000526", - }, - "workflow": { "id": "00000000-0000-0000-0000-000000000525", }, + "startedAt": { + "id": "00000000-0000-0000-0000-000000000528", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000527", + }, + "workflow": { + "id": "00000000-0000-0000-0000-000000000526", + }, "workflowVersion": { - "id": "00000000-0000-0000-0000-000000000529", + "id": "00000000-0000-0000-0000-000000000530", }, }, "viewGroups": {}, @@ -1739,67 +1742,67 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "workflowVersion": { "fields": { "createdAt": { - "id": "00000000-0000-0000-0000-000000000533", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000535", - }, - "favorites": { - "id": "00000000-0000-0000-0000-000000000543", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000532", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000536", - }, - "position": { - "id": "00000000-0000-0000-0000-000000000540", - }, - "runs": { - "id": "00000000-0000-0000-0000-000000000541", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000545", - }, - "status": { - "id": "00000000-0000-0000-0000-000000000539", - }, - "steps": { - "id": "00000000-0000-0000-0000-000000000542", - }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000544", - }, - "trigger": { - "id": "00000000-0000-0000-0000-000000000538", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000534", }, - "workflow": { + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000536", + }, + "favorites": { + "id": "00000000-0000-0000-0000-000000000544", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000533", + }, + "name": { "id": "00000000-0000-0000-0000-000000000537", }, + "position": { + "id": "00000000-0000-0000-0000-000000000541", + }, + "runs": { + "id": "00000000-0000-0000-0000-000000000542", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000546", + }, + "status": { + "id": "00000000-0000-0000-0000-000000000540", + }, + "steps": { + "id": "00000000-0000-0000-0000-000000000543", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000545", + }, + "trigger": { + "id": "00000000-0000-0000-0000-000000000539", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000535", + }, + "workflow": { + "id": "00000000-0000-0000-0000-000000000538", + }, }, - "id": "00000000-0000-0000-0000-000000000552", + "id": "00000000-0000-0000-0000-000000000553", "views": { "allWorkflowVersions": { - "id": "00000000-0000-0000-0000-000000000551", + "id": "00000000-0000-0000-0000-000000000552", "viewFields": { "name": { - "id": "00000000-0000-0000-0000-000000000546", + "id": "00000000-0000-0000-0000-000000000547", }, "runs": { - "id": "00000000-0000-0000-0000-000000000550", + "id": "00000000-0000-0000-0000-000000000551", }, "status": { - "id": "00000000-0000-0000-0000-000000000548", - }, - "updatedAt": { "id": "00000000-0000-0000-0000-000000000549", }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000550", + }, "workflow": { - "id": "00000000-0000-0000-0000-000000000547", + "id": "00000000-0000-0000-0000-000000000548", }, }, "viewGroups": {}, @@ -1809,116 +1812,116 @@ exports[`getStandardObjectMetadataRelatedEntityIds should return standard object "workspaceMember": { "fields": { "accountOwnerForCompanies": { - "id": "00000000-0000-0000-0000-000000000567", - }, - "assignedTasks": { - "id": "00000000-0000-0000-0000-000000000564", - }, - "avatarUrl": { - "id": "00000000-0000-0000-0000-000000000561", - }, - "blocklist": { - "id": "00000000-0000-0000-0000-000000000570", - }, - "calendarEventParticipants": { - "id": "00000000-0000-0000-0000-000000000571", - }, - "calendarStartDay": { - "id": "00000000-0000-0000-0000-000000000577", - }, - "colorScheme": { - "id": "00000000-0000-0000-0000-000000000559", - }, - "connectedAccounts": { "id": "00000000-0000-0000-0000-000000000568", }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000554", - }, - "dateFormat": { - "id": "00000000-0000-0000-0000-000000000574", - }, - "deletedAt": { - "id": "00000000-0000-0000-0000-000000000556", - }, - "favorites": { - "id": "00000000-0000-0000-0000-000000000566", - }, - "id": { - "id": "00000000-0000-0000-0000-000000000553", - }, - "locale": { - "id": "00000000-0000-0000-0000-000000000560", - }, - "messageParticipants": { - "id": "00000000-0000-0000-0000-000000000569", - }, - "name": { - "id": "00000000-0000-0000-0000-000000000558", - }, - "numberFormat": { - "id": "00000000-0000-0000-0000-000000000578", - }, - "ownedOpportunities": { + "assignedTasks": { "id": "00000000-0000-0000-0000-000000000565", }, - "position": { - "id": "00000000-0000-0000-0000-000000000557", - }, - "searchVector": { - "id": "00000000-0000-0000-0000-000000000576", - }, - "timeFormat": { - "id": "00000000-0000-0000-0000-000000000575", - }, - "timeZone": { - "id": "00000000-0000-0000-0000-000000000573", - }, - "timelineActivities": { - "id": "00000000-0000-0000-0000-000000000572", - }, - "updatedAt": { - "id": "00000000-0000-0000-0000-000000000555", - }, - "userEmail": { + "avatarUrl": { "id": "00000000-0000-0000-0000-000000000562", }, - "userId": { + "blocklist": { + "id": "00000000-0000-0000-0000-000000000571", + }, + "calendarEventParticipants": { + "id": "00000000-0000-0000-0000-000000000572", + }, + "calendarStartDay": { + "id": "00000000-0000-0000-0000-000000000578", + }, + "colorScheme": { + "id": "00000000-0000-0000-0000-000000000560", + }, + "connectedAccounts": { + "id": "00000000-0000-0000-0000-000000000569", + }, + "createdAt": { + "id": "00000000-0000-0000-0000-000000000555", + }, + "dateFormat": { + "id": "00000000-0000-0000-0000-000000000575", + }, + "deletedAt": { + "id": "00000000-0000-0000-0000-000000000557", + }, + "favorites": { + "id": "00000000-0000-0000-0000-000000000567", + }, + "id": { + "id": "00000000-0000-0000-0000-000000000554", + }, + "locale": { + "id": "00000000-0000-0000-0000-000000000561", + }, + "messageParticipants": { + "id": "00000000-0000-0000-0000-000000000570", + }, + "name": { + "id": "00000000-0000-0000-0000-000000000559", + }, + "numberFormat": { + "id": "00000000-0000-0000-0000-000000000579", + }, + "ownedOpportunities": { + "id": "00000000-0000-0000-0000-000000000566", + }, + "position": { + "id": "00000000-0000-0000-0000-000000000558", + }, + "searchVector": { + "id": "00000000-0000-0000-0000-000000000577", + }, + "timeFormat": { + "id": "00000000-0000-0000-0000-000000000576", + }, + "timeZone": { + "id": "00000000-0000-0000-0000-000000000574", + }, + "timelineActivities": { + "id": "00000000-0000-0000-0000-000000000573", + }, + "updatedAt": { + "id": "00000000-0000-0000-0000-000000000556", + }, + "userEmail": { "id": "00000000-0000-0000-0000-000000000563", }, + "userId": { + "id": "00000000-0000-0000-0000-000000000564", + }, }, - "id": "00000000-0000-0000-0000-000000000589", + "id": "00000000-0000-0000-0000-000000000590", "views": { "allWorkspaceMembers": { - "id": "00000000-0000-0000-0000-000000000588", + "id": "00000000-0000-0000-0000-000000000589", "viewFields": { "avatarUrl": { - "id": "00000000-0000-0000-0000-000000000581", - }, - "colorScheme": { "id": "00000000-0000-0000-0000-000000000582", }, - "createdAt": { - "id": "00000000-0000-0000-0000-000000000587", - }, - "dateFormat": { - "id": "00000000-0000-0000-0000-000000000585", - }, - "locale": { + "colorScheme": { "id": "00000000-0000-0000-0000-000000000583", }, - "name": { - "id": "00000000-0000-0000-0000-000000000579", + "createdAt": { + "id": "00000000-0000-0000-0000-000000000588", }, - "timeFormat": { + "dateFormat": { "id": "00000000-0000-0000-0000-000000000586", }, - "timeZone": { + "locale": { "id": "00000000-0000-0000-0000-000000000584", }, - "userEmail": { + "name": { "id": "00000000-0000-0000-0000-000000000580", }, + "timeFormat": { + "id": "00000000-0000-0000-0000-000000000587", + }, + "timeZone": { + "id": "00000000-0000-0000-0000-000000000585", + }, + "userEmail": { + "id": "00000000-0000-0000-0000-000000000581", + }, }, "viewGroups": {}, }, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-person-standard-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-person-standard-flat-field-metadata.util.ts index 45cd4fb2738..d53ce0340bc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-person-standard-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-person-standard-flat-field-metadata.util.ts @@ -229,6 +229,7 @@ export const buildPersonStandardFlatFieldMetadatas = ({ twentyStandardApplicationId, now, }), + //deprecated avatarUrl: createStandardFieldFlatMetadata({ objectName, workspaceId, @@ -246,6 +247,26 @@ export const buildPersonStandardFlatFieldMetadatas = ({ twentyStandardApplicationId, now, }), + avatarFile: createStandardFieldFlatMetadata({ + objectName, + workspaceId, + context: { + fieldName: 'avatarFile', + type: FieldMetadataType.FILES, + label: 'Avatar File', + description: "Contact's avatar file", + icon: 'IconFileUpload', + isSystem: true, + isNullable: true, + settings: { + maxNumberOfValues: 1, + }, + }, + standardObjectMetadataRelatedEntityIds, + dependencyFlatEntityMaps, + twentyStandardApplicationId, + now, + }), position: createStandardFieldFlatMetadata({ objectName, workspaceId, diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index 2960e251b61..db03366cb27 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -7,6 +7,7 @@ import { type PhonesMetadata, } from 'twenty-shared/types'; +import { type FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface'; @@ -42,7 +43,9 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity { phone: string | null; phones: PhonesMetadata; city: string | null; + /** @deprecated Use `avatarFile` field instead */ avatarUrl: string | null; + avatarFile: FileOutput[] | null; position: number; createdBy: ActorMetadata; updatedBy: ActorMetadata; diff --git a/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts index 441c44af5a4..0477247b242 100644 --- a/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts +++ b/packages/twenty-server/test/integration/constants/person-gql-fields.constants.ts @@ -3,12 +3,18 @@ export const PERSON_GQL_FIELDS = ` city jobTitle avatarUrl + avatarFile { + fileId + label + extension + url + } intro searchVector name { firstName lastName - } + } emails { primaryEmail additionalEmails diff --git a/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts index a4c1b1e38f8..b3ba7a53476 100644 --- a/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/all-people-resolvers.integration-spec.ts @@ -54,6 +54,7 @@ describe('people resolvers (integration)', () => { expect(person).toHaveProperty('id'); expect(person).toHaveProperty('jobTitle'); expect(person).toHaveProperty('avatarUrl'); + expect(person).toHaveProperty('avatarFile'); expect(person).toHaveProperty('intro'); expect(person).toHaveProperty('searchVector'); expect(person).toHaveProperty('name'); @@ -84,6 +85,7 @@ describe('people resolvers (integration)', () => { expect(createdPerson).toHaveProperty('id'); expect(createdPerson).toHaveProperty('jobTitle'); expect(createdPerson).toHaveProperty('avatarUrl'); + expect(createdPerson).toHaveProperty('avatarFile'); expect(createdPerson).toHaveProperty('intro'); expect(createdPerson).toHaveProperty('searchVector'); expect(createdPerson).toHaveProperty('name'); @@ -113,6 +115,7 @@ describe('people resolvers (integration)', () => { expect(person).toHaveProperty('id'); expect(person).toHaveProperty('jobTitle'); expect(person).toHaveProperty('avatarUrl'); + expect(person).toHaveProperty('avatarFile'); expect(person).toHaveProperty('intro'); expect(person).toHaveProperty('searchVector'); expect(person).toHaveProperty('name'); @@ -141,6 +144,7 @@ describe('people resolvers (integration)', () => { expect(person).toHaveProperty('id'); expect(person).toHaveProperty('jobTitle'); expect(person).toHaveProperty('avatarUrl'); + expect(person).toHaveProperty('avatarFile'); expect(person).toHaveProperty('intro'); expect(person).toHaveProperty('searchVector'); expect(person).toHaveProperty('name'); diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts index d036558a1f2..0f2c1aa9666 100644 --- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts @@ -210,14 +210,14 @@ describe('files-field.controller - GET /files-field/:id', () => { }); it('should download image file successfully with valid url', async () => { - const imageContent = 'fake-png-image-binary-content'; - const imageFile = await uploadFile( - 'test-image.png', - imageContent, - 'image/png', + const testFileContent = 'This is test file content for download'; + const textFile = await uploadFile( + 'test-file.txt', + testFileContent, + 'text/plain', ); - uploadedFiles.push(imageFile); + uploadedFiles.push(textFile); const createResponse = await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -227,8 +227,8 @@ describe('files-field.controller - GET /files-field/:id', () => { name: 'Record with image', filesField: [ { - fileId: imageFile.id, - label: 'test-image.png', + fileId: textFile.id, + label: 'test-file.txt', }, ], }, @@ -244,7 +244,7 @@ describe('files-field.controller - GET /files-field/:id', () => { const fileUrl = createdRecord.filesField[0].url; expect(fileUrl).toBeDefined(); - expect(createdRecord.filesField[0].extension).toBe('.png'); + expect(createdRecord.filesField[0].extension).toBe('.txt'); // Extract path from full URL (remove domain) const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search; @@ -254,7 +254,7 @@ describe('files-field.controller - GET /files-field/:id', () => { ); expect(downloadResponse.status).toBe(200); - expect(downloadResponse.text).toBe(imageContent); + expect(downloadResponse.text).toBe(testFileContent); await makeGraphqlAPIRequest({ query: deleteRecordsQuery, diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts index eb25b1136e7..2bc02410bfb 100644 --- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts @@ -208,23 +208,23 @@ describe('fileFieldSync - FILES field <> files sync', () => { }); it('createMany without upsert - files sync successfully', async () => { - const imageFile = await uploadFile( - 'test-image.png', - 'fake image content', - 'image/png', + const file1 = await uploadFile( + 'test-file1.txt', + 'fake text content', + 'text/plain', ); - const textFile = await uploadFile( + const file2 = await uploadFile( 'test-text.txt', 'fake text content', 'text/plain', ); - uploadedFiles.push(imageFile, textFile); + uploadedFiles.push(file1, file2); - expect(await checkFileExistsInDB(imageFile.id)).toBe(true); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileIsTemporary(imageFile.id)).toBe(true); - expect(await checkFileIsTemporary(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(true); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileIsTemporary(file1.id)).toBe(true); + expect(await checkFileIsTemporary(file2.id)).toBe(true); const response = await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -234,12 +234,12 @@ describe('fileFieldSync - FILES field <> files sync', () => { name: 'Record with image', filesField: [ { - fileId: imageFile.id, - label: 'test-image.png', + fileId: file1.id, + label: 'test-file1.txt', }, { - fileId: textFile.id, - label: 'test-text.txt', + fileId: file2.id, + label: 'test-file2.txt', }, ], }, @@ -254,17 +254,17 @@ describe('fileFieldSync - FILES field <> files sync', () => { const createdRecord = response.body.data.createFileSyncTestObjects[0]; expect(createdRecord.filesField).toHaveLength(2); - expect(createdRecord.filesField[0].fileId).toBe(imageFile.id); - expect(createdRecord.filesField[0].extension).toBe('.png'); - expect(createdRecord.filesField[1].fileId).toBe(textFile.id); + expect(createdRecord.filesField[0].fileId).toBe(file1.id); + expect(createdRecord.filesField[0].extension).toBe('.txt'); + expect(createdRecord.filesField[1].fileId).toBe(file2.id); expect(createdRecord.filesField[1].extension).toBe('.txt'); expect(createdRecord.filesField[0].url).toBeDefined(); expect(createdRecord.filesField[1].url).toBeDefined(); - expect(await checkFileExistsInDB(imageFile.id)).toBe(true); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(true); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true); await makeGraphqlAPIRequest({ query: deleteRecordsQuery, @@ -275,30 +275,30 @@ describe('fileFieldSync - FILES field <> files sync', () => { }); it('createMany with upsert - files sync successfully', async () => { - const imageFile = await uploadFile( - 'test-image.png', - 'fake image content', - 'image/png', - ); - const textFile = await uploadFile( - 'test-text.txt', + const file1 = await uploadFile( + 'test-file1.txt', 'fake text content', 'text/plain', ); - const anotherImageFile = await uploadFile( - 'test-another-image.png', - 'fake another image content', - 'image/png', + const file2 = await uploadFile( + 'test-file2.txt', + 'fake text content', + 'text/plain', + ); + const file3 = await uploadFile( + 'test-file3.txt', + 'fake text content', + 'text/plain', ); - uploadedFiles.push(imageFile, textFile, anotherImageFile); + uploadedFiles.push(file1, file2, file3); - expect(await checkFileExistsInDB(imageFile.id)).toBe(true); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileExistsInDB(anotherImageFile.id)).toBe(true); - expect(await checkFileIsTemporary(imageFile.id)).toBe(true); - expect(await checkFileIsTemporary(textFile.id)).toBe(true); - expect(await checkFileIsTemporary(anotherImageFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(true); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileExistsInDB(file3.id)).toBe(true); + expect(await checkFileIsTemporary(file1.id)).toBe(true); + expect(await checkFileIsTemporary(file2.id)).toBe(true); + expect(await checkFileIsTemporary(file3.id)).toBe(true); const createResponse = await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -308,12 +308,12 @@ describe('fileFieldSync - FILES field <> files sync', () => { name: 'Record to upsert', filesField: [ { - fileId: imageFile.id, - label: 'imageFile-label.png', + fileId: file1.id, + label: 'test-file1.txt', }, { - fileId: anotherImageFile.id, - label: 'anotherImageFile-label.png', + fileId: file2.id, + label: 'test-file2.txt', }, ], }, @@ -325,7 +325,7 @@ describe('fileFieldSync - FILES field <> files sync', () => { const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; const recordId = createdRecord.id; - expect(createdRecord.filesField[0].extension).toBe('.png'); + expect(createdRecord.filesField[0].extension).toBe('.txt'); const upsertResponse = await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -336,16 +336,16 @@ describe('fileFieldSync - FILES field <> files sync', () => { name: 'Record updated via upsert', filesField: [ { - fileId: textFile.id, - label: 'new-added-text-file.txt', + fileId: file1.id, + label: 'updated-label-test-file1.txt', }, { - fileId: imageFile.id, - label: 'imageFile-label.png', + fileId: file2.id, + label: 'test-file2.txt', }, { - fileId: anotherImageFile.id, - label: 'updated-anotherImageFile-label.png', + fileId: file3.id, + label: 'new-test-file3.txt', }, ], }, @@ -361,24 +361,24 @@ describe('fileFieldSync - FILES field <> files sync', () => { expect(updatedRecord.id).toBe(recordId); expect(updatedRecord.name).toBe('Record updated via upsert'); expect(updatedRecord.filesField).toHaveLength(3); - expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); - expect(updatedRecord.filesField[1].label).toBe('imageFile-label.png'); - expect(updatedRecord.filesField[1].extension).toBe('.png'); + expect(updatedRecord.filesField[1].fileId).toBe(file2.id); + expect(updatedRecord.filesField[1].label).toBe('test-file2.txt'); + expect(updatedRecord.filesField[1].extension).toBe('.txt'); expect(updatedRecord.filesField[1].url).toBeDefined(); - expect(updatedRecord.filesField[2].fileId).toBe(anotherImageFile.id); - expect(updatedRecord.filesField[2].label).toBe( - 'updated-anotherImageFile-label.png', - ); - expect(updatedRecord.filesField[2].extension).toBe('.png'); + expect(updatedRecord.filesField[2].fileId).toBe(file3.id); + expect(updatedRecord.filesField[2].label).toBe('new-test-file3.txt'); + expect(updatedRecord.filesField[2].extension).toBe('.txt'); expect(updatedRecord.filesField[2].url).toBeDefined(); - expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); - expect(updatedRecord.filesField[0].label).toBe('new-added-text-file.txt'); + expect(updatedRecord.filesField[0].fileId).toBe(file1.id); + expect(updatedRecord.filesField[0].label).toBe( + 'updated-label-test-file1.txt', + ); expect(updatedRecord.filesField[0].extension).toBe('.txt'); expect(updatedRecord.filesField[0].url).toBeDefined(); - expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(anotherImageFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file3.id)).toBe(true); await makeGraphqlAPIRequest({ query: deleteRecordsQuery, @@ -389,23 +389,23 @@ describe('fileFieldSync - FILES field <> files sync', () => { }); it('updateOne - files sync successfully', async () => { - const imageFile = await uploadFile( - 'test-image.png', - 'fake image content', - 'image/png', + const file1 = await uploadFile( + 'test-file1.txt', + 'fake text content', + 'text/plain', ); - const textFile = await uploadFile( - 'test-text.txt', + const file2 = await uploadFile( + 'test-file2.txt', 'fake text content', 'text/plain', ); - uploadedFiles.push(imageFile, textFile); + uploadedFiles.push(file1, file2); - expect(await checkFileExistsInDB(imageFile.id)).toBe(true); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileIsTemporary(imageFile.id)).toBe(true); - expect(await checkFileIsTemporary(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(true); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileIsTemporary(file1.id)).toBe(true); + expect(await checkFileIsTemporary(file2.id)).toBe(true); const createResponse = await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -415,8 +415,8 @@ describe('fileFieldSync - FILES field <> files sync', () => { name: 'Record for updateOne test', filesField: [ { - fileId: imageFile.id, - label: 'original-image.png', + fileId: file1.id, + label: 'test-file1.txt', }, ], }, @@ -425,12 +425,12 @@ describe('fileFieldSync - FILES field <> files sync', () => { }, }); - expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true); const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; const recordId = createdRecord.id; - expect(createdRecord.filesField[0].extension).toBe('.png'); + expect(createdRecord.filesField[0].extension).toBe('.txt'); const updateResponse = await makeGraphqlAPIRequest({ query: updateRecordQuery, @@ -440,12 +440,12 @@ describe('fileFieldSync - FILES field <> files sync', () => { name: 'Record updated via updateOne', filesField: [ { - fileId: textFile.id, + fileId: file1.id, label: 'added-text.txt', }, { - fileId: imageFile.id, - label: 'original-image.png', + fileId: file2.id, + label: 'test-file2.txt', }, ], }, @@ -459,16 +459,16 @@ describe('fileFieldSync - FILES field <> files sync', () => { expect(updatedRecord.id).toBe(recordId); expect(updatedRecord.name).toBe('Record updated via updateOne'); expect(updatedRecord.filesField).toHaveLength(2); - expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); - expect(updatedRecord.filesField[1].label).toBe('original-image.png'); + expect(updatedRecord.filesField[1].fileId).toBe(file2.id); + expect(updatedRecord.filesField[1].label).toBe('test-file2.txt'); expect(updatedRecord.filesField[1].url).toBeDefined(); - expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); + expect(updatedRecord.filesField[0].fileId).toBe(file1.id); expect(updatedRecord.filesField[0].label).toBe('added-text.txt'); expect(updatedRecord.filesField[0].url).toBeDefined(); - expect(await checkFileExistsInDB(imageFile.id)).toBe(true); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(true); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true); await makeGraphqlAPIRequest({ query: deleteRecordsQuery, @@ -479,23 +479,23 @@ describe('fileFieldSync - FILES field <> files sync', () => { }); it('updateOne with removeFiles - verifies files can be removed', async () => { - const imageFile = await uploadFile( - 'test-image-to-delete.png', - 'fake image content', - 'image/png', + const file1 = await uploadFile( + 'test-file1.txt', + 'fake text content', + 'text/plain', ); - const textFile = await uploadFile( - 'test-text-to-keep.txt', + const file2 = await uploadFile( + 'test-file2.txt', 'fake text content', 'text/plain', ); - uploadedFiles.push(imageFile, textFile); + uploadedFiles.push(file1, file2); - expect(await checkFileExistsInDB(imageFile.id)).toBe(true); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileIsTemporary(imageFile.id)).toBe(true); - expect(await checkFileIsTemporary(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(true); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileIsTemporary(file1.id)).toBe(true); + expect(await checkFileIsTemporary(file2.id)).toBe(true); const createResponse = await makeGraphqlAPIRequest({ query: createRecordsQuery, @@ -505,11 +505,11 @@ describe('fileFieldSync - FILES field <> files sync', () => { name: 'Record for file deletion test', filesField: [ { - fileId: imageFile.id, - label: 'image-to-delete.png', + fileId: file1.id, + label: 'test-file1.txt', }, { - fileId: textFile.id, + fileId: file2.id, label: 'text-to-keep.txt', }, ], @@ -519,8 +519,8 @@ describe('fileFieldSync - FILES field <> files sync', () => { }, }); - expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true); const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; const recordId = createdRecord.id; @@ -532,7 +532,7 @@ describe('fileFieldSync - FILES field <> files sync', () => { data: { filesField: [ { - fileId: textFile.id, + fileId: file2.id, label: 'text-to-keep.txt', }, ], @@ -545,11 +545,11 @@ describe('fileFieldSync - FILES field <> files sync', () => { const updatedRecord = updateResponse.body.data.updateFileSyncTestObject; expect(updatedRecord.filesField).toHaveLength(1); - expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); + expect(updatedRecord.filesField[0].fileId).toBe(file2.id); - expect(await checkFileExistsInDB(imageFile.id)).toBe(false); - expect(await checkFileExistsInDB(textFile.id)).toBe(true); - expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(file1.id)).toBe(false); + expect(await checkFileExistsInDB(file2.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true); await makeGraphqlAPIRequest({ query: deleteRecordsQuery, diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts index 4c779f5fadc..670a4dfb910 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts @@ -116,8 +116,8 @@ export const setupTestObjectsWithAllFieldTypes = async ( } const testFileContent = 'Test document content'; - const testFileName = 'Document.pdf'; - const testMimeType = 'application/pdf'; + const testFileName = 'Document.txt'; + const testMimeType = 'text/plain'; const uploadResponse = await makeMetadataAPIRequestWithFileUpload( { diff --git a/packages/twenty-server/test/integration/graphql/suites/object-generated/people.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-generated/people.integration-spec.ts index 6c71a37ae6f..83bccd64420 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-generated/people.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-generated/people.integration-spec.ts @@ -13,6 +13,12 @@ describe('peopleResolver (e2e)', () => { jobTitle city avatarUrl + avatarFile { + fileId + label + extension + url + } position searchVector id @@ -53,6 +59,7 @@ describe('peopleResolver (e2e)', () => { expect(people).toHaveProperty('jobTitle'); expect(people).toHaveProperty('city'); expect(people).toHaveProperty('avatarUrl'); + expect(people).toHaveProperty('avatarFile'); expect(people).toHaveProperty('position'); expect(people).toHaveProperty('searchVector'); expect(people).toHaveProperty('id'); diff --git a/packages/twenty-shared/src/metadata/standard-object.constant.ts b/packages/twenty-shared/src/metadata/standard-object.constant.ts index 8f1effab621..c7354818086 100644 --- a/packages/twenty-shared/src/metadata/standard-object.constant.ts +++ b/packages/twenty-shared/src/metadata/standard-object.constant.ts @@ -1236,6 +1236,9 @@ export const STANDARD_OBJECTS = { avatarUrl: { universalIdentifier: '20202020-b8a6-40df-961c-373dc5d2ec21', }, + avatarFile: { + universalIdentifier: '20202020-a7c9-4e3d-8f1b-2d5a6b7c8e9f', + }, position: { universalIdentifier: '20202020-fcd5-4231-aff5-fff583eaa0b1' }, createdBy: { universalIdentifier: '20202020-f6ab-4d98-af24-a3d5b664148a', diff --git a/yarn.lock b/yarn.lock index f384c08df46..13d1b251d4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -46057,7 +46057,7 @@ __metadata: languageName: node linkType: hard -"mrmime@npm:^2.0.0": +"mrmime@npm:^2.0.0, mrmime@npm:^2.0.1": version: 2.0.1 resolution: "mrmime@npm:2.0.1" checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 @@ -57161,6 +57161,7 @@ __metadata: lodash.upperfirst: "npm:4.3.1" mailparser: "npm:3.9.1" microdiff: "npm:1.4.0" + mrmime: "npm:^2.0.1" ms: "npm:2.1.3" nest-commander: "npm:^3.19.1" node-ical: "npm:^0.20.1"