mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
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 { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
|
||||
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
|
||||
|
|
@ -11,15 +12,18 @@ import { recordStoreIdentifierFamilySelector } from '@/object-record/record-stor
|
|||
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
|
||||
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Avatar } from 'twenty-ui/display';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
FieldMetadataType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
|
||||
export const CommandMenuRecordInfo = ({
|
||||
commandMenuPageInstanceId,
|
||||
|
|
@ -51,10 +55,15 @@ export const CommandMenuRecordInfo = ({
|
|||
}),
|
||||
);
|
||||
|
||||
const isFilesFieldMigrated = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
|
||||
);
|
||||
|
||||
const recordIdentifier = useRecoilValue(
|
||||
recordStoreIdentifierFamilySelector({
|
||||
recordId: objectRecordId,
|
||||
allowRequestsToTwentyIcons,
|
||||
isFilesFieldMigrated,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -80,7 +89,6 @@ export const CommandMenuRecordInfo = ({
|
|||
|
||||
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
|
||||
objectNameSingular,
|
||||
objectRecordId,
|
||||
});
|
||||
|
||||
const fieldDefinition = {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators';
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
|
||||
export const PreComputedChipGeneratorsProvider = ({
|
||||
children,
|
||||
|
|
@ -13,13 +15,17 @@ export const PreComputedChipGeneratorsProvider = ({
|
|||
const allowRequestsToTwentyIcons = useRecoilValue(
|
||||
allowRequestsToTwentyIconsState,
|
||||
);
|
||||
const isFilesFieldMigrated = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
|
||||
);
|
||||
const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } =
|
||||
useMemo(() => {
|
||||
return getRecordChipGenerators(
|
||||
objectMetadataItems,
|
||||
allowRequestsToTwentyIcons,
|
||||
isFilesFieldMigrated,
|
||||
);
|
||||
}, [allowRequestsToTwentyIcons, objectMetadataItems]);
|
||||
}, [allowRequestsToTwentyIcons, isFilesFieldMigrated, objectMetadataItems]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -3,19 +3,21 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
|
|||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { getCompanyDomainName } from '@/object-metadata/utils/getCompanyDomainName';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
getImageAbsoluteURI,
|
||||
getLogoUrlFromDomainName,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { getImageIdentifierFieldValue } from './getImageIdentifierFieldValue';
|
||||
|
||||
export const getAvatarUrl = (
|
||||
objectNameSingular: string,
|
||||
record: ObjectRecord,
|
||||
imageIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
|
||||
allowRequestsToTwentyIcons?: boolean | undefined,
|
||||
isFilesFieldMigrated?: boolean | undefined,
|
||||
) => {
|
||||
if (objectNameSingular === CoreObjectNameSingular.WorkspaceMember) {
|
||||
return record.avatarUrl ?? undefined;
|
||||
|
|
@ -31,7 +33,11 @@ export const getAvatarUrl = (
|
|||
}
|
||||
|
||||
if (objectNameSingular === CoreObjectNameSingular.Person) {
|
||||
return isDefined(record.avatarUrl)
|
||||
if (isFilesFieldMigrated === true) {
|
||||
return record.avatarFile?.[0]?.url ?? '';
|
||||
}
|
||||
|
||||
return isNonEmptyString(record.avatarUrl)
|
||||
? getImageAbsoluteURI({
|
||||
imageUrl: record.avatarUrl,
|
||||
baseUrl: REACT_APP_SERVER_BASE_URL,
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ export const getImageIdentifierFieldMetadataItem = (
|
|||
ObjectMetadataItem,
|
||||
'fields' | 'imageIdentifierFieldMetadataId' | 'nameSingular'
|
||||
>,
|
||||
isFilesFieldMigrated?: boolean,
|
||||
): FieldMetadataItem | undefined =>
|
||||
objectMetadataItem.fields.find((fieldMetadataItem) =>
|
||||
isImageIdentifierField({
|
||||
fieldMetadataItem,
|
||||
objectMetadataItem,
|
||||
isFilesFieldMigrated,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const getObjectRecordIdentifier = ({
|
|||
objectMetadataItem,
|
||||
record,
|
||||
allowRequestsToTwentyIcons,
|
||||
isFilesFieldMigrated,
|
||||
}: {
|
||||
objectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
|
|
@ -22,6 +23,7 @@ export const getObjectRecordIdentifier = ({
|
|||
>;
|
||||
record: ObjectRecord;
|
||||
allowRequestsToTwentyIcons: boolean;
|
||||
isFilesFieldMigrated?: boolean;
|
||||
}): ObjectRecordIdentifier => {
|
||||
const labelIdentifierFieldMetadataItem =
|
||||
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
|
||||
|
|
@ -43,6 +45,7 @@ export const getObjectRecordIdentifier = ({
|
|||
record,
|
||||
imageIdentifierFieldMetadata,
|
||||
allowRequestsToTwentyIcons,
|
||||
isFilesFieldMigrated,
|
||||
);
|
||||
|
||||
const linkToShowPage = getLinkToShowPage(
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
|
|||
export const isImageIdentifierField = ({
|
||||
fieldMetadataItem,
|
||||
objectMetadataItem,
|
||||
isFilesFieldMigrated,
|
||||
}: {
|
||||
fieldMetadataItem: Pick<FieldMetadataItem, 'id' | 'name'>;
|
||||
objectMetadataItem: Pick<
|
||||
ObjectMetadataItem,
|
||||
'imageIdentifierFieldMetadataId' | 'nameSingular'
|
||||
>;
|
||||
isFilesFieldMigrated?: boolean;
|
||||
}) => {
|
||||
if (
|
||||
objectMetadataItem.nameSingular === CoreObjectNameSingular.Company &&
|
||||
|
|
@ -19,6 +21,13 @@ export const isImageIdentifierField = ({
|
|||
return true;
|
||||
}
|
||||
|
||||
if (objectMetadataItem.nameSingular === CoreObjectNameSingular.Person) {
|
||||
if (isFilesFieldMigrated === true) {
|
||||
return fieldMetadataItem.name === 'avatarFile';
|
||||
}
|
||||
return fieldMetadataItem.name === 'avatarUrl';
|
||||
}
|
||||
|
||||
return (
|
||||
fieldMetadataItem.id === objectMetadataItem.imageIdentifierFieldMetadataId
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ export const WidgetActionFieldEdit = () => {
|
|||
|
||||
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
objectRecordId: targetRecord.id,
|
||||
});
|
||||
|
||||
const isRecordReadOnly = useIsRecordReadOnly({
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export const FieldWidgetDisplay = ({
|
|||
|
||||
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
objectRecordId: recordId,
|
||||
});
|
||||
|
||||
const isRecordReadOnly = useIsRecordReadOnly({
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
|
|||
|
||||
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
|
||||
objectNameSingular: targetRecord.targetObjectNameSingular,
|
||||
objectRecordId: targetRecord.id,
|
||||
});
|
||||
|
||||
const isRecordReadOnly = useIsRecordReadOnly({
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@
|
|||
"lodash.upperfirst": "4.3.1",
|
||||
"mailparser": "3.9.1",
|
||||
"microdiff": "1.4.0",
|
||||
"mrmime": "^2.0.1",
|
||||
"ms": "2.1.3",
|
||||
"nest-commander": "^3.19.1",
|
||||
"node-ical": "^0.20.1",
|
||||
|
|
|
|||
|
|
@ -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 { V1_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module';
|
||||
import { V1_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module';
|
||||
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
|
|
@ -10,6 +11,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
|||
imports: [
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
V1_17_UpgradeVersionCommandModule,
|
||||
V1_18_UpgradeVersionCommandModule,
|
||||
DataSourceModule,
|
||||
],
|
||||
providers: [UpgradeCommand],
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/comma
|
|||
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
|
||||
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
|
||||
import { MigrateWorkflowCodeStepsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-workflow-code-steps.command';
|
||||
import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
|
|
@ -49,6 +50,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
|||
protected readonly makeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
protected readonly migrateWorkflowCodeStepsCommand: MigrateWorkflowCodeStepsCommand,
|
||||
protected readonly fixMorphRelationFieldNamesCommand: FixMorphRelationFieldNamesCommand,
|
||||
|
||||
// 1.18 Commands
|
||||
protected readonly migratePersonAvatarFilesCommand: MigratePersonAvatarFilesCommand,
|
||||
) {
|
||||
super(
|
||||
workspaceRepository,
|
||||
|
|
@ -74,9 +78,14 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
|||
this.fixMorphRelationFieldNamesCommand,
|
||||
];
|
||||
|
||||
const commands_1180: VersionCommands = [
|
||||
this.migratePersonAvatarFilesCommand,
|
||||
];
|
||||
|
||||
this.allCommands = {
|
||||
'1.16.0': commands_1160,
|
||||
'1.17.0': commands_1170,
|
||||
'1.18.0': commands_1180,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
applicationId: application.id,
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size:
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.byteLength(sourceFile)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import { CustomException } from 'src/utils/custom-exception';
|
|||
export enum FileStorageExceptionCode {
|
||||
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
||||
INVALID_EXTENSION = 'INVALID_EXTENSION',
|
||||
}
|
||||
|
||||
const getFileStorageExceptionUserFriendlyMessage = (
|
||||
code: FileStorageExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case FileStorageExceptionCode.INVALID_EXTENSION:
|
||||
return msg`Wrong extension for file.`;
|
||||
case FileStorageExceptionCode.FILE_NOT_FOUND:
|
||||
return msg`File not found.`;
|
||||
case FileStorageExceptionCode.ACCESS_DENIED:
|
||||
|
|
|
|||
|
|
@ -56,4 +56,11 @@ export class FileEntity extends WorkspaceRelatedEntity {
|
|||
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
settings: FileSettings | null;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: 'varchar',
|
||||
default: 'application/octet-stream',
|
||||
})
|
||||
mimeType: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||
import { Readable } from 'stream';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
|
@ -44,26 +45,23 @@ export class FilesFieldService {
|
|||
async uploadFile({
|
||||
file,
|
||||
filename,
|
||||
declaredMimeType,
|
||||
workspaceId,
|
||||
fieldMetadataId,
|
||||
}: {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
declaredMimeType: string | undefined;
|
||||
workspaceId: string;
|
||||
fieldMetadataId: string;
|
||||
}): Promise<FileEntity> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file,
|
||||
declaredMimeType,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${ext ? `.${ext}` : ''}`;
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({
|
||||
select: ['applicationId', 'universalIdentifier'],
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export class FilesFieldResolver {
|
|||
@AuthWorkspace()
|
||||
{ id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename, mimetype }: FileUpload,
|
||||
{ createReadStream, filename }: FileUpload,
|
||||
@Args({
|
||||
name: 'fieldMetadataId',
|
||||
type: () => String,
|
||||
|
|
@ -44,7 +44,6 @@ export class FilesFieldResolver {
|
|||
return await this.filesFieldService.uploadFile({
|
||||
file: buffer,
|
||||
filename,
|
||||
declaredMimeType: mimetype,
|
||||
workspaceId,
|
||||
fieldMetadataId,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,28 +1,35 @@
|
|||
import FileType from 'file-type';
|
||||
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
|
||||
jest.mock('file-type', () => ({
|
||||
fromBuffer: jest.fn(),
|
||||
}));
|
||||
// Mock detectableMimeTypes to work around ESM/CommonJS interop issues in Jest
|
||||
jest.mock('file-type', () => {
|
||||
const actual = jest.requireActual('file-type');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
mimeTypes: actual.mimeTypes,
|
||||
};
|
||||
});
|
||||
|
||||
describe('extractFileInfo', () => {
|
||||
const mockBuffer = Buffer.from('test content');
|
||||
// Real PNG file header (magic numbers)
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
// Real PDF file header
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
|
||||
it('should use detected file type when available', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue({
|
||||
mime: 'image/png',
|
||||
ext: 'png',
|
||||
});
|
||||
// Plain text buffer (no magic numbers)
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
// Real ZIP file header (for testing docx, xlsx, etc.)
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
it('should detect PNG from buffer magic numbers', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
file: pngBuffer,
|
||||
filename: 'image.png',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
|
@ -31,31 +38,10 @@ describe('extractFileInfo', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should fall back to declared values when file type is not detected', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
it('should detect PDF from buffer magic numbers', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'text/plain',
|
||||
filename: 'test.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/plain',
|
||||
ext: 'txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing declared mime type when file type is detected', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue({
|
||||
mime: 'application/pdf',
|
||||
ext: 'pdf',
|
||||
});
|
||||
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: undefined,
|
||||
filename: 'document',
|
||||
file: pdfBuffer,
|
||||
filename: 'document.pdf',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
|
@ -64,45 +50,133 @@ describe('extractFileInfo', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should handle both mime type and extension being undefined', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
it('should use extension-based lookup for text files', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: undefined,
|
||||
file: textBuffer,
|
||||
filename: 'document.txt',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/plain',
|
||||
ext: 'txt',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle CSV files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'data.csv',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/csv',
|
||||
ext: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: Buffer.from('{"key": "value"}'),
|
||||
filename: 'config.json',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/json',
|
||||
ext: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return application/octet-stream for unknown extensions', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'file.unknown',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/octet-stream',
|
||||
ext: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return application/octet-stream for files without extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'file-without-extension',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: undefined,
|
||||
mimeType: 'application/octet-stream',
|
||||
ext: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle filenames with multiple dots', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
it('should detect ZIP files from buffer', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'application/gzip',
|
||||
filename: 'archive.tar.gz',
|
||||
file: zipBuffer,
|
||||
filename: 'archive.zip',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
mimeType: 'application/gzip',
|
||||
ext: 'gz',
|
||||
mimeType: 'application/zip',
|
||||
ext: 'zip',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call FileType.fromBuffer with the provided buffer', async () => {
|
||||
(FileType.fromBuffer as jest.Mock).mockResolvedValue(undefined);
|
||||
it('should throw error when PNG extension is used with non-PNG buffer', async () => {
|
||||
await expect(
|
||||
extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"File content does not match its extension. The file has extension 'png' (expected mime type: image/png), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
);
|
||||
});
|
||||
|
||||
await extractFileInfo({
|
||||
file: mockBuffer,
|
||||
declaredMimeType: 'image/png',
|
||||
filename: 'image.png',
|
||||
it('should throw error when PDF extension is used with non-PDF buffer', async () => {
|
||||
await expect(
|
||||
extractFileInfo({
|
||||
file: textBuffer,
|
||||
filename: 'fake-document.pdf',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"File content does not match its extension. The file has extension 'pdf' (expected mime type: application/pdf), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle markdown files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: Buffer.from('# Heading\n\nContent'),
|
||||
filename: 'README.md',
|
||||
});
|
||||
|
||||
expect(FileType.fromBuffer).toHaveBeenCalledWith(mockBuffer);
|
||||
expect(result).toEqual({
|
||||
mimeType: 'text/markdown',
|
||||
ext: 'md',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle HTML files using extension', async () => {
|
||||
const result = await extractFileInfo({
|
||||
file: Buffer.from('<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';
|
||||
|
||||
export const extractFileInfo = async ({
|
||||
file,
|
||||
declaredMimeType,
|
||||
filename,
|
||||
}: {
|
||||
file: Buffer;
|
||||
declaredMimeType: string | undefined;
|
||||
filename: string;
|
||||
}) => {
|
||||
const { ext: declaredExt } = buildFileInfo(filename);
|
||||
|
||||
const detectedFileType = await FileType.fromBuffer(file);
|
||||
const { ext: detectedExt, mime: detectedMime } =
|
||||
(await FileType.fromBuffer(file)) ?? {};
|
||||
|
||||
const mimeType = detectedFileType?.mime ?? declaredMimeType;
|
||||
if (isDefined(detectedExt) && isDefined(detectedMime)) {
|
||||
return {
|
||||
mimeType: detectedMime,
|
||||
ext: detectedExt,
|
||||
};
|
||||
}
|
||||
|
||||
const ext = detectedFileType?.ext ?? declaredExt;
|
||||
const ext = declaredExt;
|
||||
|
||||
let mimeType: string = 'application/octet-stream';
|
||||
|
||||
if (isNonEmptyString(ext)) {
|
||||
const mimeTypeFromExtension = lookup(ext);
|
||||
|
||||
if (
|
||||
mimeTypeFromExtension &&
|
||||
FileType.mimeTypes.has(mimeTypeFromExtension as MimeType)
|
||||
) {
|
||||
throw new FileStorageException(
|
||||
`File content does not match its extension. The file has extension '${ext}' (expected mime type: ${mimeTypeFromExtension}), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.`,
|
||||
FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
{
|
||||
userFriendlyMessage: msg`The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
mimeType = mimeTypeFromExtension ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,14 @@ export class FilesFieldSync {
|
|||
return null;
|
||||
}
|
||||
|
||||
const isModifyingFilesField = filesFields.some((filesField) =>
|
||||
isDefined(updatePayload[filesField.name as keyof typeof updatePayload]),
|
||||
);
|
||||
|
||||
if (!isModifyingFilesField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingRecords.length !== 1) {
|
||||
throw new TwentyORMException(
|
||||
`Cannot update multiple records with files field at once`,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -229,6 +229,7 @@ export const buildPersonStandardFlatFieldMetadatas = ({
|
|||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
//deprecated
|
||||
avatarUrl: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
|
|
@ -246,6 +247,26 @@ export const buildPersonStandardFlatFieldMetadatas = ({
|
|||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
avatarFile: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'avatarFile',
|
||||
type: FieldMetadataType.FILES,
|
||||
label: 'Avatar File',
|
||||
description: "Contact's avatar file",
|
||||
icon: 'IconFileUpload',
|
||||
isSystem: true,
|
||||
isNullable: true,
|
||||
settings: {
|
||||
maxNumberOfValues: 1,
|
||||
},
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
position: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type PhonesMetadata,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { type FileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
|
||||
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
|
||||
|
|
@ -42,7 +43,9 @@ export class PersonWorkspaceEntity extends BaseWorkspaceEntity {
|
|||
phone: string | null;
|
||||
phones: PhonesMetadata;
|
||||
city: string | null;
|
||||
/** @deprecated Use `avatarFile` field instead */
|
||||
avatarUrl: string | null;
|
||||
avatarFile: FileOutput[] | null;
|
||||
position: number;
|
||||
createdBy: ActorMetadata;
|
||||
updatedBy: ActorMetadata;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ export const PERSON_GQL_FIELDS = `
|
|||
city
|
||||
jobTitle
|
||||
avatarUrl
|
||||
avatarFile {
|
||||
fileId
|
||||
label
|
||||
extension
|
||||
url
|
||||
}
|
||||
intro
|
||||
searchVector
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
emails {
|
||||
primaryEmail
|
||||
additionalEmails
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe('people resolvers (integration)', () => {
|
|||
expect(person).toHaveProperty('id');
|
||||
expect(person).toHaveProperty('jobTitle');
|
||||
expect(person).toHaveProperty('avatarUrl');
|
||||
expect(person).toHaveProperty('avatarFile');
|
||||
expect(person).toHaveProperty('intro');
|
||||
expect(person).toHaveProperty('searchVector');
|
||||
expect(person).toHaveProperty('name');
|
||||
|
|
@ -84,6 +85,7 @@ describe('people resolvers (integration)', () => {
|
|||
expect(createdPerson).toHaveProperty('id');
|
||||
expect(createdPerson).toHaveProperty('jobTitle');
|
||||
expect(createdPerson).toHaveProperty('avatarUrl');
|
||||
expect(createdPerson).toHaveProperty('avatarFile');
|
||||
expect(createdPerson).toHaveProperty('intro');
|
||||
expect(createdPerson).toHaveProperty('searchVector');
|
||||
expect(createdPerson).toHaveProperty('name');
|
||||
|
|
@ -113,6 +115,7 @@ describe('people resolvers (integration)', () => {
|
|||
expect(person).toHaveProperty('id');
|
||||
expect(person).toHaveProperty('jobTitle');
|
||||
expect(person).toHaveProperty('avatarUrl');
|
||||
expect(person).toHaveProperty('avatarFile');
|
||||
expect(person).toHaveProperty('intro');
|
||||
expect(person).toHaveProperty('searchVector');
|
||||
expect(person).toHaveProperty('name');
|
||||
|
|
@ -141,6 +144,7 @@ describe('people resolvers (integration)', () => {
|
|||
expect(person).toHaveProperty('id');
|
||||
expect(person).toHaveProperty('jobTitle');
|
||||
expect(person).toHaveProperty('avatarUrl');
|
||||
expect(person).toHaveProperty('avatarFile');
|
||||
expect(person).toHaveProperty('intro');
|
||||
expect(person).toHaveProperty('searchVector');
|
||||
expect(person).toHaveProperty('name');
|
||||
|
|
|
|||
|
|
@ -210,14 +210,14 @@ describe('files-field.controller - GET /files-field/:id', () => {
|
|||
});
|
||||
|
||||
it('should download image file successfully with valid url', async () => {
|
||||
const imageContent = 'fake-png-image-binary-content';
|
||||
const imageFile = await uploadFile(
|
||||
'test-image.png',
|
||||
imageContent,
|
||||
'image/png',
|
||||
const testFileContent = 'This is test file content for download';
|
||||
const textFile = await uploadFile(
|
||||
'test-file.txt',
|
||||
testFileContent,
|
||||
'text/plain',
|
||||
);
|
||||
|
||||
uploadedFiles.push(imageFile);
|
||||
uploadedFiles.push(textFile);
|
||||
|
||||
const createResponse = await makeGraphqlAPIRequest({
|
||||
query: createRecordsQuery,
|
||||
|
|
@ -227,8 +227,8 @@ describe('files-field.controller - GET /files-field/:id', () => {
|
|||
name: 'Record with image',
|
||||
filesField: [
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'test-image.png',
|
||||
fileId: textFile.id,
|
||||
label: 'test-file.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -244,7 +244,7 @@ describe('files-field.controller - GET /files-field/:id', () => {
|
|||
const fileUrl = createdRecord.filesField[0].url;
|
||||
|
||||
expect(fileUrl).toBeDefined();
|
||||
expect(createdRecord.filesField[0].extension).toBe('.png');
|
||||
expect(createdRecord.filesField[0].extension).toBe('.txt');
|
||||
|
||||
// Extract path from full URL (remove domain)
|
||||
const urlPath = new URL(fileUrl).pathname + new URL(fileUrl).search;
|
||||
|
|
@ -254,7 +254,7 @@ describe('files-field.controller - GET /files-field/:id', () => {
|
|||
);
|
||||
|
||||
expect(downloadResponse.status).toBe(200);
|
||||
expect(downloadResponse.text).toBe(imageContent);
|
||||
expect(downloadResponse.text).toBe(testFileContent);
|
||||
|
||||
await makeGraphqlAPIRequest({
|
||||
query: deleteRecordsQuery,
|
||||
|
|
|
|||
|
|
@ -208,23 +208,23 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
});
|
||||
|
||||
it('createMany without upsert - files sync successfully', async () => {
|
||||
const imageFile = await uploadFile(
|
||||
'test-image.png',
|
||||
'fake image content',
|
||||
'image/png',
|
||||
const file1 = await uploadFile(
|
||||
'test-file1.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
const textFile = await uploadFile(
|
||||
const file2 = await uploadFile(
|
||||
'test-text.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
|
||||
uploadedFiles.push(imageFile, textFile);
|
||||
uploadedFiles.push(file1, file2);
|
||||
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file1.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file2.id)).toBe(true);
|
||||
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: createRecordsQuery,
|
||||
|
|
@ -234,12 +234,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
name: 'Record with image',
|
||||
filesField: [
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'test-image.png',
|
||||
fileId: file1.id,
|
||||
label: 'test-file1.txt',
|
||||
},
|
||||
{
|
||||
fileId: textFile.id,
|
||||
label: 'test-text.txt',
|
||||
fileId: file2.id,
|
||||
label: 'test-file2.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -254,17 +254,17 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
const createdRecord = response.body.data.createFileSyncTestObjects[0];
|
||||
|
||||
expect(createdRecord.filesField).toHaveLength(2);
|
||||
expect(createdRecord.filesField[0].fileId).toBe(imageFile.id);
|
||||
expect(createdRecord.filesField[0].extension).toBe('.png');
|
||||
expect(createdRecord.filesField[1].fileId).toBe(textFile.id);
|
||||
expect(createdRecord.filesField[0].fileId).toBe(file1.id);
|
||||
expect(createdRecord.filesField[0].extension).toBe('.txt');
|
||||
expect(createdRecord.filesField[1].fileId).toBe(file2.id);
|
||||
expect(createdRecord.filesField[1].extension).toBe('.txt');
|
||||
expect(createdRecord.filesField[0].url).toBeDefined();
|
||||
expect(createdRecord.filesField[1].url).toBeDefined();
|
||||
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
|
||||
|
||||
await makeGraphqlAPIRequest({
|
||||
query: deleteRecordsQuery,
|
||||
|
|
@ -275,30 +275,30 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
});
|
||||
|
||||
it('createMany with upsert - files sync successfully', async () => {
|
||||
const imageFile = await uploadFile(
|
||||
'test-image.png',
|
||||
'fake image content',
|
||||
'image/png',
|
||||
);
|
||||
const textFile = await uploadFile(
|
||||
'test-text.txt',
|
||||
const file1 = await uploadFile(
|
||||
'test-file1.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
const anotherImageFile = await uploadFile(
|
||||
'test-another-image.png',
|
||||
'fake another image content',
|
||||
'image/png',
|
||||
const file2 = await uploadFile(
|
||||
'test-file2.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
const file3 = await uploadFile(
|
||||
'test-file3.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
|
||||
uploadedFiles.push(imageFile, textFile, anotherImageFile);
|
||||
uploadedFiles.push(file1, file2, file3);
|
||||
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(anotherImageFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(anotherImageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file3.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file1.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file2.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file3.id)).toBe(true);
|
||||
|
||||
const createResponse = await makeGraphqlAPIRequest({
|
||||
query: createRecordsQuery,
|
||||
|
|
@ -308,12 +308,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
name: 'Record to upsert',
|
||||
filesField: [
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'imageFile-label.png',
|
||||
fileId: file1.id,
|
||||
label: 'test-file1.txt',
|
||||
},
|
||||
{
|
||||
fileId: anotherImageFile.id,
|
||||
label: 'anotherImageFile-label.png',
|
||||
fileId: file2.id,
|
||||
label: 'test-file2.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -325,7 +325,7 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
|
||||
const recordId = createdRecord.id;
|
||||
|
||||
expect(createdRecord.filesField[0].extension).toBe('.png');
|
||||
expect(createdRecord.filesField[0].extension).toBe('.txt');
|
||||
|
||||
const upsertResponse = await makeGraphqlAPIRequest({
|
||||
query: createRecordsQuery,
|
||||
|
|
@ -336,16 +336,16 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
name: 'Record updated via upsert',
|
||||
filesField: [
|
||||
{
|
||||
fileId: textFile.id,
|
||||
label: 'new-added-text-file.txt',
|
||||
fileId: file1.id,
|
||||
label: 'updated-label-test-file1.txt',
|
||||
},
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'imageFile-label.png',
|
||||
fileId: file2.id,
|
||||
label: 'test-file2.txt',
|
||||
},
|
||||
{
|
||||
fileId: anotherImageFile.id,
|
||||
label: 'updated-anotherImageFile-label.png',
|
||||
fileId: file3.id,
|
||||
label: 'new-test-file3.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -361,24 +361,24 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
expect(updatedRecord.id).toBe(recordId);
|
||||
expect(updatedRecord.name).toBe('Record updated via upsert');
|
||||
expect(updatedRecord.filesField).toHaveLength(3);
|
||||
expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id);
|
||||
expect(updatedRecord.filesField[1].label).toBe('imageFile-label.png');
|
||||
expect(updatedRecord.filesField[1].extension).toBe('.png');
|
||||
expect(updatedRecord.filesField[1].fileId).toBe(file2.id);
|
||||
expect(updatedRecord.filesField[1].label).toBe('test-file2.txt');
|
||||
expect(updatedRecord.filesField[1].extension).toBe('.txt');
|
||||
expect(updatedRecord.filesField[1].url).toBeDefined();
|
||||
expect(updatedRecord.filesField[2].fileId).toBe(anotherImageFile.id);
|
||||
expect(updatedRecord.filesField[2].label).toBe(
|
||||
'updated-anotherImageFile-label.png',
|
||||
);
|
||||
expect(updatedRecord.filesField[2].extension).toBe('.png');
|
||||
expect(updatedRecord.filesField[2].fileId).toBe(file3.id);
|
||||
expect(updatedRecord.filesField[2].label).toBe('new-test-file3.txt');
|
||||
expect(updatedRecord.filesField[2].extension).toBe('.txt');
|
||||
expect(updatedRecord.filesField[2].url).toBeDefined();
|
||||
expect(updatedRecord.filesField[0].fileId).toBe(textFile.id);
|
||||
expect(updatedRecord.filesField[0].label).toBe('new-added-text-file.txt');
|
||||
expect(updatedRecord.filesField[0].fileId).toBe(file1.id);
|
||||
expect(updatedRecord.filesField[0].label).toBe(
|
||||
'updated-label-test-file1.txt',
|
||||
);
|
||||
expect(updatedRecord.filesField[0].extension).toBe('.txt');
|
||||
expect(updatedRecord.filesField[0].url).toBeDefined();
|
||||
|
||||
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(anotherImageFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file3.id)).toBe(true);
|
||||
|
||||
await makeGraphqlAPIRequest({
|
||||
query: deleteRecordsQuery,
|
||||
|
|
@ -389,23 +389,23 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
});
|
||||
|
||||
it('updateOne - files sync successfully', async () => {
|
||||
const imageFile = await uploadFile(
|
||||
'test-image.png',
|
||||
'fake image content',
|
||||
'image/png',
|
||||
const file1 = await uploadFile(
|
||||
'test-file1.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
const textFile = await uploadFile(
|
||||
'test-text.txt',
|
||||
const file2 = await uploadFile(
|
||||
'test-file2.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
|
||||
uploadedFiles.push(imageFile, textFile);
|
||||
uploadedFiles.push(file1, file2);
|
||||
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file1.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file2.id)).toBe(true);
|
||||
|
||||
const createResponse = await makeGraphqlAPIRequest({
|
||||
query: createRecordsQuery,
|
||||
|
|
@ -415,8 +415,8 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
name: 'Record for updateOne test',
|
||||
filesField: [
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'original-image.png',
|
||||
fileId: file1.id,
|
||||
label: 'test-file1.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -425,12 +425,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
|
||||
|
||||
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
|
||||
const recordId = createdRecord.id;
|
||||
|
||||
expect(createdRecord.filesField[0].extension).toBe('.png');
|
||||
expect(createdRecord.filesField[0].extension).toBe('.txt');
|
||||
|
||||
const updateResponse = await makeGraphqlAPIRequest({
|
||||
query: updateRecordQuery,
|
||||
|
|
@ -440,12 +440,12 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
name: 'Record updated via updateOne',
|
||||
filesField: [
|
||||
{
|
||||
fileId: textFile.id,
|
||||
fileId: file1.id,
|
||||
label: 'added-text.txt',
|
||||
},
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'original-image.png',
|
||||
fileId: file2.id,
|
||||
label: 'test-file2.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -459,16 +459,16 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
expect(updatedRecord.id).toBe(recordId);
|
||||
expect(updatedRecord.name).toBe('Record updated via updateOne');
|
||||
expect(updatedRecord.filesField).toHaveLength(2);
|
||||
expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id);
|
||||
expect(updatedRecord.filesField[1].label).toBe('original-image.png');
|
||||
expect(updatedRecord.filesField[1].fileId).toBe(file2.id);
|
||||
expect(updatedRecord.filesField[1].label).toBe('test-file2.txt');
|
||||
expect(updatedRecord.filesField[1].url).toBeDefined();
|
||||
expect(updatedRecord.filesField[0].fileId).toBe(textFile.id);
|
||||
expect(updatedRecord.filesField[0].fileId).toBe(file1.id);
|
||||
expect(updatedRecord.filesField[0].label).toBe('added-text.txt');
|
||||
expect(updatedRecord.filesField[0].url).toBeDefined();
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
|
||||
|
||||
await makeGraphqlAPIRequest({
|
||||
query: deleteRecordsQuery,
|
||||
|
|
@ -479,23 +479,23 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
});
|
||||
|
||||
it('updateOne with removeFiles - verifies files can be removed', async () => {
|
||||
const imageFile = await uploadFile(
|
||||
'test-image-to-delete.png',
|
||||
'fake image content',
|
||||
'image/png',
|
||||
const file1 = await uploadFile(
|
||||
'test-file1.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
const textFile = await uploadFile(
|
||||
'test-text-to-keep.txt',
|
||||
const file2 = await uploadFile(
|
||||
'test-file2.txt',
|
||||
'fake text content',
|
||||
'text/plain',
|
||||
);
|
||||
|
||||
uploadedFiles.push(imageFile, textFile);
|
||||
uploadedFiles.push(file1, file2);
|
||||
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file1.id)).toBe(true);
|
||||
expect(await checkFileIsTemporary(file2.id)).toBe(true);
|
||||
|
||||
const createResponse = await makeGraphqlAPIRequest({
|
||||
query: createRecordsQuery,
|
||||
|
|
@ -505,11 +505,11 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
name: 'Record for file deletion test',
|
||||
filesField: [
|
||||
{
|
||||
fileId: imageFile.id,
|
||||
label: 'image-to-delete.png',
|
||||
fileId: file1.id,
|
||||
label: 'test-file1.txt',
|
||||
},
|
||||
{
|
||||
fileId: textFile.id,
|
||||
fileId: file2.id,
|
||||
label: 'text-to-keep.txt',
|
||||
},
|
||||
],
|
||||
|
|
@ -519,8 +519,8 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file1.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
|
||||
|
||||
const createdRecord = createResponse.body.data.createFileSyncTestObjects[0];
|
||||
const recordId = createdRecord.id;
|
||||
|
|
@ -532,7 +532,7 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
data: {
|
||||
filesField: [
|
||||
{
|
||||
fileId: textFile.id,
|
||||
fileId: file2.id,
|
||||
label: 'text-to-keep.txt',
|
||||
},
|
||||
],
|
||||
|
|
@ -545,11 +545,11 @@ describe('fileFieldSync - FILES field <> files sync', () => {
|
|||
const updatedRecord = updateResponse.body.data.updateFileSyncTestObject;
|
||||
|
||||
expect(updatedRecord.filesField).toHaveLength(1);
|
||||
expect(updatedRecord.filesField[0].fileId).toBe(textFile.id);
|
||||
expect(updatedRecord.filesField[0].fileId).toBe(file2.id);
|
||||
|
||||
expect(await checkFileExistsInDB(imageFile.id)).toBe(false);
|
||||
expect(await checkFileExistsInDB(textFile.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true);
|
||||
expect(await checkFileExistsInDB(file1.id)).toBe(false);
|
||||
expect(await checkFileExistsInDB(file2.id)).toBe(true);
|
||||
expect(await checkFileIsInPermanentStorage(file2.id)).toBe(true);
|
||||
|
||||
await makeGraphqlAPIRequest({
|
||||
query: deleteRecordsQuery,
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ export const setupTestObjectsWithAllFieldTypes = async (
|
|||
}
|
||||
|
||||
const testFileContent = 'Test document content';
|
||||
const testFileName = 'Document.pdf';
|
||||
const testMimeType = 'application/pdf';
|
||||
const testFileName = 'Document.txt';
|
||||
const testMimeType = 'text/plain';
|
||||
|
||||
const uploadResponse = await makeMetadataAPIRequestWithFileUpload(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ describe('peopleResolver (e2e)', () => {
|
|||
jobTitle
|
||||
city
|
||||
avatarUrl
|
||||
avatarFile {
|
||||
fileId
|
||||
label
|
||||
extension
|
||||
url
|
||||
}
|
||||
position
|
||||
searchVector
|
||||
id
|
||||
|
|
@ -53,6 +59,7 @@ describe('peopleResolver (e2e)', () => {
|
|||
expect(people).toHaveProperty('jobTitle');
|
||||
expect(people).toHaveProperty('city');
|
||||
expect(people).toHaveProperty('avatarUrl');
|
||||
expect(people).toHaveProperty('avatarFile');
|
||||
expect(people).toHaveProperty('position');
|
||||
expect(people).toHaveProperty('searchVector');
|
||||
expect(people).toHaveProperty('id');
|
||||
|
|
|
|||
|
|
@ -1236,6 +1236,9 @@ export const STANDARD_OBJECTS = {
|
|||
avatarUrl: {
|
||||
universalIdentifier: '20202020-b8a6-40df-961c-373dc5d2ec21',
|
||||
},
|
||||
avatarFile: {
|
||||
universalIdentifier: '20202020-a7c9-4e3d-8f1b-2d5a6b7c8e9f',
|
||||
},
|
||||
position: { universalIdentifier: '20202020-fcd5-4231-aff5-fff583eaa0b1' },
|
||||
createdBy: {
|
||||
universalIdentifier: '20202020-f6ab-4d98-af24-a3d5b664148a',
|
||||
|
|
|
|||
|
|
@ -46057,7 +46057,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mrmime@npm:^2.0.0":
|
||||
"mrmime@npm:^2.0.0, mrmime@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "mrmime@npm:2.0.1"
|
||||
checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761
|
||||
|
|
@ -57161,6 +57161,7 @@ __metadata:
|
|||
lodash.upperfirst: "npm:4.3.1"
|
||||
mailparser: "npm:3.9.1"
|
||||
microdiff: "npm:1.4.0"
|
||||
mrmime: "npm:^2.0.1"
|
||||
ms: "npm:2.1.3"
|
||||
nest-commander: "npm:^3.19.1"
|
||||
node-ical: "npm:^0.20.1"
|
||||
|
|
|
|||
Loading…
Reference in a new issue