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
@@ -12,11 +12,14 @@ export const buildIdentifierGqlFields = (
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
>,
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadata);
const imageIdentifierField =
getImageIdentifierFieldMetadataItem(objectMetadata);
const imageIdentifierField = getImageIdentifierFieldMetadataItem(
objectMetadata,
isFilesFieldMigrated,
);
return {
id: true,
@@ -27,6 +27,7 @@ export type GenerateDepthRecordGqlFieldsFromFields = {
>[];
depth: 0 | 1;
shouldOnlyLoadRelationIdentifiers?: boolean;
isFilesFieldMigrated?: boolean;
};
export const generateDepthRecordGqlFieldsFromFields = ({
@@ -34,6 +35,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({
fields,
depth,
shouldOnlyLoadRelationIdentifiers = true,
isFilesFieldMigrated,
}: GenerateDepthRecordGqlFieldsFromFields) => {
const generatedRecordGqlFields: RecordGqlFields = fields.reduce(
(recordGqlFields, fieldMetadata) => {
@@ -83,6 +85,7 @@ export const generateDepthRecordGqlFieldsFromFields = ({
const junctionGqlFields = generateJunctionRelationGqlFields({
fieldMetadataItem: fieldMetadata,
objectMetadataItems,
isFilesFieldMigrated,
});
if (isDefined(junctionGqlFields) && depth === 1) {
@@ -97,7 +100,10 @@ export const generateDepthRecordGqlFieldsFromFields = ({
getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem);
const imageIdentifierFieldMetadataItem =
getImageIdentifierFieldMetadataItem(targetObjectMetadataItem);
getImageIdentifierFieldMetadataItem(
targetObjectMetadataItem,
isFilesFieldMigrated,
);
const relationIdentifierSubGqlFields = {
id: true,
@@ -16,11 +16,13 @@ type JunctionFieldMetadataItem = Pick<
type GenerateJunctionRelationGqlFieldsArgs = {
fieldMetadataItem: JunctionFieldMetadataItem;
objectMetadataItems: JunctionObjectMetadataItem[];
isFilesFieldMigrated?: boolean;
};
const buildRegularTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetField.relation?.targetObjectMetadata.id,
@@ -31,13 +33,17 @@ const buildRegularTargetFieldGqlFields = (
}
return {
[targetField.name]: buildIdentifierGqlFields(targetObjectMetadata),
[targetField.name]: buildIdentifierGqlFields(
targetObjectMetadata,
isFilesFieldMigrated,
),
};
};
const buildMorphTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
const morphRelations = targetField.morphRelations;
@@ -63,7 +69,10 @@ const buildMorphTargetFieldGqlFields = (
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata);
result[computedFieldName] = buildIdentifierGqlFields(
targetObjectMetadata,
isFilesFieldMigrated,
);
}
return result;
@@ -72,17 +81,27 @@ const buildMorphTargetFieldGqlFields = (
const buildTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
isFilesFieldMigrated?: boolean,
): RecordGqlFields => {
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
return buildMorphTargetFieldGqlFields(targetField, objectMetadataItems);
return buildMorphTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
);
}
return buildRegularTargetFieldGqlFields(targetField, objectMetadataItems);
return buildRegularTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
);
};
// Generates GraphQL fields for a junction relation, including the nested target objects
export const generateJunctionRelationGqlFields = ({
fieldMetadataItem,
objectMetadataItems,
isFilesFieldMigrated,
}: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => {
const junctionConfig = getJunctionConfig({
settings: fieldMetadataItem.settings,
@@ -100,13 +119,17 @@ export const generateJunctionRelationGqlFields = ({
const junctionTargetFields = targetFields.reduce<RecordGqlFields>(
(acc, targetField) => ({
...acc,
...buildTargetFieldGqlFields(targetField, objectMetadataItems),
...buildTargetFieldGqlFields(
targetField,
objectMetadataItems,
isFilesFieldMigrated,
),
}),
{},
);
return {
...buildIdentifierGqlFields(junctionObjectMetadata),
...buildIdentifierGqlFields(junctionObjectMetadata, isFilesFieldMigrated),
...junctionTargetFields,
};
};
@@ -56,7 +56,6 @@ export const RecordFieldList = ({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular,
objectRecordId,
});
const isRecordReadOnly = useIsRecordReadOnly({
@@ -9,7 +9,9 @@ import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
type UseRecordsFieldVisibleGqlFields = {
objectMetadataItem: ObjectMetadataItem;
@@ -29,6 +31,10 @@ export const useRecordsFieldVisibleGqlFields = ({
const { objectMetadataItems } = useObjectMetadataItems();
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({
objectMetadataItems,
fields: visibleRecordFields.map(
@@ -36,12 +42,15 @@ export const useRecordsFieldVisibleGqlFields = ({
fieldMetadataItemByFieldMetadataItemId[field.fieldMetadataItemId],
),
depth: 1,
isFilesFieldMigrated,
});
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const imageIdentifierFieldMetadataItem =
getImageIdentifierFieldMetadataItem(objectMetadataItem);
const imageIdentifierFieldMetadataItem = getImageIdentifierFieldMetadataItem(
objectMetadataItem,
isFilesFieldMigrated,
);
const hasPosition = hasObjectMetadataItemPositionField(objectMetadataItem);
@@ -64,7 +64,6 @@ export const ObjectRecordShowPageBreadcrumb = ({
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular,
objectRecordId,
});
const isLabelIdentifierReadOnly = useIsRecordFieldReadOnly({
@@ -1,8 +1,10 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { usePersonAvatarUpload } from '@/object-record/record-show/hooks/usePersonAvatarUpload';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
@@ -11,10 +13,11 @@ import { RecordTitleCell } from '@/object-record/record-title-cell/components/Re
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { FeatureFlagKey } from '~/generated/graphql';
type SummaryCardProps = {
objectNameSingular: string;
@@ -41,12 +44,15 @@ export const SummaryCard = ({
const allowRequestsToTwentyIcons = useRecoilValue(
allowRequestsToTwentyIconsState,
);
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { onUploadPicture, useUpdateOneObjectRecordMutation } =
useRecordShowContainerActions({
objectNameSingular,
objectRecordId,
});
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular,
});
const { onUploadPicture } = usePersonAvatarUpload(objectRecordId);
const isMobile = useIsMobile() || isInRightDrawer;
@@ -54,6 +60,7 @@ export const SummaryCard = ({
recordStoreIdentifierFamilySelector({
recordId: objectRecordId,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}),
);
@@ -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 RecordUpdateHookParams,
} from '@/object-record/record-field/ui/contexts/FieldContext';
import {
FileFolder,
useUploadImageMutation,
} from '~/generated-metadata/graphql';
interface UseRecordShowContainerActionsProps {
objectNameSingular: string;
objectRecordId: string;
}
export const useRecordShowContainerActions = ({
objectNameSingular,
objectRecordId,
}: UseRecordShowContainerActionsProps) => {
const [uploadImage] = useUploadImageMutation();
const { updateOneRecord } = useUpdateOneRecord();
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
@@ -32,35 +25,7 @@ export const useRecordShowContainerActions = ({
return [updateEntity, { loading: false }];
};
const onUploadPicture = async (file: File) => {
if (objectNameSingular !== 'person') {
return;
}
const result = await uploadImage({
variables: {
file,
fileFolder: FileFolder.PersonPicture,
},
});
const avatarSignedFile = result?.data?.uploadImage;
if (!avatarSignedFile) {
return;
}
await updateOneRecord({
objectNameSingular,
idToUpdate: objectRecordId,
updateOneRecordInput: {
avatarUrl: avatarSignedFile.path,
},
});
};
return {
onUploadPicture,
useUpdateOneObjectRecordMutation,
};
};
@@ -11,9 +11,11 @@ export const recordStoreIdentifierFamilySelector = selectorFamily({
({
recordId,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
}: {
recordId: string;
allowRequestsToTwentyIcons: boolean;
isFilesFieldMigrated?: boolean;
}) =>
({ get }) => {
const recordFromStore = get(recordStoreFamilyState(recordId));
@@ -35,6 +37,7 @@ export const recordStoreIdentifierFamilySelector = selectorFamily({
objectMetadataItem: objectMetadataItem,
record: recordFromStore,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
});
},
});
@@ -17,6 +17,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
export const getRecordChipGenerators = (
objectMetadataItems: ObjectMetadataItem[],
allowRequestsToTwentyIcons?: boolean,
isFilesFieldMigrated?: boolean,
) => {
const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName =
{};
@@ -94,6 +95,7 @@ export const getRecordChipGenerators = (
record,
imageIdentifierFieldMetadataToUse,
allowRequestsToTwentyIcons,
isFilesFieldMigrated,
),
avatarType,
isLabelIdentifier,