File - Migrate avatarUrl > avatarFile on person (data migration + logic) + Attachment data migration (#17752)

- Migration command
    - Check IS_FILES_FIELD_MIGRATED:false
    - Check or create avatarFile field
    - Fetch all people with avatarUrl
           - Move (Copy/move) file in storage
           - Create core.file record
           - Update person record
           
    - bonus : attachment migration  : fullPath > file (same logic)
   
- BE logic
    - Add avatarFile field on person

- FE logic 
   - Adapt logic to upload on/display avatarFile data

The whole imageIdentifier logic will be done later
This commit is contained in:
Etienne
2026-02-11 16:07:05 +01:00
committed by GitHub
parent 5be64bf4be
commit d0c1841f0f
46 changed files with 1704 additions and 737 deletions
@@ -1,3 +1,4 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState'; import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState'; import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; 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 { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Avatar } from 'twenty-ui/display'; 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 { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout'; import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
export const CommandMenuRecordInfo = ({ export const CommandMenuRecordInfo = ({
commandMenuPageInstanceId, commandMenuPageInstanceId,
@@ -51,10 +55,15 @@ export const CommandMenuRecordInfo = ({
}), }),
); );
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const recordIdentifier = useRecoilValue( const recordIdentifier = useRecoilValue(
recordStoreIdentifierFamilySelector({ recordStoreIdentifierFamilySelector({
recordId: objectRecordId, recordId: objectRecordId,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}), }),
); );
@@ -80,7 +89,6 @@ export const CommandMenuRecordInfo = ({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular, objectNameSingular,
objectRecordId,
}); });
const fieldDefinition = { const fieldDefinition = {
@@ -1,10 +1,12 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators'; 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 = ({ export const PreComputedChipGeneratorsProvider = ({
children, children,
@@ -13,13 +15,17 @@ export const PreComputedChipGeneratorsProvider = ({
const allowRequestsToTwentyIcons = useRecoilValue( const allowRequestsToTwentyIcons = useRecoilValue(
allowRequestsToTwentyIconsState, allowRequestsToTwentyIconsState,
); );
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } = const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } =
useMemo(() => { useMemo(() => {
return getRecordChipGenerators( return getRecordChipGenerators(
objectMetadataItems, objectMetadataItems,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
); );
}, [allowRequestsToTwentyIcons, objectMetadataItems]); }, [allowRequestsToTwentyIcons, isFilesFieldMigrated, objectMetadataItems]);
return ( return (
<> <>
@@ -3,19 +3,21 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName'; import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { isNonEmptyString } from '@sniptt/guards';
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
import { import {
getImageAbsoluteURI, getImageAbsoluteURI,
getLogoUrlFromDomainName, getLogoUrlFromDomainName,
isDefined, isDefined,
} from 'twenty-shared/utils'; } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
export const getAvatarUrl = ( export const getAvatarUrl = (
objectNameSingular: string, objectNameSingular: string,
record: ObjectRecord, record: ObjectRecord,
imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined, imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
allowRequestsToTwentyIcons?: boolean | undefined, allowRequestsToTwentyIcons?: boolean | undefined,
isFilesFieldMigrated?: boolean | undefined,
) => { ) => {
if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) { if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) {
return record.avatarUrl ?? undefined; return record.avatarUrl ?? undefined;
@@ -31,7 +33,11 @@ export const getAvatarUrl = (
} }
if (objectNameSingular === CoreObjectNameSingular.Person) { if (objectNameSingular === CoreObjectNameSingular.Person) {
return isDefined(record.avatarUrl) if (isFilesFieldMigrated === true) {
return record.avatarFile?.[0]?.url ?? '';
}
return isNonEmptyString(record.avatarUrl)
? getImageAbsoluteURI({ ? getImageAbsoluteURI({
imageUrl: record.avatarUrl, imageUrl: record.avatarUrl,
baseUrl: REACT_APP_SERVER_BASE_URL, baseUrl: REACT_APP_SERVER_BASE_URL,
@@ -7,10 +7,12 @@ export const getImageIdentifierFieldMetadataItem = (
ObjectMetadataItem, ObjectMetadataItem,
'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular' 'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
>, >,
isFilesFieldMigrated?: boolean,
): FieldMetadataItem | undefined => ): FieldMetadataItem | undefined =>
objectMetadataItem.fields.find((fieldMetadataItem) => objectMetadataItem.fields.find((fieldMetadataItem) =>
isImageIdentifierField({ isImageIdentifierField({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
isFilesFieldMigrated,
}), }),
); );
@@ -12,6 +12,7 @@ export const getObjectRecordIdentifier = ({
objectMetadataItem, objectMetadataItem,
record, record,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}: { }: {
objectMetadataItem: Pick< objectMetadataItem: Pick<
ObjectMetadataItem, ObjectMetadataItem,
@@ -22,6 +23,7 @@ export const getObjectRecordIdentifier = ({
>; >;
record: ObjectRecord; record: ObjectRecord;
allowRequestsToTwentyIcons: boolean; allowRequestsToTwentyIcons: boolean;
isFilesFieldMigrated?: boolean;
}): ObjectRecordIdentifier => { }): ObjectRecordIdentifier => {
const labelIdentifierFieldMetadataItem = const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem); getLabelIdentifierFieldMetadataItem(objectMetadataItem);
@@ -43,6 +45,7 @@ export const getObjectRecordIdentifier = ({
record, record,
imageIdentifierFieldMetadata, imageIdentifierFieldMetadata,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
); );
const linkToShowPage = getLinkToShowPage( const linkToShowPage = getLinkToShowPage(
@@ -5,12 +5,14 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
export const isImageIdentifierField = ({ export const isImageIdentifierField = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItem, objectMetadataItem,
isFilesFieldMigrated,
}: { }: {
fieldMetadataItem: Pick<FieldMetadataItem, 'id' | 'name'>; fieldMetadataItem: Pick<FieldMetadataItem, 'id' | 'name'>;
objectMetadataItem: Pick< objectMetadataItem: Pick<
ObjectMetadataItem, ObjectMetadataItem,
'imageIdentifierFieldMetadataId' | 'nameSingular' 'imageIdentifierFieldMetadataId' | 'nameSingular'
>; >;
isFilesFieldMigrated?: boolean;
}) => { }) => {
if ( if (
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company && objectMetadataItem.nameSingular === CoreObjectNameSingular.Company &&
@@ -19,6 +21,13 @@ export const isImageIdentifierField = ({
return true; return true;
} }
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Person) {
if (isFilesFieldMigrated === true) {
return fieldMetadataItem.name === 'avatarFile';
}
return fieldMetadataItem.name === 'avatarUrl';
}
return ( return (
fieldMetadataItem.id === objectMetadataItem.imageIdentifierFieldMetadataId fieldMetadataItem.id === objectMetadataItem.imageIdentifierFieldMetadataId
); );
@@ -12,11 +12,14 @@ export const buildIdentifierGqlFields = (
| 'imageIdentifierFieldMetadataId' | 'imageIdentifierFieldMetadataId'
| 'nameSingular' | 'nameSingular'
>, >,
isFilesFieldMigrated?: boolean,
): RecordGqlFields => { ): RecordGqlFields => {
const labelIdentifierField = const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadata); getLabelIdentifierFieldMetadataItem(objectMetadata);
const imageIdentifierField = const imageIdentifierField = getImageIdentifierFieldMetadataItem(
getImageIdentifierFieldMetadataItem(objectMetadata); objectMetadata,
isFilesFieldMigrated,
);
return { return {
id: true, id: true,
@@ -27,6 +27,7 @@ export type GenerateDepthRecordGqlFieldsFromFields = {
>[]; >[];
depth: 0 | 1; depth: 0 | 1;
shouldOnlyLoadRelationIdentifiers?: boolean; shouldOnlyLoadRelationIdentifiers?: boolean;
isFilesFieldMigrated?: boolean;
}; };
export const generateDepthRecordGqlFieldsFromFields = ({ export const generateDepthRecordGqlFieldsFromFields = ({
@@ -34,6 +35,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({
fields, fields,
depth, depth,
shouldOnlyLoadRelationIdentifiers = true, shouldOnlyLoadRelationIdentifiers = true,
isFilesFieldMigrated,
}: GenerateDepthRecordGqlFieldsFromFields) => { }: GenerateDepthRecordGqlFieldsFromFields) => {
const generatedRecordGqlFields: RecordGqlFields = fields.reduce( const generatedRecordGqlFields: RecordGqlFields = fields.reduce(
(recordGqlFields, fieldMetadata) => { (recordGqlFields, fieldMetadata) => {
@@ -83,6 +85,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({
const junctionGqlFields = generateJunctionRelationGqlFields({ const junctionGqlFields = generateJunctionRelationGqlFields({
fieldMetadataItem: fieldMetadata, fieldMetadataItem: fieldMetadata,
objectMetadataItems, objectMetadataItems,
isFilesFieldMigrated,
}); });
if (isDefined(junctionGqlFields) && depth === 1) { if (isDefined(junctionGqlFields) && depth === 1) {
@@ -97,7 +100,10 @@ export const generateDepthRecordGqlFieldsFromFields = ({
getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem); getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem);
const imageIdentifierFieldMetadataItem = const imageIdentifierFieldMetadataItem =
getImageIdentifierFieldMetadataItem(targetObjectMetadataItem); getImageIdentifierFieldMetadataItem(
targetObjectMetadataItem,
isFilesFieldMigrated,
);
const relationIdentifierSubGqlFields = { const relationIdentifierSubGqlFields = {
id: true, id: true,
@@ -16,11 +16,13 @@ type JunctionFieldMetadataItem = Pick<
type GenerateJunctionRelationGqlFieldsArgs = { type GenerateJunctionRelationGqlFieldsArgs = {
fieldMetadataItem: JunctionFieldMetadataItem; fieldMetadataItem: JunctionFieldMetadataItem;
objectMetadataItems: JunctionObjectMetadataItem[]; objectMetadataItems: JunctionObjectMetadataItem[];
isFilesFieldMigrated?: boolean;
}; };
const buildRegularTargetFieldGqlFields = ( const buildRegularTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem, targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[], objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => { ): RecordGqlFields => {
const targetObjectMetadata = objectMetadataItems.find( const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetField.relation?.targetObjectMetadata.id, (item) => item.id === targetField.relation?.targetObjectMetadata.id,
@@ -31,13 +33,17 @@ const buildRegularTargetFieldGqlFields = (
} }
return { return {
[targetField.name]: buildIdentifierGqlFields(targetObjectMetadata), [targetField.name]: buildIdentifierGqlFields(
targetObjectMetadata,
isFilesFieldMigrated,
),
}; };
}; };
const buildMorphTargetFieldGqlFields = ( const buildMorphTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem, targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[], objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => { ): RecordGqlFields => {
const morphRelations = targetField.morphRelations; const morphRelations = targetField.morphRelations;
@@ -63,7 +69,10 @@ const buildMorphTargetFieldGqlFields = (
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural, targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
}); });
result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata); result[computedFieldName] = buildIdentifierGqlFields(
targetObjectMetadata,
isFilesFieldMigrated,
);
} }
return result; return result;
@@ -72,17 +81,27 @@ const buildMorphTargetFieldGqlFields = (
const buildTargetFieldGqlFields = ( const buildTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem, targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[], objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => { ): RecordGqlFields => {
if (targetField.type === FieldMetadataType.MORPH_RELATION) { 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 // Generates GraphQL fields for a junction relation, including the nested target objects
export const generateJunctionRelationGqlFields = ({ export const generateJunctionRelationGqlFields = ({
fieldMetadataItem, fieldMetadataItem,
objectMetadataItems, objectMetadataItems,
isFilesFieldMigrated,
}: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => { }: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => {
const junctionConfig = getJunctionConfig({ const junctionConfig = getJunctionConfig({
settings: fieldMetadataItem.settings, settings: fieldMetadataItem.settings,
@@ -100,13 +119,17 @@ export const generateJunctionRelationGqlFields = ({
const junctionTargetFields = targetFields.reduce<RecordGqlFields>( const junctionTargetFields = targetFields.reduce<RecordGqlFields>(
(acc, targetField) => ({ (acc, targetField) => ({
...acc, ...acc,
...buildTargetFieldGqlFields(targetField, objectMetadataItems), ...buildTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
),
}), }),
{}, {},
); );
return { return {
...buildIdentifierGqlFields(junctionObjectMetadata), ...buildIdentifierGqlFields(junctionObjectMetadata, isFilesFieldMigrated),
...junctionTargetFields, ...junctionTargetFields,
}; };
}; };
@@ -56,7 +56,6 @@ export const RecordFieldList = ({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular, objectNameSingular,
objectRecordId,
}); });
const isRecordReadOnly = useIsRecordReadOnly({ const isRecordReadOnly = useIsRecordReadOnly({
@@ -9,7 +9,9 @@ import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
type UseRecordsFieldVisibleGqlFields = { type UseRecordsFieldVisibleGqlFields = {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
@@ -29,6 +31,10 @@ export const useRecordsFieldVisibleGqlFields = ({
const { objectMetadataItems } = useObjectMetadataItems(); const { objectMetadataItems } = useObjectMetadataItems();
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({ const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({
objectMetadataItems, objectMetadataItems,
fields: visibleRecordFields.map( fields: visibleRecordFields.map(
@@ -36,12 +42,15 @@ export const useRecordsFieldVisibleGqlFields = ({
fieldMetadataItemByFieldMetadataItemId[field.fieldMetadataItemId], fieldMetadataItemByFieldMetadataItemId[field.fieldMetadataItemId],
), ),
depth: 1, depth: 1,
isFilesFieldMigrated,
}); });
const labelIdentifierFieldMetadataItem = const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem); getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const imageIdentifierFieldMetadataItem = const imageIdentifierFieldMetadataItem = getImageIdentifierFieldMetadataItem(
getImageIdentifierFieldMetadataItem(objectMetadataItem); objectMetadataItem,
isFilesFieldMigrated,
);
const hasPosition = hasObjectMetadataItemPositionField(objectMetadataItem); const hasPosition = hasObjectMetadataItemPositionField(objectMetadataItem);
@@ -64,7 +64,6 @@ export const ObjectRecordShowPageBreadcrumb = ({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular, objectNameSingular,
objectRecordId,
}); });
const isLabelIdentifierReadOnly = useIsRecordFieldReadOnly({ const isLabelIdentifierReadOnly = useIsRecordFieldReadOnly({
@@ -1,8 +1,10 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; 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 { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; 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 { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard'; import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; import { FeatureFlagKey } from '~/generated/graphql';
type SummaryCardProps = { type SummaryCardProps = {
objectNameSingular: string; objectNameSingular: string;
@@ -41,12 +44,15 @@ export const SummaryCard = ({
const allowRequestsToTwentyIcons = useRecoilValue( const allowRequestsToTwentyIcons = useRecoilValue(
allowRequestsToTwentyIconsState, allowRequestsToTwentyIconsState,
); );
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { onUploadPicture, useUpdateOneObjectRecordMutation } = const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
useRecordShowContainerActions({ objectNameSingular,
objectNameSingular, });
objectRecordId,
}); const { onUploadPicture } = usePersonAvatarUpload(objectRecordId);
const isMobile = useIsMobile() || isInRightDrawer; const isMobile = useIsMobile() || isInRightDrawer;
@@ -54,6 +60,7 @@ export const SummaryCard = ({
recordStoreIdentifierFamilySelector({ recordStoreIdentifierFamilySelector({
recordId: objectRecordId, recordId: objectRecordId,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}), }),
); );
@@ -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 };
};
@@ -3,21 +3,14 @@ import {
type RecordUpdateHook, type RecordUpdateHook,
type RecordUpdateHookParams, type RecordUpdateHookParams,
} from '@/object-record/record-field/ui/contexts/FieldContext'; } from '@/object-record/record-field/ui/contexts/FieldContext';
import {
FileFolder,
useUploadImageMutation,
} from '~/generated-metadata/graphql';
interface UseRecordShowContainerActionsProps { interface UseRecordShowContainerActionsProps {
objectNameSingular: string; objectNameSingular: string;
objectRecordId: string;
} }
export const useRecordShowContainerActions = ({ export const useRecordShowContainerActions = ({
objectNameSingular, objectNameSingular,
objectRecordId,
}: UseRecordShowContainerActionsProps) => { }: UseRecordShowContainerActionsProps) => {
const [uploadImage] = useUploadImageMutation();
const { updateOneRecord } = useUpdateOneRecord(); const { updateOneRecord } = useUpdateOneRecord();
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
@@ -32,35 +25,7 @@ export const useRecordShowContainerActions = ({
return [updateEntity, { loading: false }]; 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 { return {
onUploadPicture,
useUpdateOneObjectRecordMutation, useUpdateOneObjectRecordMutation,
}; };
}; };
@@ -11,9 +11,11 @@ export const recordStoreIdentifierFamilySelector = selectorFamily({
({ ({
recordId, recordId,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}: { }: {
recordId: string; recordId: string;
allowRequestsToTwentyIcons: boolean; allowRequestsToTwentyIcons: boolean;
isFilesFieldMigrated?: boolean;
}) => }) =>
({ get }) => { ({ get }) => {
const recordFromStore = get(recordStoreFamilyState(recordId)); const recordFromStore = get(recordStoreFamilyState(recordId));
@@ -35,6 +37,7 @@ export const recordStoreIdentifierFamilySelector = selectorFamily({
objectMetadataItem: objectMetadataItem, objectMetadataItem: objectMetadataItem,
record: recordFromStore, record: recordFromStore,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}); });
}, },
}); });
@@ -17,6 +17,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getRecordChipGenerators = ( export const getRecordChipGenerators = (
objectMetadataItems: ObjectMetadataItem[], objectMetadataItems: ObjectMetadataItem[],
allowRequestsToTwentyIcons?: boolean, allowRequestsToTwentyIcons?: boolean,
isFilesFieldMigrated?: boolean,
) => { ) => {
const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName = const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName =
{}; {};
@@ -94,6 +95,7 @@ export const getRecordChipGenerators = (
record, record,
imageIdentifierFieldMetadataToUse, imageIdentifierFieldMetadataToUse,
allowRequestsToTwentyIcons, allowRequestsToTwentyIcons,
isFilesFieldMigrated,
), ),
avatarType, avatarType,
isLabelIdentifier, isLabelIdentifier,
@@ -51,7 +51,6 @@ export const WidgetActionFieldEdit = () => {
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: targetRecord.id,
}); });
const isRecordReadOnly = useIsRecordReadOnly({ const isRecordReadOnly = useIsRecordReadOnly({
@@ -56,7 +56,6 @@ export const FieldWidgetDisplay = ({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular: objectMetadataItem.nameSingular, objectNameSingular: objectMetadataItem.nameSingular,
objectRecordId: recordId,
}); });
const isRecordReadOnly = useIsRecordReadOnly({ const isRecordReadOnly = useIsRecordReadOnly({
@@ -76,7 +76,6 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular: targetRecord.targetObjectNameSingular, objectNameSingular: targetRecord.targetObjectNameSingular,
objectRecordId: targetRecord.id,
}); });
const isRecordReadOnly = useIsRecordReadOnly({ const isRecordReadOnly = useIsRecordReadOnly({
+1
View File
@@ -142,6 +142,7 @@
"lodash.upperfirst": "4.3.1", "lodash.upperfirst": "4.3.1",
"mailparser": "3.9.1", "mailparser": "3.9.1",
"microdiff": "1.4.0", "microdiff": "1.4.0",
"mrmime": "^2.0.1",
"ms": "2.1.3", "ms": "2.1.3",
"nest-commander": "^3.19.1", "nest-commander": "^3.19.1",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",
@@ -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<WorkspaceEntity>,
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<void> {
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<FlatObjectMetadata>({
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<AttachmentWorkspaceEntity>(
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}`,
);
}
}
@@ -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<WorkspaceEntity>,
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<void> {
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<FlatObjectMetadata>({
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<PersonWorkspaceEntity>(
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}`,
);
}
}
@@ -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 {}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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_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 { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; 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: [ imports: [
TypeOrmModule.forFeature([WorkspaceEntity]), TypeOrmModule.forFeature([WorkspaceEntity]),
V1_17_UpgradeVersionCommandModule, V1_17_UpgradeVersionCommandModule,
V1_18_UpgradeVersionCommandModule,
DataSourceModule, DataSourceModule,
], ],
providers: [UpgradeCommand], providers: [UpgradeCommand],
@@ -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 { 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 { 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 { 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; 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 makeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
protected readonly migrateWorkflowCodeStepsCommand: MigrateWorkflowCodeStepsCommand, protected readonly migrateWorkflowCodeStepsCommand: MigrateWorkflowCodeStepsCommand,
protected readonly fixMorphRelationFieldNamesCommand: FixMorphRelationFieldNamesCommand, protected readonly fixMorphRelationFieldNamesCommand: FixMorphRelationFieldNamesCommand,
// 1.18 Commands
protected readonly migratePersonAvatarFilesCommand: MigratePersonAvatarFilesCommand,
) { ) {
super( super(
workspaceRepository, workspaceRepository,
@@ -74,9 +78,14 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.fixMorphRelationFieldNamesCommand, this.fixMorphRelationFieldNamesCommand,
]; ];
const commands_1180: VersionCommands = [
this.migratePersonAvatarFilesCommand,
];
this.allCommands = { this.allCommands = {
'1.16.0': commands_1160, '1.16.0': commands_1160,
'1.17.0': commands_1170, '1.17.0': commands_1170,
'1.18.0': commands_1180,
}; };
} }
@@ -0,0 +1,15 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddMimeTypeToFileTable1770814914548 implements MigrationInterface {
name = 'AddMimeTypeToFileTable1770814914548';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."file" ADD "mimeType" character varying NOT NULL DEFAULT 'application/octet-stream'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "core"."file" DROP COLUMN "mimeType"`);
}
}
@@ -114,6 +114,7 @@ export class FileStorageService {
workspaceId, workspaceId,
applicationId: application.id, applicationId: application.id,
id: fileId, id: fileId,
mimeType,
size: size:
typeof sourceFile === 'string' typeof sourceFile === 'string'
? Buffer.byteLength(sourceFile) ? Buffer.byteLength(sourceFile)
@@ -7,12 +7,15 @@ import { CustomException } from 'src/utils/custom-exception';
export enum FileStorageExceptionCode { export enum FileStorageExceptionCode {
FILE_NOT_FOUND = 'FILE_NOT_FOUND', FILE_NOT_FOUND = 'FILE_NOT_FOUND',
ACCESS_DENIED = 'ACCESS_DENIED', ACCESS_DENIED = 'ACCESS_DENIED',
INVALID_EXTENSION = 'INVALID_EXTENSION',
} }
const getFileStorageExceptionUserFriendlyMessage = ( const getFileStorageExceptionUserFriendlyMessage = (
code: FileStorageExceptionCode, code: FileStorageExceptionCode,
) => { ) => {
switch (code) { switch (code) {
case FileStorageExceptionCode.INVALID_EXTENSION:
return msg`Wrong extension for file.`;
case FileStorageExceptionCode.FILE_NOT_FOUND: case FileStorageExceptionCode.FILE_NOT_FOUND:
return msg`File not found.`; return msg`File not found.`;
case FileStorageExceptionCode.ACCESS_DENIED: case FileStorageExceptionCode.ACCESS_DENIED:
@@ -56,4 +56,11 @@ export class FileEntity extends WorkspaceRelatedEntity {
@Column({ nullable: true, type: 'jsonb' }) @Column({ nullable: true, type: 'jsonb' })
settings: FileSettings | null; settings: FileSettings | null;
@Column({
nullable: false,
type: 'varchar',
default: 'application/octet-stream',
})
mimeType: string;
} }
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types'; import { FileFolder } from 'twenty-shared/types';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
@@ -44,26 +45,23 @@ export class FilesFieldService {
async uploadFile({ async uploadFile({
file, file,
filename, filename,
declaredMimeType,
workspaceId, workspaceId,
fieldMetadataId, fieldMetadataId,
}: { }: {
file: Buffer; file: Buffer;
filename: string; filename: string;
declaredMimeType: string | undefined;
workspaceId: string; workspaceId: string;
fieldMetadataId: string; fieldMetadataId: string;
}): Promise<FileEntity> { }): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({ const { mimeType, ext } = await extractFileInfo({
file, file,
declaredMimeType,
filename, filename,
}); });
const sanitizedFile = sanitizeFile({ file, ext, mimeType }); const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4(); const fileId = v4();
const name = `${fileId}${ext ? `.${ext}` : ''}`; const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({ const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({
select: ['applicationId', 'universalIdentifier'], select: ['applicationId', 'universalIdentifier'],
@@ -30,7 +30,7 @@ export class FilesFieldResolver {
@AuthWorkspace() @AuthWorkspace()
{ id: workspaceId }: WorkspaceEntity, { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload }) @Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload, { createReadStream, filename }: FileUpload,
@Args({ @Args({
name: 'fieldMetadataId', name: 'fieldMetadataId',
type: () => String, type: () => String,
@@ -44,7 +44,6 @@ export class FilesFieldResolver {
return await this.filesFieldService.uploadFile({ return await this.filesFieldService.uploadFile({
file: buffer, file: buffer,
filename, filename,
declaredMimeType: mimetype,
workspaceId, workspaceId,
fieldMetadataId, fieldMetadataId,
}); });
@@ -1,28 +1,35 @@
import FileType from 'file-type';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils'; import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
jest.mock('file-type', () => ({ // Mock detectableMimeTypes to work around ESM/CommonJS interop issues in Jest
fromBuffer: jest.fn(), jest.mock('file-type', () => {
})); const actual = jest.requireActual('file-type');
return {
...actual,
mimeTypes: actual.mimeTypes,
};
});
describe('extractFileInfo', () => { 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(() => { // Real PDF file header
jest.clearAllMocks(); const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
});
it('should use detected file type when available', async () => { // Plain text buffer (no magic numbers)
(FileType.fromBuffer as jest.Mock).mockResolvedValue({ const textBuffer = Buffer.from('Hello, world!', 'utf-8');
mime: 'image/png',
ext: 'png',
});
// 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({ const result = await extractFileInfo({
file: mockBuffer, file: pngBuffer,
declaredMimeType: 'text/plain', filename: 'image.png',
filename: 'test.txt',
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -31,31 +38,10 @@ describe('extractFileInfo', () => {
}); });
}); });
it('should fall back to declared values when file type is not detected', async () => { it('should detect PDF from buffer magic numbers', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
const result = await extractFileInfo({ const result = await extractFileInfo({
file: mockBuffer, file: pdfBuffer,
declaredMimeType: 'text/plain', filename: 'document.pdf',
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',
}); });
expect(result).toEqual({ expect(result).toEqual({
@@ -64,45 +50,133 @@ describe('extractFileInfo', () => {
}); });
}); });
it('should handle both mime type and extension being undefined', async () => { it('should use extension-based lookup for text files', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
const result = await extractFileInfo({ const result = await extractFileInfo({
file: mockBuffer, file: textBuffer,
declaredMimeType: undefined, 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', filename: 'file-without-extension',
}); });
expect(result).toEqual({ expect(result).toEqual({
mimeType: undefined, mimeType: 'application/octet-stream',
ext: '', ext: '',
}); });
}); });
it('should handle filenames with multiple dots', async () => { it('should detect ZIP files from buffer', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
const result = await extractFileInfo({ const result = await extractFileInfo({
file: mockBuffer, file: zipBuffer,
declaredMimeType: 'application/gzip', filename: 'archive.zip',
filename: 'archive.tar.gz',
}); });
expect(result).toEqual({ expect(result).toEqual({
mimeType: 'application/gzip', mimeType: 'application/zip',
ext: 'gz', ext: 'zip',
}); });
}); });
it('should call FileType.fromBuffer with the provided buffer', async () => { it('should throw error when PNG extension is used with non-PNG buffer', async () => {
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined); 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({ it('should throw error when PDF extension is used with non-PDF buffer', async () => {
file: mockBuffer, await expect(
declaredMimeType: 'image/png', extractFileInfo({
filename: 'image.png', 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('<html><body>Test</body></html>'),
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',
});
}); });
}); });
@@ -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'; import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
export const extractFileInfo = async ({ export const extractFileInfo = async ({
file, file,
declaredMimeType,
filename, filename,
}: { }: {
file: Buffer; file: Buffer;
declaredMimeType: string | undefined;
filename: string; filename: string;
}) => { }) => {
const { ext: declaredExt } = buildFileInfo(filename); 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 { return {
mimeType, mimeType,
@@ -134,6 +134,14 @@ export class FilesFieldSync {
return null; return null;
} }
const isModifyingFilesField = filesFields.some((filesField) =>
isDefined(updatePayload[filesField.name as keyof typeof updatePayload]),
);
if (!isModifyingFilesField) {
return null;
}
if (existingRecords.length !== 1) { if (existingRecords.length !== 1) {
throw new TwentyORMException( throw new TwentyORMException(
`Cannot update multiple records with files field at once`, `Cannot update multiple records with files field at once`,
@@ -229,6 +229,7 @@ export const buildPersonStandardFlatFieldMetadatas = ({
twentyStandardApplicationId, twentyStandardApplicationId,
now, now,
}), }),
//deprecated
avatarUrl: createStandardFieldFlatMetadata({ avatarUrl: createStandardFieldFlatMetadata({
objectName, objectName,
workspaceId, workspaceId,
@@ -246,6 +247,26 @@ export const buildPersonStandardFlatFieldMetadatas = ({
twentyStandardApplicationId, twentyStandardApplicationId,
now, 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({ position: createStandardFieldFlatMetadata({
objectName, objectName,
workspaceId, workspaceId,
@@ -7,6 +7,7 @@ import {
type PhonesMetadata, type PhonesMetadata,
} from 'twenty-shared/types'; } 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 { 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 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'; 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; phone: string | null;
phones: PhonesMetadata; phones: PhonesMetadata;
city: string | null; city: string | null;
/** @deprecated Use `avatarFile` field instead */
avatarUrl: string | null; avatarUrl: string | null;
avatarFile: FileOutput[] | null;
position: number; position: number;
createdBy: ActorMetadata; createdBy: ActorMetadata;
updatedBy: ActorMetadata; updatedBy: ActorMetadata;
@@ -3,6 +3,12 @@ export const PERSON_GQL_FIELDS = `
city city
jobTitle jobTitle
avatarUrl avatarUrl
avatarFile {
fileId
label
extension
url
}
intro intro
searchVector searchVector
name { name {
@@ -54,6 +54,7 @@ describe('people resolvers (integration)', () => {
expect(person).toHaveProperty('id'); expect(person).toHaveProperty('id');
expect(person).toHaveProperty('jobTitle'); expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('avatarUrl'); expect(person).toHaveProperty('avatarUrl');
expect(person).toHaveProperty('avatarFile');
expect(person).toHaveProperty('intro'); expect(person).toHaveProperty('intro');
expect(person).toHaveProperty('searchVector'); expect(person).toHaveProperty('searchVector');
expect(person).toHaveProperty('name'); expect(person).toHaveProperty('name');
@@ -84,6 +85,7 @@ describe('people resolvers (integration)', () => {
expect(createdPerson).toHaveProperty('id'); expect(createdPerson).toHaveProperty('id');
expect(createdPerson).toHaveProperty('jobTitle'); expect(createdPerson).toHaveProperty('jobTitle');
expect(createdPerson).toHaveProperty('avatarUrl'); expect(createdPerson).toHaveProperty('avatarUrl');
expect(createdPerson).toHaveProperty('avatarFile');
expect(createdPerson).toHaveProperty('intro'); expect(createdPerson).toHaveProperty('intro');
expect(createdPerson).toHaveProperty('searchVector'); expect(createdPerson).toHaveProperty('searchVector');
expect(createdPerson).toHaveProperty('name'); expect(createdPerson).toHaveProperty('name');
@@ -113,6 +115,7 @@ describe('people resolvers (integration)', () => {
expect(person).toHaveProperty('id'); expect(person).toHaveProperty('id');
expect(person).toHaveProperty('jobTitle'); expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('avatarUrl'); expect(person).toHaveProperty('avatarUrl');
expect(person).toHaveProperty('avatarFile');
expect(person).toHaveProperty('intro'); expect(person).toHaveProperty('intro');
expect(person).toHaveProperty('searchVector'); expect(person).toHaveProperty('searchVector');
expect(person).toHaveProperty('name'); expect(person).toHaveProperty('name');
@@ -141,6 +144,7 @@ describe('people resolvers (integration)', () => {
expect(person).toHaveProperty('id'); expect(person).toHaveProperty('id');
expect(person).toHaveProperty('jobTitle'); expect(person).toHaveProperty('jobTitle');
expect(person).toHaveProperty('avatarUrl'); expect(person).toHaveProperty('avatarUrl');
expect(person).toHaveProperty('avatarFile');
expect(person).toHaveProperty('intro'); expect(person).toHaveProperty('intro');
expect(person).toHaveProperty('searchVector'); expect(person).toHaveProperty('searchVector');
expect(person).toHaveProperty('name'); expect(person).toHaveProperty('name');
@@ -210,14 +210,14 @@ describe('files-field.controller - GET /files-field/:id', () => {
}); });
it('should download image file successfully with valid url', async () => { it('should download image file successfully with valid url', async () => {
const imageContent = 'fake-png-image-binary-content'; const testFileContent = 'This is test file content for download';
const imageFile = await uploadFile( const textFile = await uploadFile(
'test-image.png', 'test-file.txt',
imageContent, testFileContent,
'image/png', 'text/plain',
); );
uploadedFiles.push(imageFile); uploadedFiles.push(textFile);
const createResponse = await makeGraphqlAPIRequest({ const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery, query: createRecordsQuery,
@@ -227,8 +227,8 @@ describe('files-field.controller - GET /files-field/:id', () => {
name: 'Record with image', name: 'Record with image',
filesField: [ filesField: [
{ {
fileId: imageFile.id, fileId: textFile.id,
label: 'test-image.png', label: 'test-file.txt',
}, },
], ],
}, },
@@ -244,7 +244,7 @@ describe('files-field.controller - GET /files-field/:id', () => {
const fileUrl = createdRecord.filesField[0].url; const fileUrl = createdRecord.filesField[0].url;
expect(fileUrl).toBeDefined(); expect(fileUrl).toBeDefined();
expect(createdRecord.filesField[0].extension).toBe('.png'); expect(createdRecord.filesField[0].extension).toBe('.txt');
// Extract path from full URL (remove domain) // Extract path from full URL (remove domain)
const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search; 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.status).toBe(200);
expect(downloadResponse.text).toBe(imageContent); expect(downloadResponse.text).toBe(testFileContent);
await makeGraphqlAPIRequest({ await makeGraphqlAPIRequest({
query: deleteRecordsQuery, query: deleteRecordsQuery,
@@ -208,23 +208,23 @@ describe('fileFieldSync - FILES field <> files sync', () => {
}); });
it('createMany without upsert - files sync successfully', async () => { it('createMany without upsert - files sync successfully', async () => {
const imageFile = await uploadFile( const file1 = await uploadFile(
'test-image.png', 'test-file1.txt',
'fake image content', 'fake text content',
'image/png', 'text/plain',
); );
const textFile = await uploadFile( const file2 = await uploadFile(
'test-text.txt', 'test-text.txt',
'fake text content', 'fake text content',
'text/plain', 'text/plain',
); );
uploadedFiles.push(imageFile, textFile); uploadedFiles.push(file1, file2);
expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsTemporary(imageFile.id)).toBe(true); expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(textFile.id)).toBe(true); expect(await checkFileIsTemporary(file2.id)).toBe(true);
const response = await makeGraphqlAPIRequest({ const response = await makeGraphqlAPIRequest({
query: createRecordsQuery, query: createRecordsQuery,
@@ -234,12 +234,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
name: 'Record with image', name: 'Record with image',
filesField: [ filesField: [
{ {
fileId: imageFile.id, fileId: file1.id,
label: 'test-image.png', label: 'test-file1.txt',
}, },
{ {
fileId: textFile.id, fileId: file2.id,
label: 'test-text.txt', label: 'test-file2.txt',
}, },
], ],
}, },
@@ -254,17 +254,17 @@ describe('fileFieldSync - FILES field <> files sync', () => {
const createdRecord = response.body.data.createFileSyncTestObjects[0]; const createdRecord = response.body.data.createFileSyncTestObjects[0];
expect(createdRecord.filesField).toHaveLength(2); expect(createdRecord.filesField).toHaveLength(2);
expect(createdRecord.filesField[0].fileId).toBe(imageFile.id); expect(createdRecord.filesField[0].fileId).toBe(file1.id);
expect(createdRecord.filesField[0].extension).toBe('.png'); expect(createdRecord.filesField[0].extension).toBe('.txt');
expect(createdRecord.filesField[1].fileId).toBe(textFile.id); expect(createdRecord.filesField[1].fileId).toBe(file2.id);
expect(createdRecord.filesField[1].extension).toBe('.txt'); expect(createdRecord.filesField[1].extension).toBe('.txt');
expect(createdRecord.filesField[0].url).toBeDefined(); expect(createdRecord.filesField[0].url).toBeDefined();
expect(createdRecord.filesField[1].url).toBeDefined(); expect(createdRecord.filesField[1].url).toBeDefined();
expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
await makeGraphqlAPIRequest({ await makeGraphqlAPIRequest({
query: deleteRecordsQuery, query: deleteRecordsQuery,
@@ -275,30 +275,30 @@ describe('fileFieldSync - FILES field <> files sync', () => {
}); });
it('createMany with upsert - files sync successfully', async () => { it('createMany with upsert - files sync successfully', async () => {
const imageFile = await uploadFile( const file1 = await uploadFile(
'test-image.png', 'test-file1.txt',
'fake image content',
'image/png',
);
const textFile = await uploadFile(
'test-text.txt',
'fake text content', 'fake text content',
'text/plain', 'text/plain',
); );
const anotherImageFile = await uploadFile( const file2 = await uploadFile(
'test-another-image.png', 'test-file2.txt',
'fake another image content', 'fake text content',
'image/png', '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(file1.id)).toBe(true);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileExistsInDB(anotherImageFile.id)).toBe(true); expect(await checkFileExistsInDB(file3.id)).toBe(true);
expect(await checkFileIsTemporary(imageFile.id)).toBe(true); expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(textFile.id)).toBe(true); expect(await checkFileIsTemporary(file2.id)).toBe(true);
expect(await checkFileIsTemporary(anotherImageFile.id)).toBe(true); expect(await checkFileIsTemporary(file3.id)).toBe(true);
const createResponse = await makeGraphqlAPIRequest({ const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery, query: createRecordsQuery,
@@ -308,12 +308,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
name: 'Record to upsert', name: 'Record to upsert',
filesField: [ filesField: [
{ {
fileId: imageFile.id, fileId: file1.id,
label: 'imageFile-label.png', label: 'test-file1.txt',
}, },
{ {
fileId: anotherImageFile.id, fileId: file2.id,
label: 'anotherImageFile-label.png', label: 'test-file2.txt',
}, },
], ],
}, },
@@ -325,7 +325,7 @@ describe('fileFieldSync - FILES field <> files sync', () => {
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
const recordId = createdRecord.id; const recordId = createdRecord.id;
expect(createdRecord.filesField[0].extension).toBe('.png'); expect(createdRecord.filesField[0].extension).toBe('.txt');
const upsertResponse = await makeGraphqlAPIRequest({ const upsertResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery, query: createRecordsQuery,
@@ -336,16 +336,16 @@ describe('fileFieldSync - FILES field <> files sync', () => {
name: 'Record updated via upsert', name: 'Record updated via upsert',
filesField: [ filesField: [
{ {
fileId: textFile.id, fileId: file1.id,
label: 'new-added-text-file.txt', label: 'updated-label-test-file1.txt',
}, },
{ {
fileId: imageFile.id, fileId: file2.id,
label: 'imageFile-label.png', label: 'test-file2.txt',
}, },
{ {
fileId: anotherImageFile.id, fileId: file3.id,
label: 'updated-anotherImageFile-label.png', label: 'new-test-file3.txt',
}, },
], ],
}, },
@@ -361,24 +361,24 @@ describe('fileFieldSync - FILES field <> files sync', () => {
expect(updatedRecord.id).toBe(recordId); expect(updatedRecord.id).toBe(recordId);
expect(updatedRecord.name).toBe('Record updated via upsert'); expect(updatedRecord.name).toBe('Record updated via upsert');
expect(updatedRecord.filesField).toHaveLength(3); expect(updatedRecord.filesField).toHaveLength(3);
expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); expect(updatedRecord.filesField[1].fileId).toBe(file2.id);
expect(updatedRecord.filesField[1].label).toBe('imageFile-label.png'); expect(updatedRecord.filesField[1].label).toBe('test-file2.txt');
expect(updatedRecord.filesField[1].extension).toBe('.png'); expect(updatedRecord.filesField[1].extension).toBe('.txt');
expect(updatedRecord.filesField[1].url).toBeDefined(); expect(updatedRecord.filesField[1].url).toBeDefined();
expect(updatedRecord.filesField[2].fileId).toBe(anotherImageFile.id); expect(updatedRecord.filesField[2].fileId).toBe(file3.id);
expect(updatedRecord.filesField[2].label).toBe( expect(updatedRecord.filesField[2].label).toBe('new-test-file3.txt');
'updated-anotherImageFile-label.png', expect(updatedRecord.filesField[2].extension).toBe('.txt');
);
expect(updatedRecord.filesField[2].extension).toBe('.png');
expect(updatedRecord.filesField[2].url).toBeDefined(); expect(updatedRecord.filesField[2].url).toBeDefined();
expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); expect(updatedRecord.filesField[0].fileId).toBe(file1.id);
expect(updatedRecord.filesField[0].label).toBe('new-added-text-file.txt'); expect(updatedRecord.filesField[0].label).toBe(
'updated-label-test-file1.txt',
);
expect(updatedRecord.filesField[0].extension).toBe('.txt'); expect(updatedRecord.filesField[0].extension).toBe('.txt');
expect(updatedRecord.filesField[0].url).toBeDefined(); expect(updatedRecord.filesField[0].url).toBeDefined();
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(anotherImageFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file3.id)).toBe(true);
await makeGraphqlAPIRequest({ await makeGraphqlAPIRequest({
query: deleteRecordsQuery, query: deleteRecordsQuery,
@@ -389,23 +389,23 @@ describe('fileFieldSync - FILES field <> files sync', () => {
}); });
it('updateOne - files sync successfully', async () => { it('updateOne - files sync successfully', async () => {
const imageFile = await uploadFile( const file1 = await uploadFile(
'test-image.png', 'test-file1.txt',
'fake image content', 'fake text content',
'image/png', 'text/plain',
); );
const textFile = await uploadFile( const file2 = await uploadFile(
'test-text.txt', 'test-file2.txt',
'fake text content', 'fake text content',
'text/plain', 'text/plain',
); );
uploadedFiles.push(imageFile, textFile); uploadedFiles.push(file1, file2);
expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsTemporary(imageFile.id)).toBe(true); expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(textFile.id)).toBe(true); expect(await checkFileIsTemporary(file2.id)).toBe(true);
const createResponse = await makeGraphqlAPIRequest({ const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery, query: createRecordsQuery,
@@ -415,8 +415,8 @@ describe('fileFieldSync - FILES field <> files sync', () => {
name: 'Record for updateOne test', name: 'Record for updateOne test',
filesField: [ filesField: [
{ {
fileId: imageFile.id, fileId: file1.id,
label: 'original-image.png', 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 createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
const recordId = createdRecord.id; const recordId = createdRecord.id;
expect(createdRecord.filesField[0].extension).toBe('.png'); expect(createdRecord.filesField[0].extension).toBe('.txt');
const updateResponse = await makeGraphqlAPIRequest({ const updateResponse = await makeGraphqlAPIRequest({
query: updateRecordQuery, query: updateRecordQuery,
@@ -440,12 +440,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
name: 'Record updated via updateOne', name: 'Record updated via updateOne',
filesField: [ filesField: [
{ {
fileId: textFile.id, fileId: file1.id,
label: 'added-text.txt', label: 'added-text.txt',
}, },
{ {
fileId: imageFile.id, fileId: file2.id,
label: 'original-image.png', label: 'test-file2.txt',
}, },
], ],
}, },
@@ -459,16 +459,16 @@ describe('fileFieldSync - FILES field <> files sync', () => {
expect(updatedRecord.id).toBe(recordId); expect(updatedRecord.id).toBe(recordId);
expect(updatedRecord.name).toBe('Record updated via updateOne'); expect(updatedRecord.name).toBe('Record updated via updateOne');
expect(updatedRecord.filesField).toHaveLength(2); expect(updatedRecord.filesField).toHaveLength(2);
expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); expect(updatedRecord.filesField[1].fileId).toBe(file2.id);
expect(updatedRecord.filesField[1].label).toBe('original-image.png'); expect(updatedRecord.filesField[1].label).toBe('test-file2.txt');
expect(updatedRecord.filesField[1].url).toBeDefined(); 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].label).toBe('added-text.txt');
expect(updatedRecord.filesField[0].url).toBeDefined(); expect(updatedRecord.filesField[0].url).toBeDefined();
expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
await makeGraphqlAPIRequest({ await makeGraphqlAPIRequest({
query: deleteRecordsQuery, query: deleteRecordsQuery,
@@ -479,23 +479,23 @@ describe('fileFieldSync - FILES field <> files sync', () => {
}); });
it('updateOne with removeFiles - verifies files can be removed', async () => { it('updateOne with removeFiles - verifies files can be removed', async () => {
const imageFile = await uploadFile( const file1 = await uploadFile(
'test-image-to-delete.png', 'test-file1.txt',
'fake image content', 'fake text content',
'image/png', 'text/plain',
); );
const textFile = await uploadFile( const file2 = await uploadFile(
'test-text-to-keep.txt', 'test-file2.txt',
'fake text content', 'fake text content',
'text/plain', 'text/plain',
); );
uploadedFiles.push(imageFile, textFile); uploadedFiles.push(file1, file2);
expect(await checkFileExistsInDB(imageFile.id)).toBe(true); expect(await checkFileExistsInDB(file1.id)).toBe(true);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsTemporary(imageFile.id)).toBe(true); expect(await checkFileIsTemporary(file1.id)).toBe(true);
expect(await checkFileIsTemporary(textFile.id)).toBe(true); expect(await checkFileIsTemporary(file2.id)).toBe(true);
const createResponse = await makeGraphqlAPIRequest({ const createResponse = await makeGraphqlAPIRequest({
query: createRecordsQuery, query: createRecordsQuery,
@@ -505,11 +505,11 @@ describe('fileFieldSync - FILES field <> files sync', () => {
name: 'Record for file deletion test', name: 'Record for file deletion test',
filesField: [ filesField: [
{ {
fileId: imageFile.id, fileId: file1.id,
label: 'image-to-delete.png', label: 'test-file1.txt',
}, },
{ {
fileId: textFile.id, fileId: file2.id,
label: 'text-to-keep.txt', 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(file1.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
const recordId = createdRecord.id; const recordId = createdRecord.id;
@@ -532,7 +532,7 @@ describe('fileFieldSync - FILES field <> files sync', () => {
data: { data: {
filesField: [ filesField: [
{ {
fileId: textFile.id, fileId: file2.id,
label: 'text-to-keep.txt', label: 'text-to-keep.txt',
}, },
], ],
@@ -545,11 +545,11 @@ describe('fileFieldSync - FILES field <> files sync', () => {
const updatedRecord = updateResponse.body.data.updateFileSyncTestObject; const updatedRecord = updateResponse.body.data.updateFileSyncTestObject;
expect(updatedRecord.filesField).toHaveLength(1); 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(file1.id)).toBe(false);
expect(await checkFileExistsInDB(textFile.id)).toBe(true); expect(await checkFileExistsInDB(file2.id)).toBe(true);
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
await makeGraphqlAPIRequest({ await makeGraphqlAPIRequest({
query: deleteRecordsQuery, query: deleteRecordsQuery,
@@ -116,8 +116,8 @@ export const setupTestObjectsWithAllFieldTypes = async (
} }
const testFileContent = 'Test document content'; const testFileContent = 'Test document content';
const testFileName = 'Document.pdf'; const testFileName = 'Document.txt';
const testMimeType = 'application/pdf'; const testMimeType = 'text/plain';
const uploadResponse = await makeMetadataAPIRequestWithFileUpload( const uploadResponse = await makeMetadataAPIRequestWithFileUpload(
{ {
@@ -13,6 +13,12 @@ describe('peopleResolver (e2e)', () => {
jobTitle jobTitle
city city
avatarUrl avatarUrl
avatarFile {
fileId
label
extension
url
}
position position
searchVector searchVector
id id
@@ -53,6 +59,7 @@ describe('peopleResolver (e2e)', () => {
expect(people).toHaveProperty('jobTitle'); expect(people).toHaveProperty('jobTitle');
expect(people).toHaveProperty('city'); expect(people).toHaveProperty('city');
expect(people).toHaveProperty('avatarUrl'); expect(people).toHaveProperty('avatarUrl');
expect(people).toHaveProperty('avatarFile');
expect(people).toHaveProperty('position'); expect(people).toHaveProperty('position');
expect(people).toHaveProperty('searchVector'); expect(people).toHaveProperty('searchVector');
expect(people).toHaveProperty('id'); expect(people).toHaveProperty('id');
@@ -1236,6 +1236,9 @@ export const STANDARD_OBJECTS = {
avatarUrl: { avatarUrl: {
universalIdentifier: '20202020-b8a6-40df-961c-373dc5d2ec21', universalIdentifier: '20202020-b8a6-40df-961c-373dc5d2ec21',
}, },
avatarFile: {
universalIdentifier: '20202020-a7c9-4e3d-8f1b-2d5a6b7c8e9f',
},
position: { universalIdentifier: '20202020-fcd5-4231-aff5-fff583eaa0b1' }, position: { universalIdentifier: '20202020-fcd5-4231-aff5-fff583eaa0b1' },
createdBy: { createdBy: {
universalIdentifier: '20202020-f6ab-4d98-af24-a3d5b664148a', universalIdentifier: '20202020-f6ab-4d98-af24-a3d5b664148a',
+2 -1
View File
@@ -46057,7 +46057,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mrmime@npm:^2.0.0": "mrmime@npm:^2.0.0, mrmime@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "mrmime@npm:2.0.1" resolution: "mrmime@npm:2.0.1"
checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761
@@ -57161,6 +57161,7 @@ __metadata:
lodash.upperfirst: "npm:4.3.1" lodash.upperfirst: "npm:4.3.1"
mailparser: "npm:3.9.1" mailparser: "npm:3.9.1"
microdiff: "npm:1.4.0" microdiff: "npm:1.4.0"
mrmime: "npm:^2.0.1"
ms: "npm:2.1.3" ms: "npm:2.1.3"
nest-commander: "npm:^3.19.1" nest-commander: "npm:^3.19.1"
node-ical: "npm:^0.20.1" node-ical: "npm:^0.20.1"