chore: remove completed migration feature flags and upgrade commands <= 1.18 (#19074)

## Summary

- Remove 3 completed migration feature flags: `IS_ATTACHMENT_MIGRATED`,
`IS_NOTE_TARGET_MIGRATED`, `IS_TASK_TARGET_MIGRATED` — these were
already enabled by default for all new workspaces via
`DEFAULT_FEATURE_FLAGS`
- Delete all upgrade command directories for versions <= 1.18 (`1-16/`,
`1-17/`, `1-18/`) along with their module registrations, removing ~6,600
lines of dead migration code
- Simplify frontend utility functions
(`getActivityTargetObjectFieldIdName`, `getActivityTargetsFilter`,
`getActivityTargetFieldNameForObject`,
`generateActivityTargetMorphFieldKeys`,
`findActivitiesOperationSignatureFactory`) by removing the
`isMorphRelation` parameter and always using the morph relation path
- Remove feature flag checks from 7 frontend hooks/components that were
gating attachment and activity target behavior behind the removed flags
- Simplify `buildDefaultRelationFlatFieldMetadatasForCustomObject`
server util to always treat attachment, noteTarget, and taskTarget as
morph relations without checking feature flags
This commit is contained in:
Charles Bochet
2026-03-29 09:15:26 +02:00
committed by GitHub
parent f651413297
commit e8cb086b64
50 changed files with 29 additions and 6668 deletions
@@ -1731,9 +1731,6 @@ enum FeatureFlagKey {
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_DASHBOARD_V2_ENABLED
IS_ATTACHMENT_MIGRATED
IS_NOTE_TARGET_MIGRATED
IS_TASK_TARGET_MIGRATED
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_COMMAND_MENU_ITEM_ENABLED
@@ -1427,7 +1427,7 @@ export interface PublicFeatureFlag {
__typename: 'PublicFeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ATTACHMENT_MIGRATED' | 'IS_NOTE_TARGET_MIGRATED' | 'IS_TASK_TARGET_MIGRATED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_GRAPHQL_QUERY_TIMING_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_APPLICATION_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_DASHBOARD_V2_ENABLED' | 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_ENABLED' | 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED' | 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_GRAPHQL_QUERY_TIMING_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export interface ClientConfig {
appVersion?: Scalars['String']
@@ -9147,9 +9147,6 @@ export const enumFeatureFlagKey = {
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
IS_DASHBOARD_V2_ENABLED: 'IS_DASHBOARD_V2_ENABLED' as const,
IS_ATTACHMENT_MIGRATED: 'IS_ATTACHMENT_MIGRATED' as const,
IS_NOTE_TARGET_MIGRATED: 'IS_NOTE_TARGET_MIGRATED' as const,
IS_TASK_TARGET_MIGRATED: 'IS_TASK_TARGET_MIGRATED' as const,
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED: 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_COMMAND_MENU_ITEM_ENABLED: 'IS_COMMAND_MENU_ITEM_ENABLED' as const,
@@ -1703,7 +1703,6 @@ export type FeatureFlag = {
export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED',
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
@@ -1718,14 +1717,12 @@ export enum FeatureFlagKey {
IS_MARKETPLACE_ENABLED = 'IS_MARKETPLACE_ENABLED',
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
IS_RECORD_TABLE_WIDGET_ENABLED = 'IS_RECORD_TABLE_WIDGET_ENABLED',
IS_RICH_TEXT_V1_MIGRATED = 'IS_RICH_TEXT_V1_MIGRATED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_USAGE_ANALYTICS_ENABLED = 'IS_USAGE_ANALYTICS_ENABLED'
}
@@ -3,17 +3,9 @@ import { type ActivityTargetableObject } from '@/activities/types/ActivityTarget
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useAttachments = (targetableObject: ActivityTargetableObject) => {
const isAttachmentMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
);
const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
isMorphRelation: isAttachmentMigrated,
});
const { records: attachments, loading } = useFindManyRecords<Attachment>({
@@ -4,12 +4,10 @@ import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivi
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useApolloClient, useMutation } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
FieldMetadataType,
UploadFilesFieldFileDocument,
} from '~/generated-metadata/graphql';
@@ -19,10 +17,6 @@ export const useUploadAttachmentFile = () => {
const [uploadFilesFieldFile] = useMutation(UploadFilesFieldFileDocument, {
client: apolloClient,
});
const isAttachmentMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
);
const { objectMetadataItem: attachmentMetadata } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
@@ -58,7 +52,6 @@ export const useUploadAttachmentFile = () => {
const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
isMorphRelation: isAttachmentMigrated,
});
const attachmentToCreate = {
@@ -6,7 +6,6 @@ import { type RecordGqlOperationSignatureFactory } from '@/object-record/graphql
type FindActivitiesOperationSignatureFactory = {
objectMetadataItems: EnrichedObjectMetadataItem[];
objectNameSingular: CoreObjectNameSingular;
isMorphRelation: boolean;
};
export const findActivitiesOperationSignatureFactory: RecordGqlOperationSignatureFactory<
@@ -14,7 +13,6 @@ export const findActivitiesOperationSignatureFactory: RecordGqlOperationSignatur
> = ({
objectMetadataItems,
objectNameSingular,
isMorphRelation,
}: FindActivitiesOperationSignatureFactory) => {
const body = {
bodyV2: {
@@ -67,10 +65,7 @@ export const findActivitiesOperationSignatureFactory: RecordGqlOperationSignatur
deletedAt: true,
note: true,
noteId: true,
...generateActivityTargetMorphFieldKeys(
objectMetadataItems,
isMorphRelation,
),
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
}
: {
@@ -82,10 +77,7 @@ export const findActivitiesOperationSignatureFactory: RecordGqlOperationSignatur
deletedAt: true,
task: true,
taskId: true,
...generateActivityTargetMorphFieldKeys(
objectMetadataItems,
isMorphRelation,
),
...generateActivityTargetMorphFieldKeys(objectMetadataItems),
},
}),
},
@@ -1,5 +1,5 @@
import {
CoreObjectNameSingular,
type CoreObjectNameSingular,
type RecordGqlOperationOrderBy,
} from 'twenty-shared/types';
@@ -12,9 +12,6 @@ import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMeta
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useActivityTargetsForTargetableObjects = ({
objectNameSingular,
@@ -37,22 +34,8 @@ export const useActivityTargetsForTargetableObjects = ({
const objectMetadataItems = useAtomStateValue<EnrichedObjectMetadataItem[]>(
objectMetadataItemsSelector,
);
const isNoteTargetMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
);
const isTaskTargetMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
);
const isMorphRelation =
objectNameSingular === CoreObjectNameSingular.Task
? isTaskTargetMigrated
: isNoteTargetMigrated;
const activityTargetsFilter = getActivityTargetsFilter({
targetableObjects: targetableObjects,
activityObjectNameSingular: objectNameSingular,
objectMetadataItems,
isMorphRelation,
targetableObjects,
});
const FIND_ACTIVITY_TARGETS_OPERATION_SIGNATURE =
@@ -7,15 +7,13 @@ import { type TaskTarget } from '@/activities/types/TaskTarget';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type CoreObjectNameSingular } from 'twenty-shared/types';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { sortByAscString } from '~/utils/array/sortByAscString';
export const usePrepareFindManyActivitiesQuery = ({
@@ -35,12 +33,6 @@ export const usePrepareFindManyActivitiesQuery = ({
const cache = useApolloCoreClient().cache;
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const isNoteTargetMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
);
const isTaskTargetMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
);
const { upsertFindManyRecordsQueryInCache: upsertFindManyActivitiesInCache } =
useUpsertFindManyRecordsQueryInCache({
@@ -124,10 +116,6 @@ export const usePrepareFindManyActivitiesQuery = ({
findActivitiesOperationSignatureFactory({
objectNameSingular: activityObjectNameSingular,
objectMetadataItems,
isMorphRelation:
activityObjectNameSingular === CoreObjectNameSingular.Task
? isTaskTargetMigrated
: isNoteTargetMigrated,
});
upsertFindManyActivitiesInCache({
@@ -11,12 +11,10 @@ import { searchRecordStoreFamilyState } from '@/object-record/record-picker/mult
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useCallback } from 'react';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
type UpdateActivityTargetFromCellProps = {
recordPickerInstanceId: string;
@@ -54,17 +52,6 @@ export const useUpdateActivityTargetFromCell = ({
? activityObjectNameSingular
: joinObjectNameSingular,
});
const isNoteTargetMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
);
const isTaskTargetMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
);
const isMorphRelation =
activityObjectNameSingular === CoreObjectNameSingular.Task
? isTaskTargetMigrated
: isNoteTargetMigrated;
const store = useStore();
const setRecordStore = useSetAtomFamilyState(
recordStoreFamilyState,
@@ -96,7 +83,6 @@ export const useUpdateActivityTargetFromCell = ({
activityObjectNameSingular,
targetObjectMetadataId: morphItem.objectMetadataId,
objectMetadataItems,
isMorphRelation,
});
if (!isDefined(targetFieldName)) {
@@ -211,7 +197,6 @@ export const useUpdateActivityTargetFromCell = ({
activityObjectNameSingular,
createOneActivityTarget,
deleteOneActivityTarget,
isMorphRelation,
setRecordStore,
],
);
@@ -3,7 +3,6 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
export const generateActivityTargetMorphFieldKeys = (
objectMetadataItems: EnrichedObjectMetadataItem[],
isMorphRelation: boolean,
) => {
const targetableObjectMetadataItems = objectMetadataItems.filter(
(objectMetadataItem) =>
@@ -14,7 +13,6 @@ export const generateActivityTargetMorphFieldKeys = (
targetableObjectMetadataItems.map((objectMetadataItem) => {
const targetFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: objectMetadataItem.nameSingular,
isMorphRelation,
});
return [targetFieldIdName.replace(/Id$/, ''), true];
@@ -25,7 +23,6 @@ export const generateActivityTargetMorphFieldKeys = (
targetableObjectMetadataItems.map((objectMetadataItem) => {
const targetFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: objectMetadataItem.nameSingular,
isMorphRelation,
});
return [targetFieldIdName, true];
@@ -9,14 +9,12 @@ type GetActivityTargetFieldNameForObjectArgs = {
| CoreObjectNameSingular.Task;
targetObjectMetadataId: string;
objectMetadataItems: EnrichedObjectMetadataItem[];
isMorphRelation?: boolean;
};
export const getActivityTargetFieldNameForObject = ({
activityObjectNameSingular,
targetObjectMetadataId,
objectMetadataItems,
isMorphRelation = false,
}: GetActivityTargetFieldNameForObjectArgs): string | undefined => {
const activityTargetObjectNameSingular =
activityObjectNameSingular === CoreObjectNameSingular.Task
@@ -36,17 +34,13 @@ export const getActivityTargetFieldNameForObject = ({
(objectMetadataItem) => objectMetadataItem.id === targetObjectMetadataId,
);
if (isMorphRelation && isDefined(targetObjectMetadataItem)) {
const fieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetObjectMetadataItem.nameSingular,
isMorphRelation: true,
});
return fieldIdName.replace(/Id$/, '');
if (!isDefined(targetObjectMetadataItem)) {
return undefined;
}
return activityTargetObjectMetadata.fields.find(
(field) =>
field.relation?.targetObjectMetadata.id === targetObjectMetadataId,
)?.name;
const fieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: targetObjectMetadataItem.nameSingular,
});
return fieldIdName.replace(/Id$/, '');
};
@@ -2,14 +2,8 @@ import { capitalize } from 'twenty-shared/utils';
export const getActivityTargetObjectFieldIdName = ({
nameSingular,
isMorphRelation = false,
}: {
nameSingular: string;
isMorphRelation?: boolean;
}) => {
if (isMorphRelation) {
return `target${capitalize(nameSingular)}Id`;
}
return `${nameSingular}Id`;
return `target${capitalize(nameSingular)}Id`;
};
@@ -1,58 +1,21 @@
import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const getActivityTargetsFilter = ({
targetableObjects,
activityObjectNameSingular,
objectMetadataItems,
isMorphRelation = false,
}: {
targetableObjects: ActivityTargetableObject[];
activityObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
objectMetadataItems: EnrichedObjectMetadataItem[];
isMorphRelation?: boolean;
targetableObjects: Pick<
ActivityTargetableObject,
'id' | 'targetObjectNameSingular'
>[];
}) => {
const activityTargetObjectNameSingular =
activityObjectNameSingular === CoreObjectNameSingular.Task
? CoreObjectNameSingular.TaskTarget
: CoreObjectNameSingular.NoteTarget;
const activityTargetObjectMetadata = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === activityTargetObjectNameSingular,
);
const findManyActivityTargetsQueryFilter = Object.fromEntries(
targetableObjects
.map((targetableObject) => {
const targetObjectMetadataItem = objectMetadataItems.find(
(item) =>
item.nameSingular === targetableObject.targetObjectNameSingular,
);
if (!isDefined(targetObjectMetadataItem)) {
return undefined;
}
const joinColumnName = isMorphRelation
? getActivityTargetObjectFieldIdName({
nameSingular: targetObjectMetadataItem.nameSingular,
isMorphRelation: true,
})
: activityTargetObjectMetadata?.fields.find(
(field) =>
field.relation?.targetObjectMetadata.id ===
targetObjectMetadataItem.id,
)?.settings?.joinColumnName;
if (!isDefined(joinColumnName)) {
return undefined;
}
const joinColumnName = getActivityTargetObjectFieldIdName({
nameSingular: targetableObject.targetObjectNameSingular,
});
return [
joinColumnName,
@@ -23,7 +23,6 @@ import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePush
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
@@ -32,7 +31,6 @@ import '@blocknote/react/style.css';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
type RichTextFieldEditorProps = {
recordId: string;
@@ -82,13 +80,8 @@ export const RichTextFieldEditor = ({
const focusId = `${recordId}-${fieldName}`;
const isAttachmentMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
);
const attachmentTargetFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: objectNameSingular,
isMorphRelation: isAttachmentMigrated,
});
const { records: attachments } = useFindManyRecords<Attachment>({
@@ -11,11 +11,9 @@ import { StandaloneRichTextEditorContent } from '@/page-layout/widgets/standalon
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import {
FeatureFlagKey,
PageLayoutType,
type StandaloneRichTextConfiguration,
} from '~/generated-metadata/graphql';
@@ -47,15 +45,11 @@ export const StandaloneRichTextWidget = ({
);
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const isAttachmentMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
);
const isDashboard = layoutType === PageLayoutType.DASHBOARD;
const dashboardId = isDashboard ? targetRecordIdentifier?.id : undefined;
const attachmentTargetFieldIdName = getActivityTargetObjectFieldIdName({
nameSingular: CoreObjectNameSingular.Dashboard,
isMorphRelation: isAttachmentMigrated,
});
const configuration = widget.configuration as
@@ -1,299 +0,0 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
// Maps indexName to its related field universal identifiers for each standard object
export const STANDARD_INDEX_FIELD_UNIVERSAL_IDENTIFIERS: Record<
string,
Record<string, string[]>
> = {
attachment: {
taskIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetTask.universalIdentifier,
],
noteIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetNote.universalIdentifier,
],
personIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetPerson.universalIdentifier,
],
companyIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetCompany.universalIdentifier,
],
opportunityIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetOpportunity.universalIdentifier,
],
dashboardIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetDashboard.universalIdentifier,
],
workflowIdIndex: [
STANDARD_OBJECTS.attachment.fields.targetWorkflow.universalIdentifier,
],
},
blocklist: {
workspaceMemberIdIndex: [
STANDARD_OBJECTS.blocklist.fields.workspaceMember.universalIdentifier,
],
},
calendarChannelEventAssociation: {
calendarChannelIdIndex: [
STANDARD_OBJECTS.calendarChannelEventAssociation.fields.calendarChannel
.universalIdentifier,
],
calendarEventIdIndex: [
STANDARD_OBJECTS.calendarChannelEventAssociation.fields.calendarEvent
.universalIdentifier,
],
},
calendarChannel: {
connectedAccountIdIndex: [
STANDARD_OBJECTS.calendarChannel.fields.connectedAccount
.universalIdentifier,
],
},
calendarEventParticipant: {
calendarEventIdIndex: [
STANDARD_OBJECTS.calendarEventParticipant.fields.calendarEvent
.universalIdentifier,
],
personIdIndex: [
STANDARD_OBJECTS.calendarEventParticipant.fields.person
.universalIdentifier,
],
workspaceMemberIdIndex: [
STANDARD_OBJECTS.calendarEventParticipant.fields.workspaceMember
.universalIdentifier,
],
},
company: {
accountOwnerIdIndex: [
STANDARD_OBJECTS.company.fields.accountOwner.universalIdentifier,
],
domainNameUniqueIndex: [
STANDARD_OBJECTS.company.fields.domainName.universalIdentifier,
],
searchVectorGinIndex: [
STANDARD_OBJECTS.company.fields.searchVector.universalIdentifier,
],
},
connectedAccount: {
accountOwnerIdIndex: [
STANDARD_OBJECTS.connectedAccount.fields.accountOwner.universalIdentifier,
],
},
dashboard: {
searchVectorGinIndex: [
STANDARD_OBJECTS.dashboard.fields.searchVector.universalIdentifier,
],
},
favorite: {
forWorkspaceMemberIdIndex: [
STANDARD_OBJECTS.favorite.fields.forWorkspaceMember.universalIdentifier,
],
personIdIndex: [
STANDARD_OBJECTS.favorite.fields.person.universalIdentifier,
],
companyIdIndex: [
STANDARD_OBJECTS.favorite.fields.company.universalIdentifier,
],
favoriteFolderIdIndex: [
STANDARD_OBJECTS.favorite.fields.favoriteFolder.universalIdentifier,
],
opportunityIdIndex: [
STANDARD_OBJECTS.favorite.fields.opportunity.universalIdentifier,
],
workflowIdIndex: [
STANDARD_OBJECTS.favorite.fields.workflow.universalIdentifier,
],
workflowVersionIdIndex: [
STANDARD_OBJECTS.favorite.fields.workflowVersion.universalIdentifier,
],
workflowRunIdIndex: [
STANDARD_OBJECTS.favorite.fields.workflowRun.universalIdentifier,
],
taskIdIndex: [STANDARD_OBJECTS.favorite.fields.task.universalIdentifier],
noteIdIndex: [STANDARD_OBJECTS.favorite.fields.note.universalIdentifier],
dashboardIdIndex: [
STANDARD_OBJECTS.favorite.fields.dashboard.universalIdentifier,
],
},
messageChannelMessageAssociation: {
messageChannelIdIndex: [
STANDARD_OBJECTS.messageChannelMessageAssociation.fields.messageChannel
.universalIdentifier,
],
messageIdIndex: [
STANDARD_OBJECTS.messageChannelMessageAssociation.fields.message
.universalIdentifier,
],
messageChannelIdMessageIdUniqueIndex: [
STANDARD_OBJECTS.messageChannelMessageAssociation.fields.messageChannel
.universalIdentifier,
STANDARD_OBJECTS.messageChannelMessageAssociation.fields.message
.universalIdentifier,
],
},
messageChannel: {
connectedAccountIdIndex: [
STANDARD_OBJECTS.messageChannel.fields.connectedAccount
.universalIdentifier,
],
},
messageFolder: {
messageChannelIdIndex: [
STANDARD_OBJECTS.messageFolder.fields.messageChannel.universalIdentifier,
],
},
messageParticipant: {
messageIdIndex: [
STANDARD_OBJECTS.messageParticipant.fields.message.universalIdentifier,
],
personIdIndex: [
STANDARD_OBJECTS.messageParticipant.fields.person.universalIdentifier,
],
workspaceMemberIdIndex: [
STANDARD_OBJECTS.messageParticipant.fields.workspaceMember
.universalIdentifier,
],
},
message: {
messageThreadIdIndex: [
STANDARD_OBJECTS.message.fields.messageThread.universalIdentifier,
],
},
note: {
searchVectorGinIndex: [
STANDARD_OBJECTS.note.fields.searchVector.universalIdentifier,
],
},
noteTarget: {
noteIdIndex: [STANDARD_OBJECTS.noteTarget.fields.note.universalIdentifier],
personIdIndex: [
STANDARD_OBJECTS.noteTarget.fields.targetPerson.universalIdentifier,
],
companyIdIndex: [
STANDARD_OBJECTS.noteTarget.fields.targetCompany.universalIdentifier,
],
opportunityIdIndex: [
STANDARD_OBJECTS.noteTarget.fields.targetOpportunity.universalIdentifier,
],
},
opportunity: {
pointOfContactIdIndex: [
STANDARD_OBJECTS.opportunity.fields.pointOfContact.universalIdentifier,
],
companyIdIndex: [
STANDARD_OBJECTS.opportunity.fields.company.universalIdentifier,
],
stageIndex: [STANDARD_OBJECTS.opportunity.fields.stage.universalIdentifier],
searchVectorGinIndex: [
STANDARD_OBJECTS.opportunity.fields.searchVector.universalIdentifier,
],
},
person: {
companyIdIndex: [
STANDARD_OBJECTS.person.fields.company.universalIdentifier,
],
emailsUniqueIndex: [
STANDARD_OBJECTS.person.fields.emails.universalIdentifier,
],
searchVectorGinIndex: [
STANDARD_OBJECTS.person.fields.searchVector.universalIdentifier,
],
},
task: {
assigneeIdIndex: [
STANDARD_OBJECTS.task.fields.assignee.universalIdentifier,
],
searchVectorGinIndex: [
STANDARD_OBJECTS.task.fields.searchVector.universalIdentifier,
],
},
taskTarget: {
taskIdIndex: [STANDARD_OBJECTS.taskTarget.fields.task.universalIdentifier],
personIdIndex: [
STANDARD_OBJECTS.taskTarget.fields.targetPerson.universalIdentifier,
],
companyIdIndex: [
STANDARD_OBJECTS.taskTarget.fields.targetCompany.universalIdentifier,
],
opportunityIdIndex: [
STANDARD_OBJECTS.taskTarget.fields.targetOpportunity.universalIdentifier,
],
},
timelineActivity: {
workspaceMemberIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.workspaceMember
.universalIdentifier,
],
personIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetPerson.universalIdentifier,
],
companyIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetCompany
.universalIdentifier,
],
opportunityIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetOpportunity
.universalIdentifier,
],
noteIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetNote.universalIdentifier,
],
taskIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetTask.universalIdentifier,
],
workflowIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflow
.universalIdentifier,
],
workflowVersionIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflowVersion
.universalIdentifier,
],
workflowRunIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflowRun
.universalIdentifier,
],
dashboardIdIndex: [
STANDARD_OBJECTS.timelineActivity.fields.targetDashboard
.universalIdentifier,
],
},
workflow: {
searchVectorGinIndex: [
STANDARD_OBJECTS.workflow.fields.searchVector.universalIdentifier,
],
},
workflowAutomatedTrigger: {
workflowIdIndex: [
STANDARD_OBJECTS.workflowAutomatedTrigger.fields.workflow
.universalIdentifier,
],
},
workflowRun: {
workflowVersionIdIndex: [
STANDARD_OBJECTS.workflowRun.fields.workflowVersion.universalIdentifier,
],
workflowIdIndex: [
STANDARD_OBJECTS.workflowRun.fields.workflow.universalIdentifier,
],
searchVectorGinIndex: [
STANDARD_OBJECTS.workflowRun.fields.searchVector.universalIdentifier,
],
},
workflowVersion: {
workflowIdIndex: [
STANDARD_OBJECTS.workflowVersion.fields.workflow.universalIdentifier,
],
searchVectorGinIndex: [
STANDARD_OBJECTS.workflowVersion.fields.searchVector.universalIdentifier,
],
},
workspaceMember: {
userEmailUniqueIndex: [
STANDARD_OBJECTS.workspaceMember.fields.userEmail.universalIdentifier,
],
searchVectorGinIndex: [
STANDARD_OBJECTS.workspaceMember.fields.searchVector.universalIdentifier,
],
},
};
@@ -1,212 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/application-package/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { LogicFunctionLayerEntity } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.entity';
import { logicFunctionCreateHash } from 'src/engine/metadata-modules/logic-function/utils/logic-function-create-hash.utils';
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';
@Command({
name: 'upgrade:1-17:backfill-application-package-files',
description:
'Backfill application package files: standard/custom apps get default files, other apps get files from logic function layer',
})
export class BackfillApplicationPackageFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
protected readonly applicationService: ApplicationService,
protected readonly fileStorageService: FileStorageService,
protected readonly workspaceCacheService: WorkspaceCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Running backfill application package files for workspace ${workspaceId}`,
);
const dryRun = options.dryRun ?? false;
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
select: ['id', 'workspaceCustomApplicationId'],
});
if (!isDefined(workspace)) {
this.logger.warn(`Workspace ${workspaceId} not found, skipping`);
return;
}
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatApplicationMaps',
]);
const flatApplications = Object.values(
flatApplicationMaps.byId,
) as FlatApplication[];
for (const application of flatApplications) {
const isStandardOrCustomApplication =
application.id === twentyStandardFlatApplication.id ||
application.id === workspaceCustomFlatApplication.id;
if (isStandardOrCustomApplication) {
const needsBackfill =
!isNonEmptyString(application.packageJsonFileId) ||
!isNonEmptyString(application.yarnLockFileId) ||
!isNonEmptyString(application.packageJsonChecksum) ||
!isNonEmptyString(application.yarnLockChecksum) ||
!isNonEmptyString(application.availablePackages);
if (needsBackfill) {
this.logger.log(
`Backfilling standard/custom application ${application.id} with default package files`,
);
if (!dryRun) {
await this.applicationService.uploadDefaultPackageFilesAndSetFileIds(
application,
);
}
} else {
this.logger.log(
`Skipping standard/custom application ${application.id} - already has package files`,
);
}
continue;
}
const hasLogicFunctionLayer = isDefined(application.logicFunctionLayerId);
const needsBackfill =
!isNonEmptyString(application.packageJsonFileId) ||
!isNonEmptyString(application.yarnLockFileId) ||
!isNonEmptyString(application.packageJsonChecksum) ||
!isNonEmptyString(application.yarnLockChecksum) ||
!isNonEmptyString(application.availablePackages);
if (!hasLogicFunctionLayer) {
this.logger.log(
`Skipping application ${application.id} - no logic function layer`,
);
continue;
}
if (!needsBackfill) {
this.logger.log(
`Skipping application ${application.id} - already has package files`,
);
continue;
}
this.logger.log(
`Backfilling application ${application.id} from logic function layer ${application.logicFunctionLayerId}`,
);
if (!dryRun) {
await this.backfillApplicationFromLogicFunctionLayer(application);
}
}
}
private async backfillApplicationFromLogicFunctionLayer(
application: Pick<
FlatApplication,
'id' | 'universalIdentifier' | 'workspaceId' | 'logicFunctionLayerId'
>,
): Promise<void> {
const layerRepository = this.coreDataSource.getRepository(
LogicFunctionLayerEntity,
);
const layer = await layerRepository.findOne({
where: {
id: application.logicFunctionLayerId as string,
workspaceId: application.workspaceId,
},
select: ['id', 'packageJson', 'yarnLock'],
});
if (!isDefined(layer)) {
this.logger.warn(
`Logic function layer ${application.logicFunctionLayerId} not found for application ${application.id}, skipping`,
);
return;
}
const packageJsonContent = JSON.stringify(layer.packageJson, null, 2);
const packageJsonChecksum = logicFunctionCreateHash(
JSON.stringify(layer.packageJson),
);
const yarnLockChecksum = logicFunctionCreateHash(layer.yarnLock);
const availablePackages = parseAvailablePackagesFromPackageJsonAndYarnLock(
packageJsonContent,
layer.yarnLock,
);
const dependencyFileSettings: FileSettings = {
isTemporaryFile: false,
toDelete: false,
};
const packageJsonFile = await this.fileStorageService.writeFile({
sourceFile: packageJsonContent,
mimeType: undefined,
fileFolder: FileFolder.Dependencies,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId: application.workspaceId,
resourcePath: 'package.json',
settings: dependencyFileSettings,
});
const yarnLockFile = await this.fileStorageService.writeFile({
sourceFile: layer.yarnLock,
mimeType: undefined,
fileFolder: FileFolder.Dependencies,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId: application.workspaceId,
resourcePath: 'yarn.lock',
settings: dependencyFileSettings,
});
await this.applicationService.update(application.id, {
packageJsonFileId: packageJsonFile.id,
yarnLockFileId: yarnLockFile.id,
packageJsonChecksum,
yarnLockChecksum,
availablePackages,
workspaceId: application.workspaceId,
});
}
}
@@ -1,142 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, Repository } from 'typeorm';
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 { updateFileTableQueries } from 'src/database/typeorm/core/migrations/utils/1768572831179-updateFileTable.util';
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 { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:delete-file-records-and-update-table',
description: 'Delete all file records and update file table schema',
})
export class DeleteFileRecordsAndUpdateTableCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
DeleteFileRecordsAndUpdateTableCommand.name,
);
private hasRunOnce = false;
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace(args: RunOnWorkspaceArgs): Promise<void> {
if (this.hasRunOnce) {
return;
}
await this.deleteFileRecords(args);
await this.updateFileTable();
await this.addFileEntityUniqueConstraint(args);
this.hasRunOnce = true;
}
private async updateFileTable(): Promise<void> {
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await updateFileTableQueries(queryRunner);
await queryRunner.commitTransaction();
this.logger.log('Successfully updated file table');
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(`Rolling back updateFileTable: ${error.message}`);
} finally {
await queryRunner.release();
}
}
private async deleteFileRecords({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting deletion of file records for workspace ${workspaceId}`,
);
const files = await this.fileRepository.find({
select: ['id'],
withDeleted: true,
});
if (files.length === 0) {
this.logger.log(
`No file records found for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${files.length} file record(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
try {
await this.fileRepository.delete({ workspaceId });
this.logger.log(
`Successfully deleted ${files.length} file record(s) for workspace ${workspaceId}`,
);
} catch (error) {
this.logger.error(
`Failed to delete file records for workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
private async addFileEntityUniqueConstraint({
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (options.dryRun) {
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await queryRunner.query(
`ALTER TABLE "core"."file" ADD CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE" UNIQUE ("workspaceId", "applicationId", "path")`,
);
await queryRunner.commitTransaction();
this.logger.log('Successfully added file entity unique constraint');
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Rolling back addFileEntityUniqueConstraint: ${error.message}`,
);
} finally {
await queryRunner.release();
}
}
}
@@ -1,337 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { DataSource, QueryFailedError, Repository } from 'typeorm';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
interface MismatchedField {
fieldId: string;
currentFieldName: string;
expectedFieldName: string;
sourceObjectName: string;
targetObjectNameSingular: string;
currentJoinColumnName: string;
expectedJoinColumnName: string;
workspaceSchema: string;
}
@Command({
name: 'upgrade:1-17:fix-morph-relation-field-names',
description:
'Fix morph relation field names that do not match their target object names',
})
export class FixMorphRelationFieldNamesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const dryRun = options?.dryRun ?? false;
this.logger.log(
`${dryRun ? '[DRY RUN] ' : ''}Checking morph relation field names for workspace ${workspaceId}`,
);
const mismatchedFields = await this.findMismatchedFields(workspaceId);
if (mismatchedFields.length === 0) {
this.logger.log(
`✅ No mismatched fields found for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${mismatchedFields.length} mismatched field(s) in workspace ${workspaceId}:`,
);
for (const field of mismatchedFields) {
this.logger.log(
` - ${field.sourceObjectName}.${field.currentFieldName}${field.expectedFieldName} (target: ${field.targetObjectNameSingular})`,
);
}
if (dryRun) {
this.logger.log('[DRY RUN] No changes made');
this.logger.log(
'⚠️ Before running without --dry-run, please ensure you have a database backup',
);
return;
}
this.logger.log(
'⚠️ Starting fix. Ensure you have a database backup before proceeding.',
);
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
let fixedCount = 0;
for (const field of mismatchedFields) {
// Step 1: Attempt to rename the column, handling failures gracefully
if (field.currentJoinColumnName !== field.expectedJoinColumnName) {
// Use a savepoint so a failed rename doesn't abort the whole transaction
const renameColumnSavepoint = 'rename_column';
await queryRunner.query(`SAVEPOINT ${renameColumnSavepoint}`);
try {
await queryRunner.query(
`ALTER TABLE "${field.workspaceSchema}"."${field.sourceObjectName}"
RENAME COLUMN "${field.currentJoinColumnName}" TO "${field.expectedJoinColumnName}"`,
);
await queryRunner.query(
`RELEASE SAVEPOINT ${renameColumnSavepoint}`,
);
this.logger.log(
`Renamed column ${field.currentJoinColumnName}${field.expectedJoinColumnName} in ${field.workspaceSchema}.${field.sourceObjectName}`,
);
} catch (error) {
await queryRunner.query(
`ROLLBACK TO SAVEPOINT ${renameColumnSavepoint}`,
);
await queryRunner.query(
`RELEASE SAVEPOINT ${renameColumnSavepoint}`,
);
const errorCode = this.getPostgresErrorCode(error);
if (errorCode === '42701') {
this.logger.warn(
`⚠️ SKIPPING ${field.sourceObjectName}.${field.currentFieldName}: Both columns exist (${field.currentJoinColumnName} and ${field.expectedJoinColumnName}). Manual intervention required.`,
);
continue;
}
if (errorCode !== '42703') {
throw error;
}
this.logger.log(
`Column rename skipped for ${field.sourceObjectName}.${field.currentJoinColumnName} (column may already be renamed or missing), updating metadata only`,
);
}
}
// Step 2: Verify field still exists with expected current name before updating
const fieldCheck = await queryRunner.query(
`SELECT id, name FROM core."fieldMetadata" WHERE id = $1`,
[field.fieldId],
);
if (fieldCheck.length === 0) {
this.logger.warn(
`⚠️ Field ${field.fieldId} no longer exists, skipping`,
);
continue;
}
if (fieldCheck[0].name !== field.currentFieldName) {
this.logger.warn(
`⚠️ Field ${field.fieldId} name changed from ${field.currentFieldName} to ${fieldCheck[0].name} since query, skipping`,
);
continue;
}
// Step 3: Update field metadata
const newSettings = {
joinColumnName: field.expectedJoinColumnName,
};
const updateResult = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET name = $1,
settings = settings || $2::jsonb
WHERE id = $3
AND name = $4
RETURNING id`,
[
field.expectedFieldName,
JSON.stringify(newSettings),
field.fieldId,
field.currentFieldName,
],
);
if (updateResult.length > 0) {
this.logger.log(
`✓ Updated fieldMetadata: ${field.currentFieldName}${field.expectedFieldName}`,
);
fixedCount++;
} else {
this.logger.log(
`Field metadata already up to date for ${field.currentFieldName}`,
);
}
}
await queryRunner.commitTransaction();
if (fixedCount > 0) {
// Invalidate flat entity caches (new cache system)
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatFieldMetadataMaps',
'ORMEntityMetadatas',
'rolesPermissions',
'userWorkspaceRoleMap',
'flatRoleTargetMaps',
'apiKeyRoleMap',
'flatRoleTargetByAgentIdMaps',
]);
// Increment metadata version so GraphQL schema is regenerated
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
// Flush legacy cache (GraphQL typeDefs, ORM entity schemas, GraphQL operations)
await this.workspaceCacheStorageService.flush(workspaceId);
this.logger.log(`Cache invalidated and metadata version incremented`);
}
this.logger.log(
`✅ Successfully fixed ${fixedCount} field(s) in workspace ${workspaceId}`,
);
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Error fixing morph relation field names (rolled transaction back on ${workspaceId})`,
error,
);
} else {
this.logger.error(
`Error fixing morph relation field names after commit on ${workspaceId}`,
error,
);
}
throw error;
} finally {
await queryRunner.release();
}
}
private async findMismatchedFields(
workspaceId: string,
): Promise<MismatchedField[]> {
// Only process fields on these specific objects that use the target morph pattern
const allowedSourceObjects = [
'noteTarget',
'taskTarget',
'attachment',
'timelineActivity',
];
const result = await this.coreDataSource.query(
`SELECT
fm.id AS "fieldId",
fm.name AS "currentFieldName",
fm.settings->>'joinColumnName' AS "currentJoinColumnName",
source_om."nameSingular" AS "sourceObjectName",
target_om."nameSingular" AS "targetObjectNameSingular",
ds.schema AS "workspaceSchema"
FROM core."fieldMetadata" fm
JOIN core."objectMetadata" source_om ON fm."objectMetadataId" = source_om.id
JOIN core."objectMetadata" target_om ON fm."relationTargetObjectMetadataId" = target_om.id
JOIN core."dataSource" ds ON ds."workspaceId" = fm."workspaceId"
WHERE fm."workspaceId" = $1
AND fm.type = $2
AND fm.name LIKE 'target%'
AND source_om."nameSingular" = ANY($3)`,
[workspaceId, FieldMetadataType.MORPH_RELATION, allowedSourceObjects],
);
// Filter in JavaScript to handle camelCase properly
return result
.filter(
(row: {
currentFieldName: string;
targetObjectNameSingular: string;
}) => {
const expectedFieldName = `target${capitalize(row.targetObjectNameSingular)}`;
return row.currentFieldName !== expectedFieldName;
},
)
.map(
(row: {
fieldId: string;
currentFieldName: string;
currentJoinColumnName: string | null;
sourceObjectName: string;
targetObjectNameSingular: string;
workspaceSchema: string;
}) => {
const expectedFieldName = `target${capitalize(row.targetObjectNameSingular)}`;
const expectedJoinColumnName =
computeMorphOrRelationFieldJoinColumnName({
name: expectedFieldName,
});
// Fallback: if settings.joinColumnName is null, derive from field name
const currentJoinColumnName =
row.currentJoinColumnName ??
computeMorphOrRelationFieldJoinColumnName({
name: row.currentFieldName,
});
return {
fieldId: row.fieldId,
currentFieldName: row.currentFieldName,
expectedFieldName,
sourceObjectName: row.sourceObjectName,
targetObjectNameSingular: row.targetObjectNameSingular,
currentJoinColumnName,
expectedJoinColumnName,
workspaceSchema: row.workspaceSchema,
};
},
);
}
private getPostgresErrorCode(error: unknown): string | null {
if (!(error instanceof QueryFailedError)) {
return null;
}
const driverError: unknown = error.driverError;
if (
typeof driverError === 'object' &&
driverError !== null &&
'code' in driverError
) {
const code = (driverError as { code?: unknown }).code;
return typeof code === 'string' ? code : null;
}
return null;
}
}
@@ -1,117 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, IsNull, 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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
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';
@Command({
name: 'upgrade:1-17:identify-webhook-metadata',
description:
'Identify webhook metadata (backfill universalIdentifier and applicationId)',
})
export class IdentifyWebhookMetadataCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
protected readonly applicationService: ApplicationService,
protected readonly workspaceCacheService: WorkspaceCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Running identify webhook metadata for workspace ${workspaceId}`,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
await this.identifyWebhookEntities({
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
dryRun: options.dryRun ?? false,
});
}
private async identifyWebhookEntities({
workspaceId,
workspaceCustomApplicationId,
dryRun,
}: {
workspaceId: string;
workspaceCustomApplicationId: string;
dryRun: boolean;
}): Promise<void> {
const webhookRepository = this.coreDataSource.getRepository(WebhookEntity);
const webhooksWithoutApplicationId = await webhookRepository.find({
select: ['id', 'universalIdentifier', 'applicationId'],
where: {
workspaceId,
applicationId: IsNull(),
},
withDeleted: true,
});
if (webhooksWithoutApplicationId.length === 0) {
this.logger.log(
`No webhook entities found without applicationId for workspace ${workspaceId}`,
);
return;
}
const updates = webhooksWithoutApplicationId.map((webhook) => ({
id: webhook.id,
universalIdentifier: webhook.universalIdentifier ?? v4(),
applicationId: workspaceCustomApplicationId,
}));
this.logger.log(
`Found ${updates.length} webhook entities to update for workspace ${workspaceId}`,
);
if (!dryRun) {
await webhookRepository.save(updates);
}
const relatedMetadataNames = getMetadataRelatedMetadataNames('webhook');
const relatedCacheKeysToInvalidate = relatedMetadataNames.map(
getMetadataFlatEntityMapsKey,
);
const flatEntityMapsKey = getMetadataFlatEntityMapsKey('webhook');
this.logger.log(
`Invalidating caches: ${flatEntityMapsKey} ${relatedCacheKeysToInvalidate.join(' ')}`,
);
if (!dryRun) {
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
flatEntityMapsKey,
...relatedCacheKeysToInvalidate,
]);
}
}
}
@@ -1,71 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { DataSource, Repository } from 'typeorm';
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 { makeWebhookUniversalIdentifierAndApplicationIdNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1769525557511-makeWebhookUniversalIdentifierAndApplicationIdNotNull.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-17:make-webhook-universal-identifier-and-application-id-not-nullable-migration',
description:
'Make universalIdentifier and applicationId columns NOT NULL on webhook entity',
})
export class MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
private hasRunOnce = false;
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (this.hasRunOnce) {
this.logger.warn(
'Skipping has already been run once MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand',
);
return;
}
if (options.dryRun) {
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await makeWebhookUniversalIdentifierAndApplicationIdNotNullQueries(
queryRunner,
);
await queryRunner.commitTransaction();
this.logger.log(
'Successfully run MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand',
);
this.hasRunOnce = true;
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Rolling back MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: ${error.message}`,
);
} finally {
await queryRunner.release();
}
}
}
@@ -1,334 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FeatureFlagKey,
FieldMetadataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
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 { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
type RelationFieldMetadataSettings =
FieldMetadataSettings<FieldMetadataType.RELATION>;
@Command({
name: 'upgrade:1-17:migrate-attachment-to-morph-relations',
description:
'Migrate attachment relations to morph relation fields and set feature flag',
})
export class MigrateAttachmentToMorphRelationsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
workspaceId,
);
this.logger.log(`Migrating attachments for workspace ${workspaceId}`);
if (isMigrated) {
this.logger.log(`Attachment migration already completed. Skipping...`);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have migrated attachments for workspace ${workspaceId}. Skipping...`,
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const schemaName = getWorkspaceSchemaName(workspaceId);
const tableName = 'attachment';
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatApplicationMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatApplicationMaps',
]);
const attachmentTargetFieldUniversalIdentifiers = new Set<string>([
STANDARD_OBJECTS.attachment.fields.targetTask.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetNote.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetOpportunity
.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetDashboard.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetWorkflow.universalIdentifier,
]);
const attachmentObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: STANDARD_OBJECTS.attachment.universalIdentifier,
});
if (!attachmentObjectMetadata) {
this.logger.error(
`🟥 Attachment object metadata not found for workspace ${workspaceId}`,
);
return;
}
const attachmentFieldMetadatas = getFlatFieldsFromFlatObjectMetadata(
attachmentObjectMetadata,
flatFieldMetadataMaps,
);
const attachmentTargetRelationFields = attachmentFieldMetadatas
.filter(isMorphOrRelationFlatFieldMetadata)
.filter(
(field): field is FlatFieldMetadata<FieldMetadataType.RELATION> =>
field.type === FieldMetadataType.RELATION,
)
.filter((field) => {
const isStandardAppField = this.isTwentyStandardApplicationField({
field,
flatApplicationMaps,
workspaceId,
});
const isStandardTarget =
isStandardAppField &&
attachmentTargetFieldUniversalIdentifiers.has(
field.universalIdentifier,
);
const targetObjectMetadata =
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: field.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
const isCustomTarget =
!isStandardAppField && targetObjectMetadata?.isCustom === true;
return isStandardTarget || isCustomTarget;
});
const fieldMigrations = attachmentTargetRelationFields.map((field) => {
const newFieldName = `target${capitalize(field.name)}`;
const relationSettings: RelationFieldMetadataSettings = field.settings;
const oldJoinColumnName =
relationSettings?.joinColumnName ??
computeMorphOrRelationFieldJoinColumnName({ name: field.name });
const newJoinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: newFieldName,
});
return {
field,
newFieldName,
oldJoinColumnName,
newJoinColumnName,
};
});
for (const { oldJoinColumnName, newJoinColumnName } of fieldMigrations) {
if (oldJoinColumnName === newJoinColumnName) {
this.logger.log(
`Column "${oldJoinColumnName}" already renamed. Skipping...`,
);
continue;
}
try {
await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}"
RENAME COLUMN "${oldJoinColumnName}" TO "${newJoinColumnName}"`,
);
this.logger.log(
`Renamed column "${oldJoinColumnName}" to "${newJoinColumnName}" for "${tableName}"`,
);
} catch (error) {
this.logger.error(
`Error renaming column "${oldJoinColumnName}" to "${newJoinColumnName}" for "${tableName}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated attachment records`);
const morphId =
STANDARD_OBJECTS.attachment.morphIds.targetMorphId.morphId;
for (const {
field: fieldToMigrate,
newFieldName,
newJoinColumnName,
} of fieldMigrations) {
const settings = {
...fieldToMigrate.settings,
joinColumnName: newJoinColumnName,
};
try {
const result = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET name = $1, type = $5, "morphId" = $3, settings = $4
WHERE id = $2`,
[
newFieldName,
fieldToMigrate.id,
morphId,
settings,
FieldMetadataType.MORPH_RELATION,
],
);
const rowsUpdated = result[1] || 0;
if (rowsUpdated > 0) {
this.logger.log(
`Updated fieldMetadata: ${fieldToMigrate.name}${newFieldName} (type: MORPH_RELATION)`,
);
}
} catch (error) {
this.logger.error(
`Error updating fieldMetadata for field "${fieldToMigrate.name}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated attachment fieldmetadata`);
await queryRunner.commitTransaction();
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_ATTACHMENT_MIGRATED],
workspaceId,
);
const relatedMetadataNames =
getMetadataRelatedMetadataNames('fieldMetadata');
const relatedCacheKeysToInvalidate: WorkspaceCacheKeyName[] =
relatedMetadataNames.map(getMetadataFlatEntityMapsKey);
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
'flatFieldMetadataMaps',
...relatedCacheKeysToInvalidate,
'featureFlagsMap',
];
this.logger.log(
`Invalidating caches: ${cacheKeysToInvalidate.join(' ')}`,
);
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
cacheKeysToInvalidate,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(`Cache flushed`);
this.logger.log(
`Set IS_ATTACHMENT_MIGRATED feature flag for workspace ${workspaceId}`,
);
this.logger.log(`Flush cache for workspace ${workspaceId}`);
await this.workspaceCacheStorageService.flush(workspaceId);
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Error migrating attachment to morph relations (rolled transaction back on ${workspaceId})`,
error,
);
} else {
this.logger.error(
`Error migrating attachment to morph relations after commit on ${workspaceId}`,
error,
);
}
throw error;
} finally {
await queryRunner.release();
}
}
private isTwentyStandardApplicationField({
field,
flatApplicationMaps,
workspaceId,
}: {
field: { id: string; name: string; applicationId: string };
flatApplicationMaps: FlatApplicationCacheMaps;
workspaceId: string;
}): boolean {
const application = flatApplicationMaps.byId[field.applicationId];
if (!application) {
this.logger.error(
`🟥 Application not found for field "${field.name}" (${field.id}) in workspace ${workspaceId} (applicationId: ${field.applicationId})`,
);
return false;
}
return (
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier
);
}
}
@@ -1,157 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import {
FieldMetadataType,
ViewFilterOperand,
FeatureFlagKey,
} from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
@Command({
name: 'upgrade:1-17:migrate-date-time-is-filter-values',
description: 'Migrate DATE_TIME IS operand values from Instant to Plain Date',
})
export class MigrateDateTimeIsFilterValuesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateDateTimeIsFilterValuesCommand.name,
{},
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(ViewFilterEntity)
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
this.logger.log(`Processing workspace ${workspaceId}`);
const viewFilters = await this.viewFilterRepository.find({
where: {
workspaceId,
},
relations: ['fieldMetadata'],
withDeleted: true,
});
const filtersToUpdate = viewFilters.filter((filter) => {
if (
!filter.fieldMetadata ||
filter.fieldMetadata.type !== FieldMetadataType.DATE_TIME ||
filter.operand !== ViewFilterOperand.IS
) {
return false;
}
const value = filter.value;
if (typeof value !== 'string') {
return false;
}
return value.includes('T');
});
if (filtersToUpdate.length === 0) {
return;
}
this.logger.log(
`Found ${filtersToUpdate.length} filters to migrate in workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`Dry run: would update ${filtersToUpdate.length} filters`,
);
return;
}
let updatedCount = 0;
for (const filter of filtersToUpdate) {
try {
const newDate = (filter.value as string).split('T')[0];
if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
this.logger.warn(
`Skipping invalid date extraction for filter ${filter.id}: ${filter.value} -> ${newDate}`,
);
continue;
}
await this.viewFilterRepository.update(filter.id, {
value: newDate,
});
updatedCount++;
} catch (err) {
this.logger.error(
`Failed to migrate filter ${filter.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
this.logger.log(
`Migrated ${updatedCount} filters in workspace ${workspaceId}`,
);
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED],
workspaceId,
);
this.logger.log(
`Enabled IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED for workspace ${workspaceId}`,
);
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
'flatViewFilterMaps',
];
this.logger.log(`Invalidating caches: ${cacheKeysToInvalidate.join(' ')}`);
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
cacheKeysToInvalidate,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(`Cache flushed`);
this.logger.log(`Flush cache for workspace ${workspaceId}`);
await this.workspaceCacheStorageService.flush(workspaceId);
}
}
@@ -1,331 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FeatureFlagKey,
FieldMetadataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
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 { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
type RelationFieldMetadataSettings =
FieldMetadataSettings<FieldMetadataType.RELATION>;
@Command({
name: 'upgrade:1-17:migrate-note-target-to-morph-relations',
description:
'Migrate noteTarget relations to morph relation fields and set feature flag',
})
export class MigrateNoteTargetToMorphRelationsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
workspaceId,
);
this.logger.log(`Migrating noteTarget for workspace ${workspaceId}`);
if (isMigrated) {
this.logger.log(`NoteTarget migration already completed. Skipping...`);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have migrated noteTarget for workspace ${workspaceId}. Skipping...`,
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const schemaName = getWorkspaceSchemaName(workspaceId);
const tableName = 'noteTarget';
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatApplicationMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatApplicationMaps',
]);
const noteTargetFieldUniversalIdentifiers = new Set<string>([
STANDARD_OBJECTS.noteTarget.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.noteTarget.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.noteTarget.fields.targetOpportunity
.universalIdentifier,
]);
const noteTargetObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: STANDARD_OBJECTS.noteTarget.universalIdentifier,
});
if (!noteTargetObjectMetadata) {
this.logger.error(
`🟥 ${tableName} object metadata not found for workspace ${workspaceId}`,
);
return;
}
const noteTargetFieldMetadatas = getFlatFieldsFromFlatObjectMetadata(
noteTargetObjectMetadata,
flatFieldMetadataMaps,
);
const noteTargetRelationFields = noteTargetFieldMetadatas
.filter(isMorphOrRelationFlatFieldMetadata)
.filter((field) => field.type === FieldMetadataType.RELATION)
.filter((field) => {
const isStandardAppField = this.isTwentyStandardApplicationField({
field,
flatApplicationMaps,
workspaceId,
});
const isStandardTarget =
isStandardAppField &&
noteTargetFieldUniversalIdentifiers.has(field.universalIdentifier);
const targetObjectMetadata = field.relationTargetObjectMetadataId
? findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: field.relationTargetObjectMetadataId,
})
: undefined;
const isCustomTarget =
!isStandardAppField && targetObjectMetadata?.isCustom === true;
return isStandardTarget || isCustomTarget;
});
const fieldMigrations = noteTargetRelationFields.map((field) => {
const newFieldName = `target${capitalize(field.name)}`;
const newFieldLabel = 'Target';
const relationSettings: RelationFieldMetadataSettings = field.settings;
const oldJoinColumnName =
relationSettings?.joinColumnName ??
computeMorphOrRelationFieldJoinColumnName({ name: field.name });
const newJoinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: newFieldName,
});
return {
field,
newFieldName,
newFieldLabel,
oldJoinColumnName,
newJoinColumnName,
};
});
// Rename columns
for (const { oldJoinColumnName, newJoinColumnName } of fieldMigrations) {
if (oldJoinColumnName === newJoinColumnName) {
this.logger.log(
`Column "${oldJoinColumnName}" already renamed. Skipping...`,
);
continue;
}
try {
await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}"
RENAME COLUMN "${oldJoinColumnName}" TO "${newJoinColumnName}"`,
);
this.logger.log(
`Renamed column "${oldJoinColumnName}" to "${newJoinColumnName}" for "${tableName}"`,
);
} catch (error) {
this.logger.error(
`Error renaming column "${oldJoinColumnName}" to "${newJoinColumnName}" for "${tableName}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated ${tableName} records`);
const morphId =
STANDARD_OBJECTS.noteTarget.morphIds.targetMorphId.morphId;
// Update field metadata
for (const {
field: fieldToMigrate,
newFieldName,
newFieldLabel,
newJoinColumnName,
} of fieldMigrations) {
const settings = {
...fieldToMigrate.settings,
joinColumnName: newJoinColumnName,
};
try {
const result = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET name = $1, type = $5, "morphId" = $3, settings = $4, "isActive" = true, label = $6
WHERE id = $2`,
[
newFieldName,
fieldToMigrate.id,
morphId,
settings,
FieldMetadataType.MORPH_RELATION,
newFieldLabel,
],
);
const rowsUpdated = result[1] || 0;
if (rowsUpdated > 0) {
this.logger.log(
`Updated fieldMetadata: ${fieldToMigrate.name}${newFieldName} (label: ${newFieldLabel}, type: MORPH_RELATION)`,
);
}
} catch (error) {
this.logger.error(
`Error updating fieldMetadata for field "${fieldToMigrate.name}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated ${tableName} fieldmetadata`);
await queryRunner.commitTransaction();
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_NOTE_TARGET_MIGRATED],
workspaceId,
);
const relatedMetadataNames =
getMetadataRelatedMetadataNames('fieldMetadata');
const relatedCacheKeysToInvalidate: WorkspaceCacheKeyName[] =
relatedMetadataNames.map(getMetadataFlatEntityMapsKey);
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
'flatFieldMetadataMaps',
...relatedCacheKeysToInvalidate,
'featureFlagsMap',
];
this.logger.log(
`Invalidating caches: ${cacheKeysToInvalidate.join(' ')}`,
);
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
cacheKeysToInvalidate,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(`Cache flushed`);
this.logger.log(
`Set IS_NOTE_TARGET_MIGRATED feature flag for workspace ${workspaceId}`,
);
this.logger.log(`Flush cache for workspace ${workspaceId}`);
await this.workspaceCacheStorageService.flush(workspaceId);
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Error migrating noteTarget to morph relations (rolled transaction back on ${workspaceId})`,
error,
);
} else {
this.logger.error(
`Error migrating noteTarget to morph relations after commit on ${workspaceId}`,
error,
);
}
throw error;
} finally {
await queryRunner.release();
}
}
private isTwentyStandardApplicationField({
field,
flatApplicationMaps,
workspaceId,
}: {
field: { id: string; name: string; applicationId: string };
flatApplicationMaps: FlatApplicationCacheMaps;
workspaceId: string;
}): boolean {
const application = flatApplicationMaps.byId[field.applicationId];
if (!application) {
this.logger.error(
`🟥 Application not found for field "${field.name}" (${field.id}) in workspace ${workspaceId} (applicationId: ${field.applicationId})`,
);
return false;
}
return (
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier
);
}
}
@@ -1,85 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
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 { migrateWorkflowSteps } from 'src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-17:migrate-send-email-recipients',
description:
'Migrate send email action from legacy email field to recipients object',
})
export class MigrateSendEmailRecipientsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateSendEmailRecipientsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`Running MigrateSendEmailRecipientsCommand for workspace ${workspaceId}`,
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersions = await workflowVersionRepository.find({
select: ['id', 'steps'],
});
let migratedCount = 0;
for (const version of workflowVersions) {
const { migratedSteps, hasChanges } = migrateWorkflowSteps(version.steps);
if (!hasChanges) {
continue;
}
migratedCount++;
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would migrate workflow version ${version.id} in workspace ${workspaceId}`,
);
} else {
await workflowVersionRepository.update(
{ id: version.id },
{ steps: migratedSteps },
);
this.logger.log(
`Migrated workflow version ${version.id} in workspace ${workspaceId}`,
);
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would have migrated' : 'Migrated'} ${migratedCount} workflow version(s) in workspace ${workspaceId}`,
);
}
}
@@ -1,331 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import {
FeatureFlagKey,
FieldMetadataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
import { DataSource, Repository } from 'typeorm';
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 { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
type RelationFieldMetadataSettings =
FieldMetadataSettings<FieldMetadataType.RELATION>;
@Command({
name: 'upgrade:1-17:migrate-task-target-to-morph-relations',
description:
'Migrate taskTarget relations to morph relation fields and set feature flag',
})
export class MigrateTaskTargetToMorphRelationsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
private readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
workspaceId,
);
this.logger.log(`Migrating taskTarget for workspace ${workspaceId}`);
if (isMigrated) {
this.logger.log(`TaskTarget migration already completed. Skipping...`);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have migrated taskTarget for workspace ${workspaceId}. Skipping...`,
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const schemaName = getWorkspaceSchemaName(workspaceId);
const tableName = 'taskTarget';
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatApplicationMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatApplicationMaps',
]);
const taskTargetFieldUniversalIdentifiers = new Set<string>([
STANDARD_OBJECTS.taskTarget.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.taskTarget.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.taskTarget.fields.targetOpportunity
.universalIdentifier,
]);
const taskTargetObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: STANDARD_OBJECTS.taskTarget.universalIdentifier,
});
if (!taskTargetObjectMetadata) {
this.logger.error(
`🟥 ${tableName} object metadata not found for workspace ${workspaceId}`,
);
return;
}
const taskTargetFieldMetadatas = getFlatFieldsFromFlatObjectMetadata(
taskTargetObjectMetadata,
flatFieldMetadataMaps,
);
const taskTargetRelationFields = taskTargetFieldMetadatas
.filter(isMorphOrRelationFlatFieldMetadata)
.filter((field) => field.type === FieldMetadataType.RELATION)
.filter((field) => {
const isStandardAppField = this.isTwentyStandardApplicationField({
field,
flatApplicationMaps,
workspaceId,
});
const isStandardTarget =
isStandardAppField &&
taskTargetFieldUniversalIdentifiers.has(field.universalIdentifier);
const targetObjectMetadata = field.relationTargetObjectMetadataId
? findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: field.relationTargetObjectMetadataId,
})
: undefined;
const isCustomTarget =
!isStandardAppField && targetObjectMetadata?.isCustom === true;
return isStandardTarget || isCustomTarget;
});
const fieldMigrations = taskTargetRelationFields.map((field) => {
const newFieldName = `target${capitalize(field.name)}`;
const newFieldLabel = 'Target';
const relationSettings: RelationFieldMetadataSettings = field.settings;
const oldJoinColumnName =
relationSettings?.joinColumnName ??
computeMorphOrRelationFieldJoinColumnName({ name: field.name });
const newJoinColumnName = computeMorphOrRelationFieldJoinColumnName({
name: newFieldName,
});
return {
field,
newFieldName,
newFieldLabel,
oldJoinColumnName,
newJoinColumnName,
};
});
// Rename columns
for (const { oldJoinColumnName, newJoinColumnName } of fieldMigrations) {
if (oldJoinColumnName === newJoinColumnName) {
this.logger.log(
`Column "${oldJoinColumnName}" already renamed. Skipping...`,
);
continue;
}
try {
await queryRunner.query(
`ALTER TABLE "${schemaName}"."${tableName}"
RENAME COLUMN "${oldJoinColumnName}" TO "${newJoinColumnName}"`,
);
this.logger.log(
`Renamed column "${oldJoinColumnName}" to "${newJoinColumnName}" for "${tableName}"`,
);
} catch (error) {
this.logger.error(
`Error renaming column "${oldJoinColumnName}" to "${newJoinColumnName}" for "${tableName}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated ${tableName} records`);
const morphId =
STANDARD_OBJECTS.taskTarget.morphIds.targetMorphId.morphId;
// Update field metadata
for (const {
field: fieldToMigrate,
newFieldName,
newFieldLabel,
newJoinColumnName,
} of fieldMigrations) {
const settings = {
...fieldToMigrate.settings,
joinColumnName: newJoinColumnName,
};
try {
const result = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET name = $1, type = $5, "morphId" = $3, settings = $4, "isActive" = true, label = $6
WHERE id = $2`,
[
newFieldName,
fieldToMigrate.id,
morphId,
settings,
FieldMetadataType.MORPH_RELATION,
newFieldLabel,
],
);
const rowsUpdated = result[1] || 0;
if (rowsUpdated > 0) {
this.logger.log(
`Updated fieldMetadata: ${fieldToMigrate.name}${newFieldName} (label: ${newFieldLabel}, type: MORPH_RELATION)`,
);
}
} catch (error) {
this.logger.error(
`Error updating fieldMetadata for field "${fieldToMigrate.name}" in workspace ${workspaceId}`,
error,
);
throw error;
}
}
this.logger.log(`✅ Successfully migrated ${tableName} fieldmetadata`);
await queryRunner.commitTransaction();
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_TASK_TARGET_MIGRATED],
workspaceId,
);
const relatedMetadataNames =
getMetadataRelatedMetadataNames('fieldMetadata');
const relatedCacheKeysToInvalidate: WorkspaceCacheKeyName[] =
relatedMetadataNames.map(getMetadataFlatEntityMapsKey);
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
'flatFieldMetadataMaps',
...relatedCacheKeysToInvalidate,
'featureFlagsMap',
];
this.logger.log(
`Invalidating caches: ${cacheKeysToInvalidate.join(' ')}`,
);
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
cacheKeysToInvalidate,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(`Cache flushed`);
this.logger.log(
`Set IS_TASK_TARGET_MIGRATED feature flag for workspace ${workspaceId}`,
);
this.logger.log(`Flush cache for workspace ${workspaceId}`);
await this.workspaceCacheStorageService.flush(workspaceId);
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Error migrating taskTarget to morph relations (rolled transaction back on ${workspaceId})`,
error,
);
} else {
this.logger.error(
`Error migrating taskTarget to morph relations after commit on ${workspaceId}`,
error,
);
}
throw error;
} finally {
await queryRunner.release();
}
}
private isTwentyStandardApplicationField({
field,
flatApplicationMaps,
workspaceId,
}: {
field: { id: string; name: string; applicationId: string };
flatApplicationMaps: FlatApplicationCacheMaps;
workspaceId: string;
}): boolean {
const application = flatApplicationMaps.byId[field.applicationId];
if (!application) {
this.logger.error(
`🟥 Application not found for field "${field.name}" (${field.id}) in workspace ${workspaceId} (applicationId: ${field.applicationId})`,
);
return false;
}
return (
application.universalIdentifier ===
TWENTY_STANDARD_APPLICATION.universalIdentifier
);
}
}
@@ -1,93 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillApplicationPackageFilesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-backfill-application-package-files.command';
import { DeleteFileRecordsAndUpdateTableCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-all-files-and-update-table.command';
import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command';
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
import { MigrateDateTimeIsFilterValuesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-date-time-is-filter-values.command';
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
import { MigrateSendEmailRecipientsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command';
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.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 { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity';
import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity';
@Module({
imports: [
TypeOrmModule.forFeature([
WorkspaceEntity,
ObjectMetadataEntity,
FieldMetadataEntity,
FeatureFlagEntity,
AttachmentWorkspaceEntity,
WebhookEntity,
NoteTargetWorkspaceEntity,
TaskTargetWorkspaceEntity,
FileEntity,
ViewFilterEntity,
LogicFunctionEntity,
]),
DataSourceModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
FeatureFlagModule,
FileStorageModule.forRoot(),
WorkspaceCacheModule,
FieldMetadataModule,
ObjectMetadataModule,
ApplicationModule,
UserWorkspaceModule,
WorkspaceMigrationModule,
RecordPositionModule,
GlobalWorkspaceDataSourceModule,
],
providers: [
FixMorphRelationFieldNamesCommand,
MigrateAttachmentToMorphRelationsCommand,
MigrateNoteTargetToMorphRelationsCommand,
MigrateTaskTargetToMorphRelationsCommand,
IdentifyWebhookMetadataCommand,
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
DeleteFileRecordsAndUpdateTableCommand,
MigrateSendEmailRecipientsCommand,
MigrateDateTimeIsFilterValuesCommand,
BackfillApplicationPackageFilesCommand,
],
exports: [
FixMorphRelationFieldNamesCommand,
MigrateAttachmentToMorphRelationsCommand,
MigrateNoteTargetToMorphRelationsCommand,
MigrateTaskTargetToMorphRelationsCommand,
IdentifyWebhookMetadataCommand,
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
MigrateSendEmailRecipientsCommand,
MigrateDateTimeIsFilterValuesCommand,
DeleteFileRecordsAndUpdateTableCommand,
BackfillApplicationPackageFilesCommand,
],
})
export class V1_17_UpgradeVersionCommandModule {}
@@ -1,98 +0,0 @@
import {
migrateInput,
migrateWorkflowSteps,
needsMigration,
} from 'src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util';
const LEGACY_STEP_FROM_PRODUCTION = {
id: '3b8934cd-1dda-4acb-a050-785e04f7f40b',
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
position: { x: 0, y: 150 },
settings: {
input: {
body: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"sample"}]}]}',
email: 'sample@gmail.com',
files: [],
subject: 'sample',
connectedAccountId: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
describe('needsMigration', () => {
it('returns true for legacy email field', () => {
expect(
needsMigration({ connectedAccountId: '', email: 'test@example.com' }),
).toBe(true);
});
it('returns false when recipients is already used', () => {
expect(
needsMigration({
connectedAccountId: '',
recipients: { to: 'test@example.com' },
}),
).toBe(false);
});
});
describe('migrateInput', () => {
it('converts legacy email to recipients.to', () => {
const result = migrateInput({
connectedAccountId: 'acc-123',
email: 'legacy@example.com',
subject: 'Test',
body: 'Body',
});
expect(result.recipients.to).toBe('legacy@example.com');
expect(result).not.toHaveProperty('email');
});
});
describe('migrateWorkflowSteps', () => {
it('migrates real production workflow with legacy email field', () => {
const { migratedSteps, hasChanges } = migrateWorkflowSteps([
LEGACY_STEP_FROM_PRODUCTION,
]);
expect(hasChanges).toBe(true);
expect(migratedSteps[0].settings.input).toEqual({
body: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"sample"}]}]}',
files: [],
subject: 'sample',
connectedAccountId: '',
recipients: {
to: 'sample@gmail.com',
cc: '',
bcc: '',
},
});
});
it('returns hasChanges false when no migration needed', () => {
const alreadyMigratedStep = {
...LEGACY_STEP_FROM_PRODUCTION,
settings: {
...LEGACY_STEP_FROM_PRODUCTION.settings,
input: {
connectedAccountId: '',
recipients: { to: 'new@example.com', cc: '', bcc: '' },
subject: 'Test',
body: 'Body',
},
},
};
const { hasChanges } = migrateWorkflowSteps([alreadyMigratedStep]);
expect(hasChanges).toBe(false);
});
});
@@ -1,95 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
type LegacySendEmailInput = {
connectedAccountId: string;
email?: string;
recipients?: {
to?: string;
cc?: string;
bcc?: string;
};
subject?: string;
body?: string;
files?: unknown[];
};
type MigratedSendEmailInput = {
connectedAccountId: string;
recipients: {
to: string;
cc: string;
bcc: string;
};
subject?: string;
body?: string;
files?: unknown[];
};
type WorkflowStep = {
id: string;
type: string;
settings: {
input: LegacySendEmailInput | MigratedSendEmailInput;
};
};
export const needsMigration = (input: LegacySendEmailInput): boolean => {
return isDefined(input.email);
};
export const migrateInput = (
input: LegacySendEmailInput,
): MigratedSendEmailInput => {
const { email, recipients, ...rest } = input;
const toValue = recipients?.to || email || '';
const ccValue = recipients?.cc ?? '';
const bccValue = recipients?.bcc ?? '';
return {
...rest,
recipients: {
to: toValue,
cc: ccValue,
bcc: bccValue,
},
};
};
export const migrateWorkflowSteps = (
steps: unknown,
): { migratedSteps: WorkflowStep[]; hasChanges: boolean } => {
if (!isDefined(steps) || !Array.isArray(steps) || steps.length === 0) {
return { migratedSteps: [], hasChanges: false };
}
const typedSteps = steps as WorkflowStep[];
let hasChanges = false;
const migratedSteps = typedSteps.map((step) => {
if (step.type !== WorkflowActionType.SEND_EMAIL) {
return step;
}
const input = step.settings.input as LegacySendEmailInput;
if (!needsMigration(input)) {
return step;
}
hasChanges = true;
return {
...step,
settings: {
...step.settings,
input: migrateInput(input),
},
};
});
return { migratedSteps, hasChanges };
};
@@ -1,184 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { FileFolder } from 'twenty-shared/types';
import { DataSource, Equal, LessThan, Repository } from 'typeorm';
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 { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
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 { streamToBuffer } from 'src/utils/stream-to-buffer';
@Command({
name: 'upgrade:1-18:backfill-file-size-and-mime-type',
description:
'Backfill file size and mime type for files with missing or default values',
})
export class BackfillFileSizeAndMimeTypeCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting file size and mime type backfill for workspace ${workspaceId}`,
);
const { flatApplicationMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatApplicationMaps',
]);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
const filesToBackfill = await fileRepository.find({
where: [
{
workspaceId,
size: LessThan(0),
},
{
workspaceId,
mimeType: Equal('application/octet-stream'),
},
],
select: [
'id',
'path',
'applicationId',
'workspaceId',
'size',
'mimeType',
],
});
if (filesToBackfill.length === 0) {
this.logger.log(`No files to backfill for workspace ${workspaceId}`);
return;
}
this.logger.log(
`Found ${filesToBackfill.length} file(s) to backfill in workspace ${workspaceId}`,
);
for (const fileEntity of filesToBackfill) {
try {
this.logger.log(
`Processing file ${fileEntity.id} (path: ${fileEntity.path})`,
);
const application = flatApplicationMaps.byId[fileEntity.applicationId];
if (!application) {
this.logger.error(
`Application not found for file ${fileEntity.id} (applicationId: ${fileEntity.applicationId})`,
);
continue;
}
const [fileFolder] = fileEntity.path.split('/') as [FileFolder];
if (!Object.values(FileFolder).includes(fileFolder)) {
this.logger.error(
`Invalid file folder '${fileFolder}' for file ${fileEntity.id} (path: ${fileEntity.path})`,
);
continue;
}
const fileExists = await this.fileStorageService.checkFileExists({
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
resourcePath: removeFileFolderFromFileEntityPath(fileEntity.path),
});
if (!fileExists) {
this.logger.error(
`File ${fileEntity.id} (path: ${fileEntity.path}) not found in storage`,
);
continue;
}
const fileStream = await this.fileStorageService.readFile({
fileFolder,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
resourcePath: removeFileFolderFromFileEntityPath(fileEntity.path),
});
const fileBuffer = await streamToBuffer(fileStream);
const updateData: {
size?: number;
mimeType?: string;
} = {};
if (fileEntity.size < 0) {
updateData.size = fileBuffer.length;
}
if (fileEntity.mimeType === 'application/octet-stream') {
try {
const { mimeType } = await extractFileInfo({
file: fileBuffer,
filename: fileEntity.path,
});
updateData.mimeType = mimeType;
} catch (error) {
this.logger.error(
`Failed to extract file mime type for file ${fileEntity.id} in workspace ${workspaceId}: ${error.message}`,
);
continue;
}
}
if (!isDryRun) {
await fileRepository.update({ id: fileEntity.id }, updateData);
}
const updateDetails = Object.entries(updateData)
.map(([key, value]) => `${key}=${value}`)
.join(', ');
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Backfilled file ${fileEntity.id}: ${updateDetails}`,
);
} catch (error) {
this.logger.error(
`Failed to backfill file ${fileEntity.id} in workspace ${workspaceId}: ${error.message}`,
);
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed file size and mime type backfill for workspace ${workspaceId}`,
);
}
}
@@ -1,120 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
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';
@Command({
name: 'upgrade:1-18:backfill-message-channel-throttle-retry-after',
description:
'Backfill throttleRetryAfter field on messageChannel standard object',
})
export class BackfillMessageChannelThrottleRetryAfterCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly applicationService: ApplicationService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Backfilling throttleRetryAfter field for workspace ${workspaceId}`,
);
const { flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
const messageChannelObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.messageChannel.universalIdentifier,
});
if (!messageChannelObjectMetadata) {
this.logger.log(
`MessageChannel object metadata not found for workspace ${workspaceId}. Skipping.`,
);
return;
}
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const existingField = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.messageChannel.fields.throttleRetryAfter
.universalIdentifier,
});
if (existingField) {
this.logger.log(
`throttleRetryAfter field already exists for workspace ${workspaceId}. Skipping.`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`Would have created throttleRetryAfter field for workspace ${workspaceId}. Skipping (dry run).`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
await this.fieldMetadataService.createOneField({
createFieldInput: {
name: 'throttleRetryAfter',
type: FieldMetadataType.DATE_TIME,
label: 'Throttle Retry After',
description: 'Throttle Retry After',
icon: 'IconClock',
isNullable: true,
isUIReadOnly: true,
objectMetadataId: messageChannelObjectMetadata.id,
universalIdentifier:
STANDARD_OBJECTS.messageChannel.fields.throttleRetryAfter
.universalIdentifier,
},
workspaceId,
ownerFlatApplication: twentyStandardFlatApplication,
});
this.logger.log(
`Successfully created throttleRetryAfter field for workspace ${workspaceId}`,
);
}
}
@@ -1,753 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { DataSource, Repository } from 'typeorm';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
// Fields that should be marked as isSystem: true
const FIELDS_TO_MARK_AS_SYSTEM = [
// attachment: deprecated fields
STANDARD_OBJECTS.attachment.fields.fullPath.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.fileCategory.universalIdentifier,
// timelineActivity: linked record fields
STANDARD_OBJECTS.timelineActivity.fields.linkedRecordCachedName
.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.linkedRecordId.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.linkedObjectMetadataId
.universalIdentifier,
// workspaceMember: relational fields
STANDARD_OBJECTS.workspaceMember.fields.favorites.universalIdentifier,
STANDARD_OBJECTS.workspaceMember.fields.accountOwnerForCompanies
.universalIdentifier,
STANDARD_OBJECTS.workspaceMember.fields.connectedAccounts.universalIdentifier,
STANDARD_OBJECTS.workspaceMember.fields.messageParticipants
.universalIdentifier,
STANDARD_OBJECTS.workspaceMember.fields.blocklist.universalIdentifier,
STANDARD_OBJECTS.workspaceMember.fields.calendarEventParticipants
.universalIdentifier,
];
// Morph target fields that should have label "Target" and icon "IconArrowUpRight"
const MORPH_FIELDS_TO_RELABEL = [
// attachment
STANDARD_OBJECTS.attachment.fields.targetTask.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetNote.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetOpportunity.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetDashboard.universalIdentifier,
STANDARD_OBJECTS.attachment.fields.targetWorkflow.universalIdentifier,
// noteTarget
STANDARD_OBJECTS.noteTarget.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.noteTarget.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.noteTarget.fields.targetOpportunity.universalIdentifier,
// taskTarget
STANDARD_OBJECTS.taskTarget.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.taskTarget.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.taskTarget.fields.targetOpportunity.universalIdentifier,
// timelineActivity
STANDARD_OBJECTS.timelineActivity.fields.targetPerson.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetCompany.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetOpportunity
.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetTask.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetNote.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflow.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflowVersion
.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflowRun
.universalIdentifier,
STANDARD_OBJECTS.timelineActivity.fields.targetDashboard.universalIdentifier,
];
interface ViewFieldDef {
fieldUI: string;
viewFieldUI: string;
position: number;
}
interface ViewToBackfill {
objectUI: string;
viewName: string;
viewIcon: string;
viewUI: string;
viewFields: ViewFieldDef[];
}
const VIEWS_TO_BACKFILL: ViewToBackfill[] = [
{
objectUI: STANDARD_OBJECTS.attachment.universalIdentifier,
viewName: 'All Attachments',
viewIcon: 'IconList',
viewUI:
STANDARD_OBJECTS.attachment.views.allAttachments.universalIdentifier,
viewFields: [
{
fieldUI: STANDARD_OBJECTS.attachment.fields.name.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields.name
.universalIdentifier,
position: 0,
},
{
fieldUI: STANDARD_OBJECTS.attachment.fields.file.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields.file
.universalIdentifier,
position: 1,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetPerson.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields
.targetPerson.universalIdentifier,
position: 2,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetCompany.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields
.targetCompany.universalIdentifier,
position: 3,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetOpportunity
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields
.targetOpportunity.universalIdentifier,
position: 4,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetTask.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields.targetTask
.universalIdentifier,
position: 5,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetNote.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields.targetNote
.universalIdentifier,
position: 6,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetDashboard
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields
.targetDashboard.universalIdentifier,
position: 7,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.targetWorkflow.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields
.targetWorkflow.universalIdentifier,
position: 8,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.createdBy.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields.createdBy
.universalIdentifier,
position: 9,
},
{
fieldUI:
STANDARD_OBJECTS.attachment.fields.createdAt.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.attachment.views.allAttachments.viewFields.createdAt
.universalIdentifier,
position: 10,
},
],
},
{
objectUI: STANDARD_OBJECTS.noteTarget.universalIdentifier,
viewName: 'All Note Targets',
viewIcon: 'IconList',
viewUI:
STANDARD_OBJECTS.noteTarget.views.allNoteTargets.universalIdentifier,
viewFields: [
{
fieldUI: STANDARD_OBJECTS.noteTarget.fields.id.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.noteTarget.views.allNoteTargets.viewFields.id
.universalIdentifier,
position: 0,
},
{
fieldUI: STANDARD_OBJECTS.noteTarget.fields.note.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.noteTarget.views.allNoteTargets.viewFields.note
.universalIdentifier,
position: 1,
},
{
fieldUI:
STANDARD_OBJECTS.noteTarget.fields.targetPerson.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.noteTarget.views.allNoteTargets.viewFields
.targetPerson.universalIdentifier,
position: 2,
},
{
fieldUI:
STANDARD_OBJECTS.noteTarget.fields.targetCompany.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.noteTarget.views.allNoteTargets.viewFields
.targetCompany.universalIdentifier,
position: 3,
},
{
fieldUI:
STANDARD_OBJECTS.noteTarget.fields.targetOpportunity
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.noteTarget.views.allNoteTargets.viewFields
.targetOpportunity.universalIdentifier,
position: 4,
},
],
},
{
objectUI: STANDARD_OBJECTS.taskTarget.universalIdentifier,
viewName: 'All Task Targets',
viewIcon: 'IconList',
viewUI:
STANDARD_OBJECTS.taskTarget.views.allTaskTargets.universalIdentifier,
viewFields: [
{
fieldUI: STANDARD_OBJECTS.taskTarget.fields.id.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.taskTarget.views.allTaskTargets.viewFields.id
.universalIdentifier,
position: 0,
},
{
fieldUI: STANDARD_OBJECTS.taskTarget.fields.task.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.taskTarget.views.allTaskTargets.viewFields.task
.universalIdentifier,
position: 1,
},
{
fieldUI:
STANDARD_OBJECTS.taskTarget.fields.targetPerson.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.taskTarget.views.allTaskTargets.viewFields
.targetPerson.universalIdentifier,
position: 2,
},
{
fieldUI:
STANDARD_OBJECTS.taskTarget.fields.targetCompany.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.taskTarget.views.allTaskTargets.viewFields
.targetCompany.universalIdentifier,
position: 3,
},
{
fieldUI:
STANDARD_OBJECTS.taskTarget.fields.targetOpportunity
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.taskTarget.views.allTaskTargets.viewFields
.targetOpportunity.universalIdentifier,
position: 4,
},
],
},
{
objectUI: STANDARD_OBJECTS.timelineActivity.universalIdentifier,
viewName: 'All Timeline Activities',
viewIcon: 'IconList',
viewUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.universalIdentifier,
viewFields: [
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.name.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.name.universalIdentifier,
position: 0,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.happensAt
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.happensAt.universalIdentifier,
position: 1,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetPerson
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetPerson.universalIdentifier,
position: 2,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetCompany
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetCompany.universalIdentifier,
position: 3,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetOpportunity
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetOpportunity.universalIdentifier,
position: 4,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetTask
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetTask.universalIdentifier,
position: 5,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetNote
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetNote.universalIdentifier,
position: 6,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflow
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetWorkflow.universalIdentifier,
position: 7,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflowVersion
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetWorkflowVersion.universalIdentifier,
position: 8,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetWorkflowRun
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetWorkflowRun.universalIdentifier,
position: 9,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.targetDashboard
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.targetDashboard.universalIdentifier,
position: 10,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.workspaceMember
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.workspaceMember.universalIdentifier,
position: 11,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.properties
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.properties.universalIdentifier,
position: 12,
},
{
fieldUI:
STANDARD_OBJECTS.timelineActivity.fields.linkedRecordCachedName
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.timelineActivity.views.allTimelineActivities
.viewFields.linkedRecordCachedName.universalIdentifier,
position: 13,
},
],
},
{
objectUI: STANDARD_OBJECTS.workspaceMember.universalIdentifier,
viewName: 'All Workspace Members',
viewIcon: 'IconList',
viewUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers
.universalIdentifier,
viewFields: [
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.name.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.name.universalIdentifier,
position: 0,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.userEmail.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.userEmail.universalIdentifier,
position: 1,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.avatarUrl.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.avatarUrl.universalIdentifier,
position: 2,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.colorScheme
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.colorScheme.universalIdentifier,
position: 3,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.locale.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.locale.universalIdentifier,
position: 4,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.timeZone.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.timeZone.universalIdentifier,
position: 5,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.dateFormat
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.dateFormat.universalIdentifier,
position: 6,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.timeFormat
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.timeFormat.universalIdentifier,
position: 7,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.createdAt.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.createdAt.universalIdentifier,
position: 8,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.ownedOpportunities
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.ownedOpportunities.universalIdentifier,
position: 9,
},
{
fieldUI:
STANDARD_OBJECTS.workspaceMember.fields.assignedTasks
.universalIdentifier,
viewFieldUI:
STANDARD_OBJECTS.workspaceMember.views.allWorkspaceMembers.viewFields
.assignedTasks.universalIdentifier,
position: 10,
},
],
},
];
@Command({
name: 'upgrade:1-18:backfill-standard-views-and-field-metadata',
description:
'Backfill standard views and fix field metadata (isSystem, labels, icons) for attachment, noteTarget, taskTarget, timelineActivity, workspaceMember',
})
export class BackfillStandardViewsAndFieldMetadataCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectDataSource()
private readonly coreDataSource: DataSource,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const dryRun = options?.dryRun ?? false;
this.logger.log(
`${dryRun ? '[DRY RUN] ' : ''}Backfilling views and field metadata for workspace ${workspaceId}`,
);
if (dryRun) {
this.logger.log(
`[DRY RUN] Would update isSystem, labels/icons, backfill views and viewFields for workspace ${workspaceId}. Skipping.`,
);
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Part 1: Mark fields as isSystem
const systemResult = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET "isSystem" = true
WHERE "workspaceId" = $1
AND "universalIdentifier" = ANY($2)
AND "isSystem" = false`,
[workspaceId, FIELDS_TO_MARK_AS_SYSTEM],
);
const systemCount = systemResult?.[1] ?? 0;
if (systemCount > 0) {
this.logger.log(
`Marked ${systemCount} field(s) as isSystem for workspace ${workspaceId}`,
);
}
// Part 1b: Update morph field labels and icons to "Target"
const labelResult = await queryRunner.query(
`UPDATE core."fieldMetadata"
SET label = 'Target', icon = 'IconArrowUpRight'
WHERE "workspaceId" = $1
AND "universalIdentifier" = ANY($2)
AND (label != 'Target' OR icon != 'IconArrowUpRight')`,
[workspaceId, MORPH_FIELDS_TO_RELABEL],
);
const labelCount = labelResult?.[1] ?? 0;
if (labelCount > 0) {
this.logger.log(
`Relabeled ${labelCount} morph field(s) to "Target" for workspace ${workspaceId}`,
);
}
// Part 2: Resolve applicationId for twenty-standard app
const applicationRows = await queryRunner.query(
`SELECT id FROM core."application"
WHERE "workspaceId" = $1
AND "universalIdentifier" = $2`,
[workspaceId, TWENTY_STANDARD_APPLICATION.universalIdentifier],
);
if (applicationRows.length === 0) {
this.logger.warn(
`Twenty Standard Application not found for workspace ${workspaceId}. Skipping view backfill.`,
);
await queryRunner.commitTransaction();
await this.invalidateCaches(workspaceId);
return;
}
const applicationId = applicationRows[0].id;
// Part 3 & 4: Backfill views and viewFields
for (const view of VIEWS_TO_BACKFILL) {
// Insert view if it doesn't exist
const viewInsertResult = await queryRunner.query(
`INSERT INTO core."view" (
id, name, "objectMetadataId", type, icon, position,
visibility, "workspaceId", "applicationId", "universalIdentifier"
)
SELECT
gen_random_uuid(), $2, om.id, 'TABLE', $3, 0,
'WORKSPACE', $1, $4, $5
FROM core."objectMetadata" om
WHERE om."workspaceId" = $1
AND om."universalIdentifier" = $6
AND NOT EXISTS (
SELECT 1 FROM core."view" v
WHERE v."workspaceId" = $1
AND v."universalIdentifier" = $5
AND v."deletedAt" IS NULL
)
RETURNING 1`,
[
workspaceId,
view.viewName,
view.viewIcon,
applicationId,
view.viewUI,
view.objectUI,
],
);
const viewInserted = viewInsertResult?.length ?? 0;
if (viewInserted > 0) {
this.logger.log(
`Created view "${view.viewName}" for workspace ${workspaceId}`,
);
}
// Insert viewFields
for (const viewField of view.viewFields) {
await queryRunner.query(
`INSERT INTO core."viewField" (
id, "fieldMetadataId", "isVisible", size, position,
"viewId", "workspaceId", "applicationId", "universalIdentifier"
)
SELECT
gen_random_uuid(), fm.id, true, 150, $4,
v.id, $1, $5, $6
FROM core."view" v
JOIN core."fieldMetadata" fm
ON fm."workspaceId" = $1
AND fm."universalIdentifier" = $3
WHERE v."workspaceId" = $1
AND v."universalIdentifier" = $2
AND v."deletedAt" IS NULL
AND NOT EXISTS (
SELECT 1 FROM core."viewField" vf
WHERE vf."workspaceId" = $1
AND vf."universalIdentifier" = $6
AND vf."deletedAt" IS NULL
)`,
[
workspaceId,
view.viewUI,
viewField.fieldUI,
viewField.position,
applicationId,
viewField.viewFieldUI,
],
);
}
}
await queryRunner.commitTransaction();
this.logger.log(`Completed for workspace ${workspaceId}`);
await this.invalidateCaches(workspaceId);
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
this.logger.error(
`Error backfilling views and field metadata (rolled back) for workspace ${workspaceId}`,
error,
);
} else {
this.logger.error(
`Error backfilling views and field metadata (after commit) for workspace ${workspaceId}`,
error,
);
}
throw error;
} finally {
await queryRunner.release();
}
}
private async invalidateCaches(workspaceId: string): Promise<void> {
const modifiedMetadataNames = [
'fieldMetadata',
'view',
'viewField',
] as const;
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
...new Set(
modifiedMetadataNames
.flatMap((name) => [name, ...getMetadataRelatedMetadataNames(name)])
.map(getMetadataFlatEntityMapsKey),
),
'ORMEntityMetadatas',
];
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
cacheKeysToInvalidate,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
await this.workspaceCacheStorageService.flush(workspaceId);
this.logger.log(
`Cache invalidated and metadata version incremented for workspace ${workspaceId}`,
);
}
}
@@ -1,87 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
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 { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
@Command({
name: 'upgrade:1-18:delete-orphan-favorites',
description:
'Delete favorites whose viewId does not exist in workspace view metadata (fixes upgrade failure)',
})
export class DeleteOrphanFavoritesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(DeleteOrphanFavoritesCommand.name);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(`Deleting orphan favorites for workspace ${workspaceId}`);
const { flatViewMaps } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['flatViewMaps'],
);
const favoriteRepository =
await this.twentyORMGlobalManager.getRepository<FavoriteWorkspaceEntity>(
workspaceId,
'favorite',
{ shouldBypassPermissionChecks: true },
);
const favoritesWithViewId = await favoriteRepository.find({
where: {
deletedAt: IsNull(),
viewId: Not(IsNull()),
},
select: { id: true, viewId: true },
});
const orphanFavoriteIds = favoritesWithViewId
.filter(
(favorite) =>
!isDefined(flatViewMaps.universalIdentifierById[favorite.viewId]),
)
.map((favorite) => favorite.id);
if (orphanFavoriteIds.length === 0) {
this.logger.log(`No orphan favorites found for workspace ${workspaceId}`);
return;
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would delete ${orphanFavoriteIds.length} orphan favorite(s) for workspace ${workspaceId}`,
);
return;
}
await favoriteRepository.delete(orphanFavoriteIds);
this.logger.log(
`Deleted ${orphanFavoriteIds.length} orphan favorite(s) for workspace ${workspaceId}`,
);
}
}
@@ -1,468 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { DataSource, 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 { ApplicationService } from 'src/engine/core-modules/application/application.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 { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.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 { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity';
import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity';
type RichTextBlock = Record<string, unknown>;
@Command({
name: 'upgrade:1-18:migrate-activity-rich-text-attachment-file-ids',
description:
'[DEPRECATED] Migrate activity rich text blocks - this migration is now complete and no longer needed',
})
export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`[DEPRECATED] Activity rich text attachment file IDs migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_FILES_FIELD_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting activity rich text attachment file IDs 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,
},
);
const attachmentFileFieldMetadata = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.attachment.fields.file.universalIdentifier,
});
if (!isDefined(attachmentFileFieldMetadata)) {
this.logger.error(
`Failed to find attachment file field metadata for workspace ${workspaceId}`,
);
throw new Error(
`Failed to find attachment file field metadata for workspace ${workspaceId}`,
);
}
const systemAuthContext = buildSystemAuthContext(workspaceId);
await this.twentyORMGlobalManager.executeInWorkspaceContext(async () => {
const attachmentRepository =
await this.twentyORMGlobalManager.getRepository<AttachmentWorkspaceEntity>(
workspaceId,
'attachment',
{ shouldBypassPermissionChecks: true },
);
const noteRepository =
await this.twentyORMGlobalManager.getRepository<NoteWorkspaceEntity>(
workspaceId,
'note',
{ shouldBypassPermissionChecks: true },
);
const taskRepository =
await this.twentyORMGlobalManager.getRepository<TaskWorkspaceEntity>(
workspaceId,
'task',
{ shouldBypassPermissionChecks: true },
);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
await this.migrateActivityTable({
activityRepository: noteRepository,
attachmentRepository,
fileRepository,
activityType: 'note',
targetColumnName: 'targetNoteId',
workspaceId,
twentyStandardApplication: twentyStandardFlatApplication,
fileFieldMetadata: attachmentFileFieldMetadata,
isDryRun,
});
await this.migrateActivityTable({
activityRepository: taskRepository,
attachmentRepository,
fileRepository,
activityType: 'task',
targetColumnName: 'targetTaskId',
workspaceId,
twentyStandardApplication: twentyStandardFlatApplication,
fileFieldMetadata: attachmentFileFieldMetadata,
isDryRun,
});
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed activity rich text attachment file IDs migration for workspace ${workspaceId}`,
);
}, systemAuthContext);
};
private async migrateActivityTable({
activityRepository,
attachmentRepository,
fileRepository,
activityType,
targetColumnName,
workspaceId,
twentyStandardApplication,
fileFieldMetadata,
isDryRun,
}: {
activityRepository:
| WorkspaceRepository<NoteWorkspaceEntity>
| WorkspaceRepository<TaskWorkspaceEntity>;
attachmentRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>;
fileRepository: Repository<FileEntity>;
activityType: 'note' | 'task';
targetColumnName: 'targetNoteId' | 'targetTaskId';
workspaceId: string;
twentyStandardApplication: { id: string; universalIdentifier: string };
fileFieldMetadata: { universalIdentifier: string };
isDryRun: boolean;
}): Promise<void> {
this.logger.log(
`Migrating ${activityType} rich text for workspace ${workspaceId}`,
);
const activities = await activityRepository.find();
this.logger.log(
`Found ${activities.length} ${activityType}(s) to process in workspace ${workspaceId}`,
);
for (const activity of activities) {
const bodyV2 = activity.bodyV2;
if (!isDefined(bodyV2?.blocknote)) {
continue;
}
let blocknote: RichTextBlock[];
try {
blocknote = JSON.parse(bodyV2.blocknote);
} catch (error) {
this.logger.warn(
`Failed to parse bodyV2.blocknote for ${activityType} ${activity.id}: ${error.message}`,
);
continue;
}
const needsMigration = blocknote.some((block: RichTextBlock) => {
const props = (block.props as Record<string, unknown>) || {};
const url = props.url as string | undefined;
return (
isDefined(url) &&
!isDefined(extractFileIdFromUrl(url, FileFolder.FilesField))
);
});
if (!needsMigration) {
continue;
}
const urlToFileIdMap = await this.buildUrlToFileIdMap(
attachmentRepository,
targetColumnName,
activity.id,
);
let hasChanges = false;
const enrichedBlocknote = [];
for (const block of blocknote) {
const props = (block.props as Record<string, unknown>) || {};
const url = props.url as string | undefined;
if (
!isDefined(url) ||
isDefined(extractFileIdFromUrl(url, FileFolder.FilesField)) ||
!url.includes('/files/attachment/')
) {
enrichedBlocknote.push(block);
continue;
}
let fileId = this.findMatchingFileId(url, urlToFileIdMap);
if (!isDefined(fileId) && !isDryRun) {
const createdFileId = await this.createAttachmentFromUrl(
url,
block,
attachmentRepository,
fileRepository,
targetColumnName,
activity.id,
workspaceId,
twentyStandardApplication,
fileFieldMetadata,
);
if (isDefined(createdFileId)) {
fileId = createdFileId;
}
}
if (isDefined(fileId)) {
this.logger.log(
`Enriching ${block.type} block in ${activityType} ${activity.id} with fileId: ${fileId}`,
);
hasChanges = true;
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
});
enrichedBlocknote.push({
...block,
props: {
...props,
url: signedUrl,
},
});
} else {
enrichedBlocknote.push(block);
}
}
if (hasChanges && !isDryRun) {
const updatedBodyV2 = {
...bodyV2,
blocknote: JSON.stringify(enrichedBlocknote),
};
await activityRepository.update(
{ id: activity.id },
{ bodyV2: updatedBodyV2 },
);
}
}
}
private async buildUrlToFileIdMap(
attachmentRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
targetColumnName: 'targetNoteId' | 'targetTaskId',
activityId: string,
): Promise<Map<string, string>> {
const urlToFileIdMap = new Map<string, string>();
const attachments = await attachmentRepository.find({
where: {
[targetColumnName]: activityId,
},
select: ['fullPath', 'file'],
});
for (const attachment of attachments) {
if (
isDefined(attachment.file) &&
Array.isArray(attachment.file) &&
attachment.file.length > 0
) {
const fileData = attachment.file[0];
if (isDefined(attachment.fullPath) && isDefined(fileData.fileId)) {
urlToFileIdMap.set(attachment.fullPath, fileData.fileId);
}
}
}
return urlToFileIdMap;
}
private findMatchingFileId(
url: string,
urlToFileIdMap: Map<string, string>,
): string | undefined {
const normalizedUrl = url.includes(FileFolder.FilesField)
? this.getAttachmentNewdRelativePath(url)
: this.getAttachmentLegacyRelativePath(url);
return urlToFileIdMap.get(normalizedUrl);
}
private async createAttachmentFromUrl(
url: string,
block: RichTextBlock,
attachmentRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>,
fileRepository: Repository<FileEntity>,
targetColumnName: 'targetNoteId' | 'targetTaskId',
activityId: string,
workspaceId: string,
twentyStandardApplication: { id: string; universalIdentifier: string },
fileFieldMetadata: { universalIdentifier: string },
): Promise<string | undefined> {
try {
const props = block.props as Record<string, unknown>;
const blockName = (props.name as string) || '';
const filePath = this.getAttachmentLegacyRelativePath(url);
const {
type: fileExtension,
filename,
folderPath,
} = extractFolderPathFilenameAndTypeOrThrow(filePath);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
const newResourcePath = `${FileFolder.FilesField}/${fileFieldMetadata.universalIdentifier}/${newFilename}`;
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}/${folderPath}`,
filename: filename,
},
to: {
folderPath: `${workspaceId}/${twentyStandardApplication.universalIdentifier}`,
filename: newResourcePath,
},
});
const fileEntity = fileRepository.create({
id: fileId,
path: newResourcePath,
workspaceId,
applicationId: twentyStandardApplication.id,
size: -1,
settings: {
isTemporaryFile: true,
toDelete: false,
},
});
await fileRepository.save(fileEntity);
const attachmentName = isNonEmptyString(blockName) ? blockName : filename;
await attachmentRepository.save({
[targetColumnName]: activityId,
name: attachmentName,
file: [
{
fileId: fileEntity.id,
label: attachmentName,
},
],
});
this.logger.log(
`Created attachment for ${block.type} block with URL: ${url}`,
);
return fileId;
} catch (error) {
this.logger.warn(
`Failed to create attachment from URL ${url}: ${error.message}`,
);
return undefined;
}
}
getAttachmentLegacyRelativePath = (url: string): string => {
if (!url.includes('/files/attachment/')) {
throw new Error(`Invalid attachment legacy relative path: ${url}`);
}
//https://example.com/files/attachment/token/filename.jpg -> attachment/filename.jpg
const attachmentPath = url.split('/files/attachment/')[1].split('/')[1];
return `${FileFolder.Attachment}/${attachmentPath}`;
};
getAttachmentNewdRelativePath = (url: string): string => {
if (!url.includes('/files-field/')) {
throw new Error(`Invalid new attachment path: ${url}`);
}
//https://example.com/files-field/123/456/789/filename.jpg -> files-field/123/456/789/filename.jpg
const attachmentPath = url.split('/files-field/')[1];
return `${FileFolder.FilesField}/${attachmentPath}`;
};
}
@@ -1,297 +0,0 @@
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/application.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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
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:
'[DEPRECATED] Migrate attachment files to file field - this migration is now complete and no longer needed',
})
export class MigrateAttachmentFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
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,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`[DEPRECATED] Attachment files migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_FILES_FIELD_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
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}`,
);
} else {
this.logger.log(
`[DRY RUN] Would create 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 systemAuthContext = buildSystemAuthContext(workspaceId);
await this.twentyORMGlobalManager.executeInWorkspaceContext(async () => {
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;
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed attachment files migration for workspace ${workspaceId}`,
);
}, systemAuthContext);
};
}
@@ -1,782 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FieldMetadataType, FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, IsNull, Not, Or, Repository } from 'typeorm';
import { v4 as uuidv4 } 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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_NAVIGATION_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
const WORKSPACE_LEVEL = 'workspace-level';
@Command({
name: 'upgrade:1-18:migrate-favorites-to-navigation-menu-items',
description: 'Migrate favorites and favoriteFolders to navigationMenuItems',
})
export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateFavoritesToNavigationMenuItemsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly featureFlagService: FeatureFlagService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`Starting migration of favorites to navigation menu items for workspace ${workspaceId}`,
);
const isNavigationMenuItemEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
workspaceId,
);
if (isNavigationMenuItemEnabled) {
this.logger.log(
`Navigation menu item feature flag is already enabled for workspace ${workspaceId}. Skipping migration.`,
);
return;
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would migrate favorites to navigation menu items for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication, workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const {
flatObjectMetadataMaps,
flatViewMaps,
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
flatFieldMetadataMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatViewMaps',
'flatNavigationMenuItemMaps',
'flatFieldMetadataMaps',
]);
const {
folderIdMapping,
flatNavigationMenuItemsToCreate: folderNavItemsToCreate,
} = await this.migrateFavoriteFolders({
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
existingFlatNavigationMenuItemMaps,
});
const { flatNavigationMenuItemsToCreate: favoriteNavItemsToCreate } =
await this.migrateFavorites({
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
twentyStandardApplicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
flatObjectMetadataMaps,
flatViewMaps,
folderIdMapping,
existingFlatNavigationMenuItemMaps,
flatFieldMetadataMaps,
});
const allFlatNavigationMenuItemsToCreate = [
...folderNavItemsToCreate,
...favoriteNavItemsToCreate,
];
if (allFlatNavigationMenuItemsToCreate.length > 0) {
const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatNavigationMenuItemMaps',
]);
const twentyStandardItems = allFlatNavigationMenuItemsToCreate.filter(
(item) => item.applicationId === twentyStandardFlatApplication.id,
);
const workspaceCustomItems = allFlatNavigationMenuItemsToCreate.filter(
(item) => item.applicationId === workspaceCustomFlatApplication.id,
);
if (workspaceCustomItems.length > 0) {
await this.createNavigationMenuItems({
workspaceId,
flatNavigationMenuItemsToCreate: workspaceCustomItems,
existingFlatNavigationMenuItemMaps,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
});
}
if (twentyStandardItems.length > 0) {
await this.createNavigationMenuItems({
workspaceId,
flatNavigationMenuItemsToCreate: twentyStandardItems,
existingFlatNavigationMenuItemMaps,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
});
}
}
await this.featureFlagService.upsertWorkspaceFeatureFlag({
workspaceId,
featureFlag: FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
value: true,
});
this.logger.log(
`Successfully migrated favorites to navigation menu items for workspace ${workspaceId}`,
);
}
private async migrateFavoriteFolders({
workspaceId,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
existingFlatNavigationMenuItemMaps,
}: {
workspaceId: string;
workspaceCustomApplicationId: string;
workspaceCustomApplicationUniversalIdentifier: string;
existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
}): Promise<{
folderIdMapping: Map<string, Map<string, string>>;
flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[];
}> {
const existingFlatNavigationMenuItemIds = Object.keys(
existingFlatNavigationMenuItemMaps.universalIdentifierById,
);
const favoriteFolderRepository =
await this.twentyORMGlobalManager.getRepository<FavoriteFolderWorkspaceEntity>(
workspaceId,
'favoriteFolder',
{ shouldBypassPermissionChecks: true },
);
const favoriteFolders = await favoriteFolderRepository.find({
where: {
deletedAt: IsNull(),
id: Not(In(existingFlatNavigationMenuItemIds)),
},
order: { position: 'ASC' },
relations: {
favorites: true,
},
});
if (favoriteFolders.length === 0) {
return {
folderIdMapping: new Map(),
flatNavigationMenuItemsToCreate: [],
};
}
const folderIdMapping = new Map<string, Map<string, string>>(); // favoriteFolderId -> workspaceMemberId or workspace-level -> navigationMenuItemId
const flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[] = [];
for (const favoriteFolder of favoriteFolders) {
const workspaceMemberIdToNavigationMenuItemIdMap = new Map<
string,
string
>();
const workspaceMemberIdsWithFavoritesInFolder = new Set(
favoriteFolder.favorites
.map((favorite) => favorite.forWorkspaceMemberId)
.filter(isDefined),
);
const hasWorkspaceScopedFavoritesInFolder = favoriteFolder.favorites.some(
(favorite) => !isDefined(favorite.forWorkspaceMemberId),
);
const now = new Date().toISOString();
for (const [index, workspaceMemberId] of Array.from(
workspaceMemberIdsWithFavoritesInFolder,
).entries()) {
const navigationMenuItemId = index === 0 ? favoriteFolder.id : uuidv4();
workspaceMemberIdToNavigationMenuItemIdMap.set(
workspaceMemberId,
navigationMenuItemId,
);
const userWorkspaceId =
await this.getUserWorkspaceIdFromWorkspaceMemberId(
workspaceMemberId,
workspaceId,
);
const folderToCreate = {
id: navigationMenuItemId,
type: NavigationMenuItemType.FOLDER,
universalIdentifier: navigationMenuItemId,
userWorkspaceId,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: null,
viewUniversalIdentifier: null,
folderId: null,
folderUniversalIdentifier: null,
name: favoriteFolder.name,
link: null,
icon: null,
color: null,
position: favoriteFolder.position,
workspaceId,
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
workspaceCustomApplicationUniversalIdentifier,
createdAt: now,
updatedAt: now,
};
flatNavigationMenuItemsToCreate.push(folderToCreate);
}
if (hasWorkspaceScopedFavoritesInFolder) {
const workspaceLevelNavigationMenuItemId = uuidv4();
workspaceMemberIdToNavigationMenuItemIdMap.set(
WORKSPACE_LEVEL,
workspaceLevelNavigationMenuItemId,
);
const workspaceLevelFolderToCreate = {
id: workspaceLevelNavigationMenuItemId,
type: NavigationMenuItemType.FOLDER,
universalIdentifier: workspaceLevelNavigationMenuItemId,
userWorkspaceId: null,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: null,
viewUniversalIdentifier: null,
folderId: null,
folderUniversalIdentifier: null,
name: favoriteFolder.name,
link: null,
icon: null,
color: null,
position: favoriteFolder.position,
workspaceId,
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
workspaceCustomApplicationUniversalIdentifier,
createdAt: now,
updatedAt: now,
};
flatNavigationMenuItemsToCreate.push(workspaceLevelFolderToCreate);
}
folderIdMapping.set(
favoriteFolder.id,
workspaceMemberIdToNavigationMenuItemIdMap,
);
}
return {
folderIdMapping,
flatNavigationMenuItemsToCreate,
};
}
private async migrateFavorites({
workspaceId,
twentyStandardApplicationId,
twentyStandardApplicationUniversalIdentifier,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
flatObjectMetadataMaps,
flatViewMaps,
folderIdMapping,
existingFlatNavigationMenuItemMaps,
flatFieldMetadataMaps,
}: {
workspaceId: string;
twentyStandardApplicationId: string;
twentyStandardApplicationUniversalIdentifier: string;
workspaceCustomApplicationId: string;
workspaceCustomApplicationUniversalIdentifier: string;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatViewMaps: FlatEntityMaps<FlatView>;
folderIdMapping: Map<string, Map<string, string>>;
existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<{
migratedFavoriteIds: string[];
flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[];
}> {
const favoriteRepository =
await this.twentyORMGlobalManager.getRepository<FavoriteWorkspaceEntity>(
workspaceId,
'favorite',
{ shouldBypassPermissionChecks: true },
);
const existingFlatNavigationMenuItemIds = Object.keys(
existingFlatNavigationMenuItemMaps.universalIdentifierById,
);
const viewIdsFromExistingNavigationMenuItems = Object.values(
existingFlatNavigationMenuItemMaps.byUniversalIdentifier,
)
.filter(isDefined)
.map((item): string | null => item.viewId)
.filter(isDefined);
const favorites = await favoriteRepository.find({
where: {
deletedAt: IsNull(),
id: Not(In(existingFlatNavigationMenuItemIds)),
viewId: Or(IsNull(), Not(In(viewIdsFromExistingNavigationMenuItems))),
},
order: { position: 'ASC' },
});
if (favorites.length === 0) {
return { migratedFavoriteIds: [], flatNavigationMenuItemsToCreate: [] };
}
const favoriteObjectMetadata = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: STANDARD_OBJECTS.favorite.universalIdentifier,
});
if (!isDefined(favoriteObjectMetadata)) {
throw new Error('Favorite object metadata not found');
}
const favoriteRelationFields =
findManyFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityIds: favoriteObjectMetadata.fieldIds,
}).filter(
(field): field is FlatFieldMetadata<FieldMetadataType.RELATION> =>
isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION),
);
const favoriteRelationFieldMap = new Map<
string,
{ name: string; relationTargetUniversalIdentifier: string }
>();
for (const field of favoriteRelationFields) {
if (
field.name !== 'forWorkspaceMember' &&
field.name !== 'favoriteFolder' &&
isDefined(field.relationTargetObjectMetadataId)
) {
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatObjectMetadataMaps,
flatEntityId: field.relationTargetObjectMetadataId,
});
if (isDefined(targetObjectMetadata)) {
favoriteRelationFieldMap.set(field.name, {
name: field.name,
relationTargetUniversalIdentifier:
targetObjectMetadata.universalIdentifier,
});
}
}
}
const flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[] = [];
const migratedFavoriteIds: string[] = [];
for (const favorite of favorites) {
const userWorkspaceId = !isDefined(favorite.forWorkspaceMemberId)
? null
: await this.getUserWorkspaceIdFromWorkspaceMemberId(
favorite.forWorkspaceMemberId,
workspaceId,
);
if (
isDefined(favorite.forWorkspaceMemberId) &&
!isDefined(userWorkspaceId)
) {
this.logger.warn(
`Skipping favorite ${favorite.id} - forWorkspaceMemberId ${favorite.forWorkspaceMemberId} did not resolve to a userWorkspaceId`,
);
continue;
}
const folderId = favorite.favoriteFolderId
? (folderIdMapping
.get(favorite.favoriteFolderId)
?.get(
isDefined(favorite.forWorkspaceMemberId)
? favorite.forWorkspaceMemberId
: WORKSPACE_LEVEL,
) ?? null)
: null;
if (isDefined(favorite.viewId)) {
const existingItem = Object.values(
existingFlatNavigationMenuItemMaps.byUniversalIdentifier,
).find(
(item): item is FlatNavigationMenuItem =>
isDefined(item) &&
item.workspaceId === workspaceId &&
item.userWorkspaceId === userWorkspaceId &&
item.viewId === favorite.viewId &&
item.folderId === folderId,
);
if (isDefined(existingItem)) {
this.logger.log(
`Skipping favorite ${favorite.id} - navigation menu item already exists for view ${favorite.viewId}`,
);
continue;
}
try {
const {
applicationId,
applicationUniversalIdentifier,
universalIdentifier,
} = this.getApplicationIdAndUniversalIdentifierForViewFavorite({
viewId: favorite.viewId,
flatViewMaps,
twentyStandardApplicationId,
twentyStandardApplicationUniversalIdentifier,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
});
const now = new Date().toISOString();
flatNavigationMenuItemsToCreate.push({
id: favorite.id,
type: NavigationMenuItemType.VIEW,
universalIdentifier,
userWorkspaceId,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: favorite.viewId,
viewUniversalIdentifier:
flatViewMaps.universalIdentifierById[favorite.viewId] ?? null,
folderId,
folderUniversalIdentifier: folderId,
name: null,
link: null,
icon: null,
color: null,
position: favorite.position,
workspaceId,
applicationId,
applicationUniversalIdentifier,
createdAt: now,
updatedAt: now,
});
migratedFavoriteIds.push(favorite.id);
} catch (error) {
this.logger.error(
`Failed to migrate favorite ${favorite.id} with view ${favorite.viewId} - ${error}`,
);
continue;
}
continue;
}
const { targetRecordId, targetObjectMetadataId } =
this.getTargetRecordFromFavorite(
favorite,
flatObjectMetadataMaps,
favoriteRelationFieldMap,
);
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
this.logger.warn(
`Skipping favorite ${favorite.id} - no valid target record found`,
);
continue;
}
const now = new Date().toISOString();
flatNavigationMenuItemsToCreate.push({
id: favorite.id,
type: NavigationMenuItemType.RECORD,
universalIdentifier: favorite.id,
userWorkspaceId,
targetRecordId,
targetObjectMetadataId,
targetObjectMetadataUniversalIdentifier:
flatObjectMetadataMaps.universalIdentifierById[
targetObjectMetadataId
] ?? null,
viewId: null,
viewUniversalIdentifier: null,
folderId,
folderUniversalIdentifier: folderId,
name: null,
link: null,
icon: null,
color: null,
position: favorite.position,
workspaceId,
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
workspaceCustomApplicationUniversalIdentifier,
createdAt: now,
updatedAt: now,
});
migratedFavoriteIds.push(favorite.id);
}
return {
migratedFavoriteIds,
flatNavigationMenuItemsToCreate,
};
}
private getApplicationIdAndUniversalIdentifierForViewFavorite({
viewId,
flatViewMaps,
twentyStandardApplicationId,
twentyStandardApplicationUniversalIdentifier,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
}: {
viewId: string;
flatViewMaps: FlatEntityMaps<FlatView>;
twentyStandardApplicationId: string;
twentyStandardApplicationUniversalIdentifier: string;
workspaceCustomApplicationId: string;
workspaceCustomApplicationUniversalIdentifier: string;
}): {
applicationId: string;
applicationUniversalIdentifier: string;
universalIdentifier: string;
} {
const flatView = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatViewMaps,
flatEntityId: viewId,
});
if (!isDefined(flatView)) {
throw new Error(`View ${viewId} not found`);
}
const matchingStandardItem = Object.values(
STANDARD_NAVIGATION_MENU_ITEMS,
).find(
(item) =>
'viewUniversalIdentifier' in item &&
item.viewUniversalIdentifier === flatView.universalIdentifier,
);
return !isDefined(matchingStandardItem)
? {
applicationId: workspaceCustomApplicationId,
applicationUniversalIdentifier:
workspaceCustomApplicationUniversalIdentifier,
universalIdentifier: uuidv4(),
}
: {
applicationId: twentyStandardApplicationId,
applicationUniversalIdentifier:
twentyStandardApplicationUniversalIdentifier,
universalIdentifier: matchingStandardItem.universalIdentifier,
};
}
private async getUserWorkspaceIdFromWorkspaceMemberId(
workspaceMemberId: string | null | undefined,
workspaceId: string,
): Promise<string | null> {
if (!isDefined(workspaceMemberId)) {
return null;
}
const authContext = buildSystemAuthContext(workspaceId);
try {
const workspaceMember =
await this.twentyORMGlobalManager.executeInWorkspaceContext(
async () => {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
return await workspaceMemberRepository.findOne({
where: { id: workspaceMemberId },
});
},
authContext,
);
if (!isDefined(workspaceMember)) {
return null;
}
const userWorkspace =
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
userId: workspaceMember.userId,
workspaceId,
});
return userWorkspace.id;
} catch (error) {
this.logger.warn(
`Failed to get userWorkspaceId for workspaceMemberId ${workspaceMemberId}: ${error}`,
);
return null;
}
}
private getTargetRecordFromFavorite(
favorite: FavoriteWorkspaceEntity,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
favoriteRelationFieldMap: Map<
string,
{ name: string; relationTargetUniversalIdentifier: string }
>,
): {
targetRecordId: string | null;
targetObjectMetadataId: string | null;
} {
for (const [
fieldName,
{ relationTargetUniversalIdentifier },
] of favoriteRelationFieldMap.entries()) {
const fieldIdName = `${fieldName}Id`;
const recordId = (
favorite as unknown as Record<string, string | undefined>
)[fieldIdName];
if (!isDefined(recordId)) {
continue;
}
const targetObjectMetadata = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: relationTargetUniversalIdentifier,
});
if (isDefined(targetObjectMetadata)) {
return {
targetRecordId: recordId,
targetObjectMetadataId: targetObjectMetadata.id,
};
}
}
return { targetRecordId: null, targetObjectMetadataId: null };
}
private async createNavigationMenuItems({
workspaceId,
flatNavigationMenuItemsToCreate,
existingFlatNavigationMenuItemMaps: _existingFlatNavigationMenuItemMaps,
applicationUniversalIdentifier,
}: {
workspaceId: string;
flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[];
existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
applicationUniversalIdentifier: string;
}): Promise<void> {
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: flatNavigationMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: true,
applicationUniversalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to create navigation menu items: ${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
}
}
}
@@ -1,299 +0,0 @@
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/application.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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
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:
'[DEPRECATED] Migrate person avatarUrl files to file field - this migration is now complete and no longer needed',
})
export class MigratePersonAvatarFilesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
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,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`[DEPRECATED] Person avatar files migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_FILES_FIELD_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
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}`,
);
} else {
this.logger.log(
`[DRY RUN] Would create avatarFile field metadata for workspace ${workspaceId}`,
);
}
}
if (!isDefined(avatarFileFieldMetadata)) {
this.logger.error(
`Avatar file field metadata not found for workspace ${workspaceId}`,
);
return;
}
const systemAuthContext = buildSystemAuthContext(workspaceId);
await this.twentyORMGlobalManager.executeInWorkspaceContext(async () => {
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}`,
);
}, systemAuthContext);
};
}
@@ -1,211 +0,0 @@
import { Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FileFolder } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { DataSource, In, Repository } from 'typeorm';
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 { ApplicationService } from 'src/engine/core-modules/application/application.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 { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
type WorkflowFile = {
id: string;
path?: string;
name: string;
size: number;
type: string;
createdAt: string;
};
type SendEmailStep = {
id: string;
type: WorkflowActionType.SEND_EMAIL;
settings: {
input: {
files?: WorkflowFile[];
[key: string]: unknown;
};
[key: string]: unknown;
};
[key: string]: unknown;
};
@Command({
name: 'upgrade:1-18:migrate-workflow-send-email-attachments',
description:
'[DEPRECATED] Migrate workflow send email attachments - this migration is now complete and no longer needed',
})
export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateWorkflowSendEmailAttachmentsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`[DEPRECATED] Workflow send email attachments migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_OTHER_FILE_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
private _deprecatedMigrationLogic = async ({
workspaceId,
isDryRun,
}: {
workspaceId: string;
isDryRun: boolean;
}) => {
const isMigrated = false;
if (isMigrated) {
this.logger.log(
`Workflow attachments migration already completed for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting workflow send email attachments migration for workspace ${workspaceId}`,
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersions = await workflowVersionRepository.find({
select: ['id', 'steps'],
where: {
status: In([
WorkflowVersionStatus.DRAFT,
WorkflowVersionStatus.ACTIVE,
WorkflowVersionStatus.DEACTIVATED,
]),
},
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
for (const workflowVersion of workflowVersions) {
const steps = workflowVersion.steps;
if (!isNonEmptyArray(steps)) {
continue;
}
for (const step of steps) {
if (step.type !== WorkflowActionType.SEND_EMAIL) {
continue;
}
const sendEmailStep = step as SendEmailStep;
const files = sendEmailStep.settings?.input?.files;
if (!isNonEmptyArray(files)) {
continue;
}
for (const file of files) {
const fileEntity = await fileRepository.findOne({
where: {
id: file.id,
workspaceId,
},
});
if (!isDefined(fileEntity)) {
this.logger.warn(
`File ${file.id} not found for workflow version ${workflowVersion.id}, skipping`,
);
continue;
}
if (fileEntity.path.startsWith(FileFolder.Workflow)) {
this.logger.log(
`File ${file.id} already in Workflow folder, skipping copy`,
);
continue;
}
const newResourcePath = `${fileEntity.id}${isNonEmptyString(file.type) ? `.${file.type}` : ''}`;
const newPath = `${FileFolder.Workflow}/${newResourcePath}`;
if (!isDryRun) {
try {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: fileEntity.path,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newPath,
},
});
} catch (error) {
this.logger.error(
`Failed to migrate file ${fileEntity.id} in workspace ${workspaceId}: ${error.message}`,
);
continue;
}
} else {
this.logger.log(
`[DRY RUN] Would migrate file ${fileEntity.id} from ${fileEntity.path} to ${newPath}`,
);
}
await fileRepository.update(
{ id: fileEntity.id },
{
path: newPath,
applicationId: workspaceCustomFlatApplication.id,
settings: {
isTemporaryFile: true,
toDelete: false,
},
},
);
}
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed workflow send email attachments migration for workspace ${workspaceId}`,
);
};
}
@@ -1,381 +0,0 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { FileTypeParser } from 'file-type';
import { detectPdf } from '@file-type/pdf';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { And, DataSource, IsNull, Like, Not, 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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.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 { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { getImageBufferFromUrl } from 'src/utils/image';
@Command({
name: 'upgrade:1-18:migrate-workspace-pictures',
description:
'[DEPRECATED] Migrate workspace logos and workspace member avatars to file records - this migration is now complete and no longer needed',
})
export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly fileStorageService: FileStorageService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
private readonly secureHttpClientService: SecureHttpClientService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
this.logger.log(
`[DEPRECATED] Workspace pictures migration is no longer needed for workspace ${workspaceId}. ` +
`The IS_CORE_PICTURE_MIGRATED feature flag has been removed as all workspaces are now migrated.`,
);
}
private async migrateWorkspaceLogo({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
}: {
workspaceId: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
fileRepository: Repository<FileEntity>;
}): Promise<void> {
const workspace = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
logo: Not(IsNull()),
logoFileId: IsNull(),
},
});
if (!workspace || !isNonEmptyString(workspace.logo)) {
this.logger.log(
`No workspace logo to migrate for workspace ${workspaceId}`,
);
return;
}
const isInWorkspaceLogo = workspace.logo.startsWith(
FileFolder.WorkspaceLogo,
);
const isTwentyIconLogo = workspace.logo.includes('twenty-icons');
if (!isTwentyIconLogo && !isInWorkspaceLogo) {
this.logger.log(
`Workspace logo is not a twenty icon or a workspace logo, skipping`,
);
return;
}
this.logger.log(
`Migrating workspace logo for workspace ${workspaceId}: ${workspace.logo}`,
);
if (isInWorkspaceLogo) {
await this.migrateWorkspaceLogoFromWorkspaceFolder({
workspaceId,
logoPath: workspace.logo,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
});
}
if (isTwentyIconLogo) {
await this.migrateWorkspaceLogoFromTwentyIcons({
workspaceId,
logoUrl: workspace.logo,
isDryRun,
workspaceCustomFlatApplication,
});
}
}
private async migrateWorkspaceLogoFromWorkspaceFolder({
workspaceId,
logoPath,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
}: {
workspaceId: string;
logoPath: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
fileRepository: Repository<FileEntity>;
}): Promise<void> {
try {
const { type: fileExtension } =
extractFolderPathFilenameAndTypeOrThrow(logoPath);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
const newResourcePath = `${FileFolder.CorePicture}/${newFilename}`;
if (!isDryRun) {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: logoPath,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newResourcePath,
},
});
const fileEntity = fileRepository.create({
id: fileId,
path: newResourcePath,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
size: -1,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await fileRepository.save(fileEntity);
await this.workspaceRepository.update(
{ id: workspaceId },
{ logoFileId: fileId },
);
}
this.logger.log(
`Migrated workspace logo for workspace ${workspaceId} (${logoPath} -> ${newResourcePath})`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace logo for workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
private async migrateWorkspaceLogoFromTwentyIcons({
workspaceId,
logoUrl,
isDryRun,
workspaceCustomFlatApplication,
}: {
workspaceId: string;
logoUrl: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
}): Promise<void> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(logoUrl, httpClient);
const parser = new FileTypeParser({ customDetectors: [detectPdf] });
const type = await parser.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
this.logger.warn(
`Unable to detect image type for workspace logo ${logoUrl}, skipping`,
);
return;
}
const fileId = v4();
const newFilename = `${fileId}.${type.ext}`;
const newResourcePath = `${newFilename}`;
if (!isDryRun) {
const fileEntity = await this.fileStorageService.writeFile({
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
fileFolder: FileFolder.CorePicture,
resourcePath: newResourcePath,
sourceFile: buffer,
mimeType: type.mime,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await this.workspaceRepository.update(
{ id: workspaceId },
{ logoFileId: fileEntity.id },
);
}
this.logger.log(
`Migrated workspace logo from twenty-icons for workspace ${workspaceId} (${logoUrl} -> ${FileFolder.CorePicture}/${newResourcePath})`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace logo from twenty-icons for workspace ${workspaceId}: ${error.message}`,
);
}
}
private async migrateWorkspaceMemberAvatars({
workspaceId,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
}: {
workspaceId: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
fileRepository: Repository<FileEntity>;
}): Promise<void> {
const { flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
const workspaceMemberObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.workspaceMember.universalIdentifier,
});
if (!isDefined(workspaceMemberObjectMetadata)) {
this.logger.warn(
`Workspace member object metadata not found for workspace ${workspaceId}, skipping member avatar migration`,
);
return;
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembers = await workspaceMemberRepository.find({
where: {
avatarUrl: And(Not(IsNull()), Not(Like(`%${FileFolder.CorePicture}%`))),
},
select: ['id', 'avatarUrl'],
});
if (workspaceMembers.length === 0) {
this.logger.log(
`No workspace member avatars to migrate for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${workspaceMembers.length} workspace member avatar(s) to migrate in workspace ${workspaceId}`,
);
for (const workspaceMember of workspaceMembers) {
if (!isNonEmptyString(workspaceMember.avatarUrl)) {
this.logger.warn(
`Skipping workspace member ${workspaceMember.id} - invalid avatarUrl`,
);
continue;
}
try {
const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow(
workspaceMember.avatarUrl,
);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
const newResourcePath = `${FileFolder.CorePicture}/${newFilename}`;
if (!isDryRun) {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: workspaceMember.avatarUrl,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newResourcePath,
},
});
const fileEntity = fileRepository.create({
id: fileId,
path: newResourcePath,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
size: -1,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await fileRepository.save(fileEntity);
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
await workspaceMemberRepository.update(
{ id: workspaceMember.id },
{
avatarUrl: signedUrl,
},
);
}
this.logger.log(
`Migrated workspace member avatar ${workspaceMember.id}`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace member avatar ${workspaceMember.id} in workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
}
}
@@ -1,83 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillFileSizeAndMimeTypeCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-file-size-and-mime-type.command';
import { BackfillMessageChannelThrottleRetryAfterCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-message-channel-throttle-retry-after.command';
import { BackfillStandardViewsAndFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-standard-views-and-field-metadata.command';
import { DeleteOrphanFavoritesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-delete-orphan-favorites.command';
import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command';
import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command';
import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-favorites-to-navigation-menu-items.command';
import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command';
import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command';
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.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 { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
imports: [
TypeOrmModule.forFeature([
WorkspaceEntity,
FeatureFlagEntity,
PersonWorkspaceEntity,
FileEntity,
AttachmentWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
ObjectMetadataEntity,
FieldMetadataEntity,
]),
DataSourceModule,
FeatureFlagModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataVersionModule,
FieldMetadataModule,
ApplicationModule,
FileModule,
UserWorkspaceModule,
WorkspaceMigrationModule,
SecureHttpClientModule,
],
providers: [
DeleteOrphanFavoritesCommand,
MigratePersonAvatarFilesCommand,
MigrateFavoritesToNavigationMenuItemsCommand,
MigrateAttachmentFilesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateWorkspacePicturesCommand,
MigrateActivityRichTextAttachmentFileIdsCommand,
BackfillMessageChannelThrottleRetryAfterCommand,
BackfillStandardViewsAndFieldMetadataCommand,
MigrateWorkflowSendEmailAttachmentsCommand,
],
exports: [
DeleteOrphanFavoritesCommand,
MigratePersonAvatarFilesCommand,
MigrateFavoritesToNavigationMenuItemsCommand,
MigrateAttachmentFilesCommand,
MigrateActivityRichTextAttachmentFileIdsCommand,
BackfillMessageChannelThrottleRetryAfterCommand,
BackfillStandardViewsAndFieldMetadataCommand,
MigrateWorkspacePicturesCommand,
MigrateWorkflowSendEmailAttachmentsCommand,
BackfillFileSizeAndMimeTypeCommand,
],
})
export class V1_18_UpgradeVersionCommandModule {}
@@ -1,8 +1,6 @@
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 { V1_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-19/1-19-upgrade-version-command.module';
import { V1_20_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
@@ -15,8 +13,6 @@ import { WorkspaceVersionModule } from 'src/engine/workspace-manager/workspace-v
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
V1_17_UpgradeVersionCommandModule,
V1_18_UpgradeVersionCommandModule,
V1_19_UpgradeVersionCommandModule,
V1_20_UpgradeVersionCommandModule,
DataSourceModule,
@@ -10,24 +10,6 @@ import {
type VersionCommands,
} from 'src/database/commands/command-runners/upgrade.command-runner';
import { CoreMigrationRunnerService } from 'src/database/commands/core-migration-runner/services/core-migration-runner.service';
import { BackfillApplicationPackageFilesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-backfill-application-package-files.command';
import { DeleteFileRecordsAndUpdateTableCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-delete-all-files-and-update-table.command';
import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-fix-morph-relation-field-names.command';
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
import { BackfillFileSizeAndMimeTypeCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-file-size-and-mime-type.command';
import { BackfillMessageChannelThrottleRetryAfterCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-message-channel-throttle-retry-after.command';
import { BackfillStandardViewsAndFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-standard-views-and-field-metadata.command';
import { DeleteOrphanFavoritesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-delete-orphan-favorites.command';
import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command';
import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command';
import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-favorites-to-navigation-menu-items.command';
import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command';
import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command';
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command';
import { AddMissingSystemFieldsToStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-add-missing-system-fields-to-standard-objects.command';
import { BackfillMessageChannelMessageAssociationMessageFolderCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-message-channel-message-association-message-folder.command';
import { BackfillMissingStandardViewsCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-missing-standard-views.command';
@@ -74,28 +56,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly workspaceVersionService: WorkspaceVersionService,
protected readonly coreMigrationRunnerService: CoreMigrationRunnerService,
// 1.17 Commands
protected readonly backfillApplicationPackageFilesCommand: BackfillApplicationPackageFilesCommand,
protected readonly deleteFileRecordsAndUpdateTableCommand: DeleteFileRecordsAndUpdateTableCommand,
protected readonly migrateAttachmentToMorphRelationsCommand: MigrateAttachmentToMorphRelationsCommand,
protected readonly migrateNoteTargetToMorphRelationsCommand: MigrateNoteTargetToMorphRelationsCommand,
protected readonly migrateTaskTargetToMorphRelationsCommand: MigrateTaskTargetToMorphRelationsCommand,
protected readonly identifyWebhookMetadataCommand: IdentifyWebhookMetadataCommand,
protected readonly makeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
protected readonly fixMorphRelationFieldNamesCommand: FixMorphRelationFieldNamesCommand,
// 1.18 Commands
protected readonly deleteOrphanFavoritesCommand: DeleteOrphanFavoritesCommand,
protected readonly migrateFavoritesToNavigationMenuItemsCommand: MigrateFavoritesToNavigationMenuItemsCommand,
protected readonly migratePersonAvatarFilesCommand: MigratePersonAvatarFilesCommand,
protected readonly backfillFileSizeAndMimeTypeCommand: BackfillFileSizeAndMimeTypeCommand,
protected readonly migrateAttachmentFilesCommand: MigrateAttachmentFilesCommand,
protected readonly migrateActivityRichTextAttachmentFileIdsCommand: MigrateActivityRichTextAttachmentFileIdsCommand,
protected readonly backfillMessageChannelThrottleRetryAfterCommand: BackfillMessageChannelThrottleRetryAfterCommand,
protected readonly backfillStandardViewsAndFieldMetadataCommand: BackfillStandardViewsAndFieldMetadataCommand,
protected readonly migrateWorkspacePicturesCommand: MigrateWorkspacePicturesCommand,
protected readonly migrateWorkflowSendEmailAttachmentsCommand: MigrateWorkflowSendEmailAttachmentsCommand,
// 1.19 Commands
protected readonly backfillSystemFieldsIsSystemCommand: BackfillSystemFieldsIsSystemCommand,
protected readonly addMissingSystemFieldsToStandardObjectsCommand: AddMissingSystemFieldsToStandardObjectsCommand,
@@ -132,34 +92,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
coreMigrationRunnerService,
);
// Note: Required empty commands array to allow retrieving previous version
const commands_1160: VersionCommands = [];
const commands_1170: VersionCommands = [
this.migrateAttachmentToMorphRelationsCommand,
this.migrateNoteTargetToMorphRelationsCommand,
this.migrateTaskTargetToMorphRelationsCommand,
this.identifyWebhookMetadataCommand,
this
.makeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
this.deleteFileRecordsAndUpdateTableCommand,
this.backfillApplicationPackageFilesCommand,
this.fixMorphRelationFieldNamesCommand,
];
const commands_1180: VersionCommands = [
this.deleteOrphanFavoritesCommand,
this.migrateFavoritesToNavigationMenuItemsCommand,
this.migratePersonAvatarFilesCommand,
this.migrateAttachmentFilesCommand,
this.migrateActivityRichTextAttachmentFileIdsCommand,
this.migrateWorkspacePicturesCommand,
this.migrateWorkflowSendEmailAttachmentsCommand,
this.backfillFileSizeAndMimeTypeCommand,
this.backfillMessageChannelThrottleRetryAfterCommand,
this.backfillStandardViewsAndFieldMetadataCommand,
];
const commands_1190: VersionCommands = [
this.fixRoleAndAgentUniversalIdentifiersCommand,
this.backfillSystemFieldsIsSystemCommand,
@@ -193,9 +125,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
];
this.allCommands = {
'1.16.0': commands_1160,
'1.17.0': commands_1170,
'1.18.0': commands_1180,
'1.19.0': commands_1190,
'1.20.0': commands_1200,
};
@@ -1,10 +1,4 @@
export const UPGRADE_COMMAND_SUPPORTED_VERSIONS = [
'1.16.0',
'1.17.0',
'1.18.0',
'1.19.0',
'1.20.0',
] as const;
export const UPGRADE_COMMAND_SUPPORTED_VERSIONS = ['1.19.0', '1.20.0'] as const;
export type UpgradeCommandVersion =
(typeof UPGRADE_COMMAND_SUPPORTED_VERSIONS)[number];
@@ -4,8 +4,6 @@ import {
} from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
@@ -20,14 +18,12 @@ type FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs
{
createObjectInput: CreateObjectInput;
flatApplication: FlatApplication;
existingFeatureFlagsMap: FeatureFlagMap;
} & Pick<AllFlatEntityMaps, 'flatObjectMetadataMaps'>;
export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate =
({
createObjectInput: rawCreateObjectInput,
flatApplication,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
existingFeatureFlagsMap,
}: FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs): {
flatObjectMetadataToCreate: UniversalFlatObjectMetadata & { id: string };
relationTargetFlatFieldMetadataToCreate: UniversalFlatFieldMetadata[];
@@ -109,7 +105,6 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
existingFlatObjectMetadataMaps,
sourceFlatObjectMetadata: universalFlatObjectMetadataToCreate,
flatApplication,
existingFeatureFlagsMap,
});
const objectFlatFieldMetadatas: UniversalFlatFieldMetadata[] = [
@@ -406,7 +406,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
createObjectInput,
flatApplication: resolvedOwnerFlatApplication,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
existingFeatureFlagsMap,
});
const flatDefaultViewToCreate = this.computeFlatViewToCreate({
@@ -2,10 +2,9 @@ import {
STANDARD_OBJECTS,
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
} from 'twenty-shared/metadata';
import { FieldMetadataType, FeatureFlagKey } from 'twenty-shared/types';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
@@ -34,14 +33,7 @@ const morphIdByRelationObjectNameSingular = {
string | null
>;
// TODO: once we have finished migrating, we can delete custom code
// once we migrate timeline activity to morph relations, we can add it.
// another way to check if an object is migrated to morph relations is to check if the feature flag is enabled
const DEFAULT_MORPH_RELATIONS_OBJECTS_STANDARD_IDS =
[] as const satisfies (keyof typeof STANDARD_OBJECTS)[];
export type BuildDefaultRelationFieldsForCustomObjectArgs = {
existingFeatureFlagsMap: FeatureFlagMap;
existingFlatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
sourceFlatObjectMetadata: UniversalFlatObjectMetadata;
flatApplication: FlatApplication;
@@ -58,7 +50,6 @@ const EMPTY_SOURCE_AND_TARGET_FLAT_FIELD_METADATAS_RECORD: SourceAndTargetFlatFi
};
export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
existingFeatureFlagsMap,
existingFlatObjectMetadataMaps,
sourceFlatObjectMetadata,
flatApplication,
@@ -79,21 +70,11 @@ export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
const result =
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.reduce<SourceAndTargetFlatFieldMetadatasRecord>(
(sourceAndTargetFlatFieldMetadatasRecord, objectMetadataNameSingular) => {
const isObjectMigratedFromOlderReleases =
DEFAULT_MORPH_RELATIONS_OBJECTS_STANDARD_IDS.map(toString).includes(
objectMetadataNameSingular,
);
const isFeatureFlagEnabled =
objectMetadataNameSingular === 'timelineActivity' ||
(objectMetadataNameSingular === 'attachment' &&
existingFeatureFlagsMap[FeatureFlagKey.IS_ATTACHMENT_MIGRATED]) ||
(objectMetadataNameSingular === 'noteTarget' &&
existingFeatureFlagsMap[FeatureFlagKey.IS_NOTE_TARGET_MIGRATED]) ||
(objectMetadataNameSingular === 'taskTarget' &&
existingFeatureFlagsMap[FeatureFlagKey.IS_TASK_TARGET_MIGRATED]) ||
false;
const isObjectMigratedToMorphRelations =
isObjectMigratedFromOlderReleases || isFeatureFlagEnabled;
objectMetadataNameSingular === 'timelineActivity' ||
objectMetadataNameSingular === 'attachment' ||
objectMetadataNameSingular === 'noteTarget' ||
objectMetadataNameSingular === 'taskTarget';
const targetFlatObjectMetadataId =
objectIdByNameSingular[objectMetadataNameSingular];
@@ -239,9 +239,6 @@ describe('WorkspaceEntityManager', () => {
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_DASHBOARD_V2_ENABLED: false,
IS_ATTACHMENT_MIGRATED: false,
IS_NOTE_TARGET_MIGRATED: false,
IS_TASK_TARGET_MIGRATED: false,
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_COMMAND_MENU_ITEM_ENABLED: false,
@@ -50,21 +50,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED,
workspaceId: workspaceId,
@@ -1,7 +1,3 @@
import { FeatureFlagKey } from 'twenty-shared/types';
import { type FeatureFlagKey } from 'twenty-shared/types';
export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_ATTACHMENT_MIGRATED,
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
] as const satisfies FeatureFlagKey[];
export const DEFAULT_FEATURE_FLAGS = [] as const satisfies FeatureFlagKey[];
@@ -8,9 +8,6 @@ export enum FeatureFlagKey {
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED',
IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED',
IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',