diff --git a/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts b/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts index b58ae53f723..ece6d08aa6d 100644 --- a/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts +++ b/packages/twenty-eslint-rules/rules/rest-api-methods-should-be-guarded.ts @@ -52,11 +52,11 @@ export const rule = createRule<[], 'restApiMethodsShouldBeGuarded'>({ meta: { docs: { description: - 'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, FilePathGuard, or FilesFieldGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionsGuard or CustomPermissionGuard) to maintain our security model.', + 'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, FilePathGuard, FileByIdGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionsGuard or CustomPermissionGuard) to maintain our security model.', }, messages: { restApiMethodsShouldBeGuarded: - 'All REST API controller endpoints must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard/FilePathGuard/FileIdGuard/FilesFieldGuard/PublicEndpointGuard)) and permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases).', + 'All REST API controller endpoints must have authentication guards (@UseGuards(UserAuthGuard/WorkspaceAuthGuard/FilePathGuard/FileByIdGuard/PublicEndpointGuard)) and permission guards (@UseGuards(..., SettingsPermissionsGuard(PermissionFlagType.XXX)), CustomPermissionGuard for custom logic, or NoPermissionGuard for special cases).', }, schema: [], hasSuggestions: false, diff --git a/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts b/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts index bbb14c744c7..c04a784f22c 100644 --- a/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts +++ b/packages/twenty-eslint-rules/utils/typedTokenHelpers.ts @@ -42,7 +42,7 @@ export const typedTokenHelpers = { TSESTree.AST_NODE_TYPES.Identifier && decorator.expression.callee.name === 'UseGuards' ) { - // Check the arguments for UserAuthGuard, WorkspaceAuthGuard, PublicEndpoint, FilePathGuard, or FilesFieldGuard + // Check the arguments for UserAuthGuard, WorkspaceAuthGuard, PublicEndpoint, FilePathGuard or FileByIdGuard return decorator.expression.arguments.some((arg) => { if (arg.type === TSESTree.AST_NODE_TYPES.Identifier) { return ( @@ -50,7 +50,7 @@ export const typedTokenHelpers = { arg.name === 'WorkspaceAuthGuard' || arg.name === 'PublicEndpointGuard' || arg.name === 'FilePathGuard' || - arg.name === 'FilesFieldGuard' + arg.name === 'FileByIdGuard' ); } return false; diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7e6f5587544..bce2d699d65 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1482,6 +1482,7 @@ export enum FeatureFlagKey { IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED = 'IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED', IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED', IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED', + IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED', IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED', IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED', IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED', @@ -1492,6 +1493,7 @@ export enum FeatureFlagKey { 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_OTHER_FILE_MIGRATED = 'IS_OTHER_FILE_MIGRATED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED', IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED', @@ -1630,6 +1632,7 @@ export enum FileFolder { Attachment = 'Attachment', BuiltFrontComponent = 'BuiltFrontComponent', BuiltLogicFunction = 'BuiltLogicFunction', + CorePicture = 'CorePicture', Dependencies = 'Dependencies', File = 'File', FilesField = 'FilesField', @@ -1637,16 +1640,12 @@ export enum FileFolder { ProfilePicture = 'ProfilePicture', PublicAsset = 'PublicAsset', Source = 'Source', + Workflow = 'Workflow', WorkspaceLogo = 'WorkspaceLogo' } -export type FilesConfiguration = { - __typename?: 'FilesConfiguration'; - configurationType: WidgetConfigurationType; -}; - -export type FilesFieldFile = { - __typename?: 'FilesFieldFile'; +export type FileWithSignedUrl = { + __typename?: 'FileWithSignedUrl'; createdAt: Scalars['DateTime']; id: Scalars['UUID']; path: Scalars['String']; @@ -1654,6 +1653,11 @@ export type FilesFieldFile = { url: Scalars['String']; }; +export type FilesConfiguration = { + __typename?: 'FilesConfiguration'; + configurationType: WidgetConfigurationType; +}; + export type FindAvailableSsoidpOutput = { __typename?: 'FindAvailableSSOIDPOutput'; id: Scalars['UUID']; @@ -2168,6 +2172,7 @@ export type Mutation = { createCoreViewSort: CoreViewSort; createDatabaseConfigVariable: Scalars['Boolean']; createEmailingDomain: EmailingDomain; + /** @deprecated Use specific file service instead */ createFile: File; createFrontComponent: FrontComponent; createManyCoreViewFieldGroups: Array; @@ -2203,6 +2208,7 @@ export type Mutation = { deleteCurrentWorkspace: Workspace; deleteDatabaseConfigVariable: Scalars['Boolean']; deleteEmailingDomain: Scalars['Boolean']; + /** @deprecated */ deleteFile: File; deleteFrontComponent: FrontComponent; deleteJobs: DeleteJobsResponse; @@ -2307,10 +2313,13 @@ export type Mutation = { uploadApplicationFile: File; /** @deprecated Use uploadFilesFieldFile instead */ uploadFile: SignedFile; - uploadFilesFieldFile: FilesFieldFile; + uploadFilesFieldFile: FileWithSignedUrl; uploadImage: SignedFile; - uploadWorkspaceLogo: SignedFile; - uploadWorkspaceMemberProfilePicture: SignedFile; + uploadWorkflowFile: FileWithSignedUrl; + uploadWorkspaceLogo: FileWithSignedUrl; + uploadWorkspaceLogoLegacy: SignedFile; + uploadWorkspaceMemberProfilePicture: FileWithSignedUrl; + uploadWorkspaceMemberProfilePictureLegacy: SignedFile; upsertFieldPermissions: Array; upsertObjectPermissions: Array; upsertPermissionFlags: Array; @@ -3130,16 +3139,31 @@ export type MutationUploadImageArgs = { }; +export type MutationUploadWorkflowFileArgs = { + file: Scalars['Upload']; +}; + + export type MutationUploadWorkspaceLogoArgs = { file: Scalars['Upload']; }; +export type MutationUploadWorkspaceLogoLegacyArgs = { + file: Scalars['Upload']; +}; + + export type MutationUploadWorkspaceMemberProfilePictureArgs = { file: Scalars['Upload']; }; +export type MutationUploadWorkspaceMemberProfilePictureLegacyArgs = { + file: Scalars['Upload']; +}; + + export type MutationUpsertFieldPermissionsArgs = { upsertFieldPermissionsInput: UpsertFieldPermissionsInput; }; @@ -5080,6 +5104,7 @@ export type Workspace = { isPublicInviteLinkEnabled: Scalars['Boolean']; isTwoFactorAuthenticationEnforced: Scalars['Boolean']; logo?: Maybe; + logoFileId?: Maybe; metadataVersion: Scalars['Float']; routerModel: Scalars['String']; smartModel: Scalars['String']; @@ -5734,7 +5759,14 @@ export type UploadFilesFieldFileMutationVariables = Exact<{ }>; -export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'FilesFieldFile', id: string, path: string, size: number, createdAt: string, url: string } }; +export type UploadFilesFieldFileMutation = { __typename?: 'Mutation', uploadFilesFieldFile: { __typename?: 'FileWithSignedUrl', id: string, path: string, size: number, createdAt: string, url: string } }; + +export type UploadWorkflowFileMutationVariables = Exact<{ + file: Scalars['Upload']; +}>; + + +export type UploadWorkflowFileMutation = { __typename?: 'Mutation', uploadWorkflowFile: { __typename?: 'FileWithSignedUrl', id: string, path: string, size: number, createdAt: string, url: string } }; export type FindManyFrontComponentsQueryVariables = Exact<{ [key: string]: never; }>; @@ -6221,7 +6253,14 @@ export type UploadWorkspaceMemberProfilePictureMutationVariables = Exact<{ }>; -export type UploadWorkspaceMemberProfilePictureMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePicture: { __typename?: 'SignedFile', path: string, token: string } }; +export type UploadWorkspaceMemberProfilePictureMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePicture: { __typename?: 'FileWithSignedUrl', url: string } }; + +export type UploadWorkspaceMemberProfilePictureLegacyMutationVariables = Exact<{ + file: Scalars['Upload']; +}>; + + +export type UploadWorkspaceMemberProfilePictureLegacyMutation = { __typename?: 'Mutation', uploadWorkspaceMemberProfilePictureLegacy: { __typename?: 'SignedFile', path: string, token: string } }; export type UpdateUserEmailMutationVariables = Exact<{ newEmail: Scalars['String']; @@ -6740,7 +6779,14 @@ export type UploadWorkspaceLogoMutationVariables = Exact<{ }>; -export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: { __typename?: 'SignedFile', path: string, token: string } }; +export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: { __typename?: 'FileWithSignedUrl', url: string } }; + +export type UploadWorkspaceLogoLegacyMutationVariables = Exact<{ + file: Scalars['Upload']; +}>; + + +export type UploadWorkspaceLogoLegacyMutation = { __typename?: 'Mutation', uploadWorkspaceLogoLegacy: { __typename?: 'SignedFile', path: string, token: string } }; export type CheckCustomDomainValidRecordsMutationVariables = Exact<{ [key: string]: never; }>; @@ -10440,6 +10486,43 @@ export function useUploadFilesFieldFileMutation(baseOptions?: Apollo.MutationHoo export type UploadFilesFieldFileMutationHookResult = ReturnType; export type UploadFilesFieldFileMutationResult = Apollo.MutationResult; export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions; +export const UploadWorkflowFileDocument = gql` + mutation UploadWorkflowFile($file: Upload!) { + uploadWorkflowFile(file: $file) { + id + path + size + createdAt + url + } +} + `; +export type UploadWorkflowFileMutationFn = Apollo.MutationFunction; + +/** + * __useUploadWorkflowFileMutation__ + * + * To run a mutation, you first call `useUploadWorkflowFileMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUploadWorkflowFileMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [uploadWorkflowFileMutation, { data, loading, error }] = useUploadWorkflowFileMutation({ + * variables: { + * file: // value for 'file' + * }, + * }); + */ +export function useUploadWorkflowFileMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UploadWorkflowFileDocument, options); + } +export type UploadWorkflowFileMutationHookResult = ReturnType; +export type UploadWorkflowFileMutationResult = Apollo.MutationResult; +export type UploadWorkflowFileMutationOptions = Apollo.BaseMutationOptions; export const FindManyFrontComponentsDocument = gql` query FindManyFrontComponents { frontComponents { @@ -13095,8 +13178,7 @@ export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptio export const UploadWorkspaceMemberProfilePictureDocument = gql` mutation UploadWorkspaceMemberProfilePicture($file: Upload!) { uploadWorkspaceMemberProfilePicture(file: $file) { - path - token + url } } `; @@ -13126,6 +13208,40 @@ export function useUploadWorkspaceMemberProfilePictureMutation(baseOptions?: Apo export type UploadWorkspaceMemberProfilePictureMutationHookResult = ReturnType; export type UploadWorkspaceMemberProfilePictureMutationResult = Apollo.MutationResult; export type UploadWorkspaceMemberProfilePictureMutationOptions = Apollo.BaseMutationOptions; +export const UploadWorkspaceMemberProfilePictureLegacyDocument = gql` + mutation UploadWorkspaceMemberProfilePictureLegacy($file: Upload!) { + uploadWorkspaceMemberProfilePictureLegacy(file: $file) { + path + token + } +} + `; +export type UploadWorkspaceMemberProfilePictureLegacyMutationFn = Apollo.MutationFunction; + +/** + * __useUploadWorkspaceMemberProfilePictureLegacyMutation__ + * + * To run a mutation, you first call `useUploadWorkspaceMemberProfilePictureLegacyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUploadWorkspaceMemberProfilePictureLegacyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [uploadWorkspaceMemberProfilePictureLegacyMutation, { data, loading, error }] = useUploadWorkspaceMemberProfilePictureLegacyMutation({ + * variables: { + * file: // value for 'file' + * }, + * }); + */ +export function useUploadWorkspaceMemberProfilePictureLegacyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UploadWorkspaceMemberProfilePictureLegacyDocument, options); + } +export type UploadWorkspaceMemberProfilePictureLegacyMutationHookResult = ReturnType; +export type UploadWorkspaceMemberProfilePictureLegacyMutationResult = Apollo.MutationResult; +export type UploadWorkspaceMemberProfilePictureLegacyMutationOptions = Apollo.BaseMutationOptions; export const UpdateUserEmailDocument = gql` mutation UpdateUserEmail($newEmail: String!, $verifyEmailRedirectPath: String) { updateUserEmail( @@ -15527,8 +15643,7 @@ export type UpdateWorkspaceMutationOptions = Apollo.BaseMutationOptions; export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult; export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions; +export const UploadWorkspaceLogoLegacyDocument = gql` + mutation UploadWorkspaceLogoLegacy($file: Upload!) { + uploadWorkspaceLogoLegacy(file: $file) { + path + token + } +} + `; +export type UploadWorkspaceLogoLegacyMutationFn = Apollo.MutationFunction; + +/** + * __useUploadWorkspaceLogoLegacyMutation__ + * + * To run a mutation, you first call `useUploadWorkspaceLogoLegacyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUploadWorkspaceLogoLegacyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [uploadWorkspaceLogoLegacyMutation, { data, loading, error }] = useUploadWorkspaceLogoLegacyMutation({ + * variables: { + * file: // value for 'file' + * }, + * }); + */ +export function useUploadWorkspaceLogoLegacyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UploadWorkspaceLogoLegacyDocument, options); + } +export type UploadWorkspaceLogoLegacyMutationHookResult = ReturnType; +export type UploadWorkspaceLogoLegacyMutationResult = Apollo.MutationResult; +export type UploadWorkspaceLogoLegacyMutationOptions = Apollo.BaseMutationOptions; export const CheckCustomDomainValidRecordsDocument = gql` mutation CheckCustomDomainValidRecords { checkCustomDomainValidRecords { diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx index e051f8f2b0f..3daf01b105c 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowAttachmentChip.tsx @@ -1,14 +1,14 @@ import { getFileType } from '@/activities/files/utils/getFileType'; -import { IconMapping } from '@/file/utils/fileIconMappings'; import { useFileCategoryColors } from '@/file/hooks/useFileCategoryColors'; -import { type WorkflowAttachmentType } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType'; +import { IconMapping } from '@/file/utils/fileIconMappings'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { type WorkflowAttachment } from 'twenty-shared/workflow'; import { AvatarChip } from 'twenty-ui/components'; import { IconX } from 'twenty-ui/display'; type WorkflowAttachmentChipProps = { - file: WorkflowAttachmentType; + file: WorkflowAttachment; onRemove: () => void; readonly?: boolean; }; diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx index 8237de576e1..a1e75ad70ec 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/WorkflowSendEmailAttachments.tsx @@ -1,18 +1,18 @@ -import { InputLabel } from '@/ui/input/components/InputLabel'; import { WorkflowAttachmentChip } from '@/advanced-text-editor/components/WorkflowAttachmentChip'; import { useUploadWorkflowFile } from '@/advanced-text-editor/hooks/useUploadWorkflowFile'; +import { InputLabel } from '@/ui/input/components/InputLabel'; -import { type WorkflowAttachmentType } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { type ChangeEvent, useRef } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { type WorkflowAttachment } from 'twenty-shared/workflow'; import { IconUpload } from 'twenty-ui/display'; -import { useTheme } from '@emotion/react'; type WorkflowSendEmailAttachmentsProps = { - files: WorkflowAttachmentType[]; - onChange: (files: WorkflowAttachmentType[]) => void; + files: WorkflowAttachment[]; + onChange: (files: WorkflowAttachment[]) => void; label?: string; }; @@ -92,9 +92,7 @@ export const WorkflowSendEmailAttachments = ({ filesToUpload.map((file) => uploadWorkflowFile(file)), ); - const successfulUploads = uploadedFiles.filter( - (file): file is WorkflowAttachmentType => file !== null, - ); + const successfulUploads = uploadedFiles.filter(isDefined); if (successfulUploads.length > 0) { onChange([...files, ...successfulUploads]); @@ -132,7 +130,7 @@ export const WorkflowSendEmailAttachments = ({ > {files.length > 0 ? ( - {files.map((file: WorkflowAttachmentType) => ( + {files.map((file: WorkflowAttachment) => ( { - const coreClient = useApolloCoreClient(); - const [createFile] = useCreateFileMutation({ client: coreClient }); + const isOtherFileMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_OTHER_FILE_MIGRATED, + ); + const [uploadWorkflowFileMutation] = useUploadWorkflowFileMutation(); + const apolloClient = useApolloClient(); + const [createFile] = useCreateFileMutation({ client: apolloClient }); const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); const uploadWorkflowFile = async ( file: File, - ): Promise => { + ): Promise => { try { if (file.size > MAX_ATTACHMENT_SIZE) { const fileName = file.name; @@ -36,28 +38,42 @@ export const useUploadWorkflowFile = () => { return null; } - const result = await createFile({ - variables: { file }, - }); + let workflowFile: WorkflowAttachment; + if (isOtherFileMigrated) { + const result = await uploadWorkflowFileMutation({ + variables: { file }, + }); + const uploadedFile = result?.data?.uploadWorkflowFile; + if (!isDefined(uploadedFile)) { + throw new Error('File upload failed'); + } + workflowFile = { + id: uploadedFile.id, + name: file.name, + size: uploadedFile.size, + type: extractFolderPathFilenameAndTypeOrThrow(uploadedFile.path).type, + createdAt: uploadedFile.createdAt, + }; + } else { + const result = await createFile({ + variables: { file }, + }); - const uploadedFile = result?.data?.createFile; + const uploadedFile = result?.data?.createFile; - if (!isDefined(uploadedFile)) { - throw new Error('File upload failed'); + if (!isDefined(uploadedFile)) { + throw new Error('File upload failed'); + } + + workflowFile = { + id: uploadedFile.id, + name: file.name, + size: uploadedFile.size, + type: extractFolderPathFilenameAndTypeOrThrow(uploadedFile.path).type, + createdAt: uploadedFile.createdAt, + }; } - const { type } = extractFolderPathFilenameAndTypeOrThrow( - uploadedFile.path, - ); - - const workflowFile: WorkflowFile = { - id: uploadedFile.id, - name: file.name, - size: uploadedFile.size, - type: type, - createdAt: uploadedFile.createdAt, - }; - const fileName = file.name; enqueueSuccessSnackBar({ message: t`File "${fileName}" uploaded successfully`, diff --git a/packages/twenty-front/src/modules/file/graphql/mutations/uploadWorkflowFile.ts b/packages/twenty-front/src/modules/file/graphql/mutations/uploadWorkflowFile.ts new file mode 100644 index 00000000000..c6e301edbf8 --- /dev/null +++ b/packages/twenty-front/src/modules/file/graphql/mutations/uploadWorkflowFile.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const UPLOAD_WORKFLOW_FILE = gql` + mutation UploadWorkflowFile($file: Upload!) { + uploadWorkflowFile(file: $file) { + id + path + size + createdAt + url + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts b/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts index 44f992bc782..073498c4592 100644 --- a/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts +++ b/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture.ts @@ -3,8 +3,7 @@ import { gql } from '@apollo/client'; export const UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE = gql` mutation UploadWorkspaceMemberProfilePicture($file: Upload!) { uploadWorkspaceMemberProfilePicture(file: $file) { - path - token + url } } `; diff --git a/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePictureLegacy.ts b/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePictureLegacy.ts new file mode 100644 index 00000000000..2ead74f73ef --- /dev/null +++ b/packages/twenty-front/src/modules/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePictureLegacy.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE_LEGACY = gql` + mutation UploadWorkspaceMemberProfilePictureLegacy($file: Upload!) { + uploadWorkspaceMemberProfilePictureLegacy(file: $file) { + path + token + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx b/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx index ffc8c994e58..1da381f3edb 100644 --- a/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx +++ b/packages/twenty-front/src/modules/settings/workspace-member/components/WorkspaceMemberPictureUploader.tsx @@ -5,12 +5,16 @@ import { useRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE } from '@/settings/members/graphql/mutations/uploadWorkspaceMemberProfilePicture'; import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ImageInput } from '@/ui/input/components/ImageInput'; -import { useMutation } from '@apollo/client'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { buildSignedPath, isDefined } from 'twenty-shared/utils'; +import { + FeatureFlagKey, + useUploadWorkspaceMemberProfilePictureLegacyMutation, + useUploadWorkspaceMemberProfilePictureMutation, +} from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; type WorkspaceMemberPictureUploaderProps = { @@ -26,6 +30,9 @@ export const WorkspaceMemberPictureUploader = ({ onAvatarUpdated, disabled = false, }: WorkspaceMemberPictureUploaderProps) => { + const isCorePictureMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + ); const { enqueueErrorSnackBar } = useSnackBar(); const [isUploading, setIsUploading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -36,7 +43,9 @@ export const WorkspaceMemberPictureUploader = ({ currentWorkspaceMemberState, ); - const [uploadPicture] = useMutation(UPLOAD_WORKSPACE_MEMBER_PROFILE_PICTURE); + const [uploadPicture] = useUploadWorkspaceMemberProfilePictureMutation(); + const [uploadPictureLegacy] = + useUploadWorkspaceMemberProfilePictureLegacyMutation(); const { updateOneRecord } = useUpdateOneRecord(); @@ -56,29 +65,54 @@ export const WorkspaceMemberPictureUploader = ({ setIsUploading(true); setErrorMessage(null); + let newAvatarUrl: string | null = null; try { - const { data } = await uploadPicture({ - variables: { file }, - context: { - fetchOptions: { - signal: controller.signal, + if (!isCorePictureMigrated) { + const { data } = await uploadPictureLegacy({ + variables: { file }, + context: { + fetchOptions: { + signal: controller.signal, + }, }, - }, - }); + }); - const signedFile = data?.uploadWorkspaceMemberProfilePicture; - if (!isDefined(signedFile)) { - throw new Error('Avatar upload failed'); + const signedFile = data?.uploadWorkspaceMemberProfilePictureLegacy; + if (!isDefined(signedFile)) { + throw new Error('Avatar upload failed'); + } + + await updateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + idToUpdate: workspaceMemberId, + updateOneRecordInput: { avatarUrl: signedFile.path }, + }); + + newAvatarUrl = buildSignedPath(signedFile); + } else { + const { data } = await uploadPicture({ + variables: { file }, + context: { + fetchOptions: { + signal: controller.signal, + }, + }, + }); + + const signedFile = data?.uploadWorkspaceMemberProfilePicture; + if (!isDefined(signedFile)) { + throw new Error('Avatar upload failed'); + } + + await updateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + idToUpdate: workspaceMemberId, + updateOneRecordInput: { avatarUrl: signedFile.url }, + }); + + newAvatarUrl = signedFile.url; } - await updateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkspaceMember, - idToUpdate: workspaceMemberId, - updateOneRecordInput: { avatarUrl: signedFile.path }, - }); - - const newAvatarUrl = buildSignedPath(signedFile); - if (isEditingSelf && isDefined(currentWorkspaceMember)) { setCurrentWorkspaceMember({ ...currentWorkspaceMember, diff --git a/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx b/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx index a8aa87392ab..9d4f0252180 100644 --- a/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx +++ b/packages/twenty-front/src/modules/settings/workspace/components/WorkspaceLogoUploader.tsx @@ -2,14 +2,21 @@ import { useRecoilState } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { ImageInput } from '@/ui/input/components/ImageInput'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { buildSignedPath } from 'twenty-shared/utils'; import { + FeatureFlagKey, useUpdateWorkspaceMutation, + useUploadWorkspaceLogoLegacyMutation, useUploadWorkspaceLogoMutation, } from '~/generated-metadata/graphql'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { buildSignedPath } from 'twenty-shared/utils'; export const WorkspaceLogoUploader = () => { + const isCorePictureMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + ); + const [uploadLogoLegacy] = useUploadWorkspaceLogoLegacyMutation(); const [uploadLogo] = useUploadWorkspaceLogoMutation(); const [updateWorkspace] = useUpdateWorkspaceMutation(); const [currentWorkspace, setCurrentWorkspace] = useRecoilState( @@ -23,17 +30,32 @@ export const WorkspaceLogoUploader = () => { if (!currentWorkspace?.id) { throw new Error('Workspace id not found'); } - await uploadLogo({ - variables: { - file, - }, - onCompleted: (data) => { - setCurrentWorkspace({ - ...currentWorkspace, - logo: buildSignedPath(data.uploadWorkspaceLogo), - }); - }, - }); + + if (isCorePictureMigrated) { + await uploadLogo({ + variables: { + file, + }, + onCompleted: (data) => { + setCurrentWorkspace({ + ...currentWorkspace, + logo: data.uploadWorkspaceLogo.url, + }); + }, + }); + } else { + await uploadLogoLegacy({ + variables: { + file, + }, + onCompleted: (data) => { + setCurrentWorkspace({ + ...currentWorkspace, + logo: buildSignedPath(data.uploadWorkspaceLogoLegacy), + }); + }, + }); + } }; const onRemove = async () => { diff --git a/packages/twenty-front/src/modules/workflow/types/EmailFormData.ts b/packages/twenty-front/src/modules/workflow/types/EmailFormData.ts new file mode 100644 index 00000000000..bcdaecd58fb --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/types/EmailFormData.ts @@ -0,0 +1,12 @@ +import { + type EmailRecipients, + type WorkflowAttachment, +} from 'twenty-shared/workflow'; + +export type EmailFormData = { + connectedAccountId: string; + recipients: Required; + subject: string; + body: string; + files: WorkflowAttachment[]; +}; diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 528b21eedbb..306ac064b67 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -120,7 +120,6 @@ export type WorkflowVersionStatus = | 'DEACTIVATED' | 'ARCHIVED'; -// Keep existing types that are not covered by schemas export type WorkflowVersion = { id: string; name: string; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction.ts b/packages/twenty-front/src/modules/workflow/types/WorkflowEmailAction.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction.ts rename to packages/twenty-front/src/modules/workflow/types/WorkflowEmailAction.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts index a69e6ee4e87..57a2aa7be8b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/generateWorkflowRunDiagram.test.ts @@ -2,8 +2,8 @@ import { type WorkflowStep, type WorkflowTrigger, } from '@/workflow/types/Workflow'; -import { FieldMetadataType } from 'twenty-shared/types'; import { StepStatus, type WorkflowRunStepInfos } from 'twenty-shared/workflow'; +import { FieldMetadataType } from 'twenty-shared/types'; import { getUuidV4Mock } from '~/testing/utils/getUuidV4Mock'; import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts index 7db5caf09fc..e70b8419fd4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowRunStepContext.ts @@ -1,12 +1,12 @@ import { type WorkflowRunFlow } from '@/workflow/types/Workflow'; -import { type WorkflowRunStepContext } from '@/workflow/workflow-steps/types/WorkflowRunStepContext'; -import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps'; -import { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory'; -import { isDefined } from 'twenty-shared/utils'; import { TRIGGER_STEP_ID, type WorkflowRunStepInfos, } from 'twenty-shared/workflow'; +import { type WorkflowRunStepContext } from '@/workflow/workflow-steps/types/WorkflowRunStepContext'; +import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps'; +import { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory'; +import { isDefined } from 'twenty-shared/utils'; export const getWorkflowRunStepContext = ({ stepId, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase.tsx index 0f8909688bd..9ca250a4678 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionEmailBase.tsx @@ -22,7 +22,6 @@ import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithC import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter'; -import { type WorkflowEmailAction } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction'; import { useEmailForm } from '@/workflow/workflow-steps/workflow-actions/hooks/useEmailForm'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useTheme } from '@emotion/react'; @@ -31,6 +30,7 @@ import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { type WorkflowEmailAction } from '@/workflow/types/WorkflowEmailAction'; import { Callout, IconPlus } from 'twenty-ui/display'; import { Button, type SelectOption } from 'twenty-ui/input'; import { MenuItem } from 'twenty-ui/navigation'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx index 278848e293d..19590d607e2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord.tsx @@ -1,4 +1,3 @@ -import { t } from '@lingui/core/macro'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition'; import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput'; @@ -14,6 +13,7 @@ import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-ac import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useTheme } from '@emotion/react'; +import { t } from '@lingui/core/macro'; import { useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts deleted file mode 100644 index 7f3452db8e1..00000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type WorkflowAttachmentType } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType'; -import { type EmailRecipients } from 'twenty-shared/workflow'; - -export type EmailFormData = { - connectedAccountId: string; - recipients: Required; - subject: string; - body: string; - files: WorkflowAttachmentType[]; -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts index 7341397c179..9e83d19eeb6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/useEmailForm.ts @@ -1,6 +1,6 @@ -import { type EmailFormData } from '@/workflow/workflow-steps/workflow-actions/email-action/types/EmailFormData'; -import { type WorkflowEmailAction } from '@/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowEmailAction'; import { useState } from 'react'; +import { type EmailFormData } from '@/workflow/types/EmailFormData'; +import { type WorkflowEmailAction } from '@/workflow/types/WorkflowEmailAction'; import { type JsonValue } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts index 9061c5c81f6..d944968ae1d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/http-request-action/hooks/useHttpRequestOutputSchema.ts @@ -1,8 +1,8 @@ import { type WorkflowHttpRequestAction } from '@/workflow/types/Workflow'; +import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow'; import { parseAndValidateVariableFriendlyStringifiedJson } from '@/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson'; import { isNonEmptyString } from '@sniptt/guards'; import { useState } from 'react'; -import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow'; import { convertOutputSchemaToJson } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/convertOutputSchemaToJson'; import { getHttpRequestOutputSchema } from '@/workflow/workflow-steps/workflow-actions/http-request-action/utils/getHttpRequestOutputSchema'; diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogo.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogo.ts index 9d30419470f..3b80ff9a21e 100644 --- a/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogo.ts +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogo.ts @@ -3,8 +3,7 @@ import { gql } from '@apollo/client'; export const UPLOAD_WORKSPACE_LOGO = gql` mutation UploadWorkspaceLogo($file: Upload!) { uploadWorkspaceLogo(file: $file) { - path - token + url } } `; diff --git a/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogoLegacy.ts b/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogoLegacy.ts new file mode 100644 index 00000000000..bfcad00089b --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/graphql/mutations/uploadWorkspaceLogoLegacy.ts @@ -0,0 +1,10 @@ +import { gql } from '@apollo/client'; + +export const UPLOAD_WORKSPACE_LOGO_LEGACY = gql` + mutation UploadWorkspaceLogoLegacy($file: Upload!) { + uploadWorkspaceLogoLegacy(file: $file) { + path + token + } + } +`; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts index e861b0eb854..5951e93075e 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command.ts @@ -18,7 +18,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; -import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +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'; @@ -48,7 +48,7 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus private readonly fileStorageService: FileStorageService, private readonly workspaceCacheService: WorkspaceCacheService, private readonly applicationService: ApplicationService, - private readonly filesFieldService: FilesFieldService, + private readonly fileUrlService: FileUrlService, @InjectDataSource() private readonly coreDataSource: DataSource, ) { @@ -237,7 +237,10 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus const props = (block.props as Record) || {}; const url = props.url as string | undefined; - return isDefined(url) && !isDefined(extractFileIdFromUrl(url)); + return ( + isDefined(url) && + !isDefined(extractFileIdFromUrl(url, FileFolder.FilesField)) + ); }); if (!needsMigration) { @@ -259,7 +262,7 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus if ( !isDefined(url) || - isDefined(extractFileIdFromUrl(url)) || + isDefined(extractFileIdFromUrl(url, FileFolder.FilesField)) || !url.includes('/files/attachment/') ) { enrichedBlocknote.push(block); @@ -292,9 +295,10 @@ export class MigrateActivityRichTextAttachmentFileIdsCommand extends ActiveOrSus ); hasChanges = true; - const signedUrl = this.filesFieldService.signFileUrl({ + const signedUrl = this.fileUrlService.signFileByIdUrl({ fileId, workspaceId, + fileFolder: FileFolder.FilesField, }); enrichedBlocknote.push({ diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command.ts new file mode 100644 index 00000000000..8420fe42b19 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command.ts @@ -0,0 +1,214 @@ +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/services/application.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { 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: + 'Migrate workflow send email attachments to FileFolder.Workflow and update payload paths', +}) +export class MigrateWorkflowSendEmailAttachmentsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger( + MigrateWorkflowSendEmailAttachmentsCommand.name, + ); + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly featureFlagService: FeatureFlagService, + private readonly fileStorageService: FileStorageService, + private readonly applicationService: ApplicationService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const isMigrated = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_OTHER_FILE_MIGRATED, + workspaceId, + ); + + 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( + 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, + }, + }, + ); + } + } + } + + if (!isDryRun) { + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_OTHER_FILE_MIGRATED], + workspaceId, + ); + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Completed workflow send email attachments migration for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts new file mode 100644 index 00000000000..5cf7244336b --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command.ts @@ -0,0 +1,327 @@ +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 { 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/services/application.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.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'; + +@Command({ + name: 'upgrade:1-18:migrate-workspace-pictures', + description: + 'Migrate workspace logos and workspace member avatars to file records', +}) +export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly featureFlagService: FeatureFlagService, + private readonly fileStorageService: FileStorageService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly applicationService: ApplicationService, + private readonly fileUrlService: FileUrlService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const isMigrated = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + workspaceId, + ); + + if (isMigrated) { + this.logger.log( + `Workspace pictures migration already completed for workspace ${workspaceId}, skipping`, + ); + + return; + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Starting workspace pictures migration for workspace ${workspaceId}`, + ); + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + const fileRepository = this.coreDataSource.getRepository(FileEntity); + + await this.migrateWorkspaceLogo({ + workspaceId, + isDryRun, + workspaceCustomFlatApplication, + fileRepository, + }); + + await this.migrateWorkspaceMemberAvatars({ + workspaceId, + isDryRun, + workspaceCustomFlatApplication, + fileRepository, + }); + + if (!isDryRun) { + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_CORE_PICTURE_MIGRATED], + workspaceId, + ); + } + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Completed workspace pictures migration for workspace ${workspaceId}`, + ); + } + + private async migrateWorkspaceLogo({ + workspaceId, + isDryRun, + workspaceCustomFlatApplication, + fileRepository, + }: { + workspaceId: string; + isDryRun: boolean; + workspaceCustomFlatApplication: FlatApplication; + fileRepository: Repository; + }): Promise { + 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; + } + + this.logger.log( + `Migrating workspace logo for workspace ${workspaceId}: ${workspace.logo}`, + ); + + try { + const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow( + workspace.logo, + ); + + 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: workspace.logo, + }, + 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} (${workspace.logo} -> ${newResourcePath})`, + ); + } catch (error) { + this.logger.error( + `Failed to migrate workspace logo for workspace ${workspaceId}: ${error.message}`, + ); + throw error; + } + } + + private async migrateWorkspaceMemberAvatars({ + workspaceId, + isDryRun, + workspaceCustomFlatApplication, + fileRepository, + }: { + workspaceId: string; + isDryRun: boolean; + workspaceCustomFlatApplication: FlatApplication; + fileRepository: Repository; + }): Promise { + const { flatObjectMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + ]); + + const workspaceMemberObjectMetadata = + findFlatEntityByUniversalIdentifier({ + 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( + 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; + } + } + + if (!isDryRun) { + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_CORE_PICTURE_MIGRATED], + workspaceId, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts index 1ffb08bb758..8b0418a13a9 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module.ts @@ -8,12 +8,13 @@ import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/co 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 { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; -import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module'; +import { FileModule } from 'src/engine/core-modules/file/file.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'; @@ -21,11 +22,12 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/ 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 { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.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: [ @@ -35,18 +37,18 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso PersonWorkspaceEntity, FileEntity, AttachmentWorkspaceEntity, + WorkspaceMemberWorkspaceEntity, ObjectMetadataEntity, FieldMetadataEntity, ]), DataSourceModule, FeatureFlagModule, - FileStorageModule.forRoot(), WorkspaceCacheModule, WorkspaceCacheStorageModule, WorkspaceMetadataVersionModule, FieldMetadataModule, ApplicationModule, - FilesFieldModule, + FileModule, UserWorkspaceModule, WorkspaceMigrationModule, ], @@ -55,18 +57,22 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso MigrateFavoritesToNavigationMenuItemsCommand, MigrateAttachmentFilesCommand, BackfillFileSizeAndMimeTypeCommand, + MigrateWorkspacePicturesCommand, MigrateActivityRichTextAttachmentFileIdsCommand, BackfillMessageChannelThrottleRetryAfterCommand, BackfillStandardViewsAndFieldMetadataCommand, + MigrateWorkflowSendEmailAttachmentsCommand, ], exports: [ MigratePersonAvatarFilesCommand, MigrateFavoritesToNavigationMenuItemsCommand, MigrateAttachmentFilesCommand, - BackfillFileSizeAndMimeTypeCommand, MigrateActivityRichTextAttachmentFileIdsCommand, BackfillMessageChannelThrottleRetryAfterCommand, BackfillStandardViewsAndFieldMetadataCommand, + MigrateWorkspacePicturesCommand, + BackfillFileSizeAndMimeTypeCommand, + MigrateWorkflowSendEmailAttachmentsCommand, ], }) export class V1_18_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index d340c6aaf8b..33d535f35fb 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -25,6 +25,8 @@ import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/co 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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; @@ -63,6 +65,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly migrateActivityRichTextAttachmentFileIdsCommand: MigrateActivityRichTextAttachmentFileIdsCommand, protected readonly backfillMessageChannelThrottleRetryAfterCommand: BackfillMessageChannelThrottleRetryAfterCommand, protected readonly backfillStandardViewsAndFieldMetadataCommand: BackfillStandardViewsAndFieldMetadataCommand, + protected readonly migrateWorkspacePicturesCommand: MigrateWorkspacePicturesCommand, + protected readonly migrateWorkflowSendEmailAttachmentsCommand: MigrateWorkflowSendEmailAttachmentsCommand, ) { super( workspaceRepository, @@ -92,6 +96,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.migratePersonAvatarFilesCommand, this.migrateAttachmentFilesCommand, this.migrateActivityRichTextAttachmentFileIdsCommand, + this.migrateWorkspacePicturesCommand, + this.migrateWorkflowSendEmailAttachmentsCommand, this.backfillFileSizeAndMimeTypeCommand, this.backfillMessageChannelThrottleRetryAfterCommand, this.backfillStandardViewsAndFieldMetadataCommand, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1771323022170-add-logo-file-id-column-on-workspace-table.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1771323022170-add-logo-file-id-column-on-workspace-table.ts new file mode 100644 index 00000000000..ff1ad9d0c9e --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1771323022170-add-logo-file-id-column-on-workspace-table.ts @@ -0,0 +1,31 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddLogoFileIdColumnOnWorkspaceTable1771323022170 + implements MigrationInterface +{ + name = 'AddLogoFileIdColumnOnWorkspaceTable1771323022170'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "logoFileId" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "UQ_282123b2f32e927b6003311e33a" UNIQUE ("logoFileId")`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "FK_282123b2f32e927b6003311e33a" FOREIGN KEY ("logoFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "FK_282123b2f32e927b6003311e33a"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT "UQ_282123b2f32e927b6003311e33a"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "logoFileId"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts index 458b2e14380..d0f22c019ff 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/common-result-getters.service.ts @@ -16,7 +16,7 @@ import { AttachmentQueryResultGetterHandler } from 'src/engine/api/graphql/works import { PersonQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/person-query-result-getter.handler'; import { WorkspaceMemberQueryResultGetterHandler } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; @@ -42,7 +42,7 @@ export class CommonResultGettersService { constructor( private readonly fileService: FileService, - private readonly filesFieldService: FilesFieldService, + private readonly fileUrlService: FileUrlService, private readonly featureFlagService: FeatureFlagService, ) { this.initializeObjectHandlers(); @@ -55,7 +55,11 @@ export class CommonResultGettersService { ['person', new PersonQueryResultGetterHandler(this.fileService)], [ 'workspaceMember', - new WorkspaceMemberQueryResultGetterHandler(this.fileService), + new WorkspaceMemberQueryResultGetterHandler( + this.fileService, + this.featureFlagService, + this.fileUrlService, + ), ], ]); } @@ -67,13 +71,13 @@ export class CommonResultGettersService { >([ [ FieldMetadataType.FILES, - new FilesFieldQueryResultGetterHandler(this.filesFieldService), + new FilesFieldQueryResultGetterHandler(this.fileUrlService), ], [ FieldMetadataType.RICH_TEXT_V2, new RichTextV2FieldQueryResultGetterHandler( this.fileService, - this.filesFieldService, + this.fileUrlService, this.featureFlagService, ), ], diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts index 6e01e407b4b..3c740026ebc 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/__tests__/rich-text-v2-field-query-result-getter.handler.spec.ts @@ -2,7 +2,7 @@ import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; import { RichTextV2FieldQueryResultGetterHandler } from 'src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler'; import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { type FileService } from 'src/engine/core-modules/file/services/file.service'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -24,9 +24,9 @@ const mockFileService = { signFileUrl: jest.fn().mockReturnValue('signed-path'), } as unknown as FileService; -const mockFilesFieldService = { +const mockFileUrlService = { signFileUrl: jest.fn().mockReturnValue('signed-path'), -} as unknown as FilesFieldService; +} as unknown as FileUrlService; const mockFeatureFlagService = { isFeatureEnabled: jest.fn().mockReturnValue(true), @@ -39,7 +39,7 @@ describe('RichTextV2FieldQueryResultGetterHandler', () => { process.env.SERVER_URL = 'https://my-domain.twenty.com'; handler = new RichTextV2FieldQueryResultGetterHandler( mockFileService, - mockFilesFieldService, + mockFileUrlService, mockFeatureFlagService, ); }); diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts index 96585897b67..dc4eeeeebe6 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/files-field-query-result-getter.handler.ts @@ -1,16 +1,20 @@ -import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; +import { + FieldMetadataType, + FileFolder, + type ObjectRecord, +} from 'twenty-shared/types'; import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface'; import { isFileOutputArray } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.guard'; import type { SignedFileOutput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type'; -import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { type FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; export class FilesFieldQueryResultGetterHandler implements QueryResultGetterHandlerInterface { - constructor(private readonly filesFieldService: FilesFieldService) {} + constructor(private readonly fileUrlService: FileUrlService) {} async handle( record: ObjectRecord, @@ -35,9 +39,10 @@ export class FilesFieldQueryResultGetterHandler const signedFilesFieldValue: SignedFileOutput[] = []; for (const file of filesFieldValue) { - const url = this.filesFieldService.signFileUrl({ + const url = this.fileUrlService.signFileByIdUrl({ fileId: file.fileId, workspaceId, + fileFolder: FileFolder.FilesField, }); signedFilesFieldValue.push({ diff --git a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts index 7568efdfff2..2ad819bd3db 100644 --- a/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/common/common-result-getters/handlers/field-handlers/rich-text-v2-field-query-result-getter.handler.ts @@ -1,11 +1,15 @@ -import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; +import { + FieldMetadataType, + FileFolder, + type ObjectRecord, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { type FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { type 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 { type FileService } from 'src/engine/core-modules/file/services/file.service'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -34,7 +38,7 @@ export class RichTextV2FieldQueryResultGetterHandler { constructor( private readonly fileService: FileService, - private readonly filesFieldService: FilesFieldService, + private readonly fileUrlService: FileUrlService, private readonly featureFlagService: FeatureFlagService, ) {} @@ -92,15 +96,19 @@ export class RichTextV2FieldQueryResultGetterHandler ): RichTextBlock[] => { return blocknoteBlocks.map((block: RichTextBlock) => { if (isFilesFieldMigrated && isDefined(block.props?.url)) { - const fileIdFromUrl = extractFileIdFromUrl(block.props.url); + const fileIdFromUrl = extractFileIdFromUrl( + block.props.url, + FileFolder.FilesField, + ); if (!isDefined(fileIdFromUrl)) { return block; } - const url = this.filesFieldService.signFileUrl({ + const url = this.fileUrlService.signFileByIdUrl({ fileId: fileIdFromUrl, workspaceId, + fileFolder: FileFolder.FilesField, }); return { diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts index 2f32f15d709..3109f7b91d2 100644 --- a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts +++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts @@ -13,7 +13,6 @@ import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-que import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; -import { FilesFieldModule } from 'src/engine/core-modules/file/files-field/files-field.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module'; @@ -35,7 +34,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache UserRoleModule, ApiKeyModule, FileModule, - FilesFieldModule, ViewModule, ViewFilterModule, ViewFilterGroupModule, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts index cdc925ff21b..1779f7a7aff 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/handlers/workspace-member-query-result-getter.handler.ts @@ -1,12 +1,23 @@ +import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + import { type QueryResultGetterHandlerInterface } from 'src/engine/api/graphql/workspace-query-runner/factories/query-result-getters/interfaces/query-result-getter-handler.interface'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { type 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 { type FileService } from 'src/engine/core-modules/file/services/file.service'; import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; export class WorkspaceMemberQueryResultGetterHandler implements QueryResultGetterHandlerInterface { - constructor(private readonly fileService: FileService) {} + constructor( + private readonly fileService: FileService, + private readonly featureFlagService: FeatureFlagService, + private readonly fileUrlService: FileUrlService, + ) {} async handle( workspaceMember: WorkspaceMemberWorkspaceEntity, @@ -16,6 +27,33 @@ export class WorkspaceMemberQueryResultGetterHandler return workspaceMember; } + if ( + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + workspaceId, + ) + ) { + const fileId = extractFileIdFromUrl( + workspaceMember.avatarUrl, + FileFolder.CorePicture, + ); + + if (!isDefined(fileId)) { + return workspaceMember; + } + + const signedUrl = this.fileUrlService.signFileByIdUrl({ + fileId, + workspaceId, + fileFolder: FileFolder.CorePicture, + }); + + return { + ...workspaceMember, + avatarUrl: signedUrl, + }; + } + const signedPath = this.fileService.signFileUrl({ url: workspaceMember.avatarUrl, workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 8212bff833c..ad85a87733d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -504,13 +504,14 @@ export class SignInUpService { workspaceToCreate, ); - await this.applicationService.createWorkspaceCustomApplication( - { - workspaceId, - applicationId: workspaceCustomApplicationId, - }, - queryRunner, - ); + const customApplication = + await this.applicationService.createWorkspaceCustomApplication( + { + workspaceId, + applicationId: workspaceCustomApplicationId, + }, + queryRunner, + ); const isExistingUser = userData.type === 'existingUser'; const user = isExistingUser @@ -532,6 +533,7 @@ export class SignInUpService { pictureUrl: isExistingUser ? undefined : userData.newUserWithPicture.picture, + applicationUniversalIdentifier: customApplication.universalIdentifier, }, queryRunner, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index e91bf1defb0..7a2b3406fa3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -20,7 +20,7 @@ import { type ApiKeyTokenJwtPayload, ApplicationAccessTokenJwtPayload, type AuthContext, - type FileTokenJwtPayload, + FileTokenJwtPayloadLegacy, type JwtPayload, JwtTokenTypeEnum, type WorkspaceAgnosticTokenJwtPayload, @@ -55,7 +55,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => { try { const decodedToken = jwtWrapperService.decode< - | FileTokenJwtPayload + | FileTokenJwtPayloadLegacy | AccessTokenJwtPayload | WorkspaceAgnosticTokenJwtPayload >(rawJwtToken); diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index a8a04a7922b..c73621f1e0d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -48,7 +48,7 @@ type CommonPropertiesJwtPayload = { sub: string; }; -export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { +export type FileTokenJwtPayloadLegacy = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.FILE; workspaceId: string; filename: string; @@ -58,7 +58,7 @@ export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { personId?: string; }; -export type FilesFieldTokenJwtPayload = CommonPropertiesJwtPayload & { +export type FileTokenJwtPayload = CommonPropertiesJwtPayload & { type: JwtTokenTypeEnum.FILE; workspaceId: string; fileId: string; @@ -145,5 +145,5 @@ export type JwtPayload = | TransientTokenJwtPayload | RefreshTokenJwtPayload | FileTokenJwtPayload - | FilesFieldTokenJwtPayload + | FileTokenJwtPayloadLegacy | PostgresProxyTokenJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 215a1e6ee13..3d1e8829884 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -13,6 +13,8 @@ export enum FeatureFlagKey { IS_NOTE_TARGET_MIGRATED = 'IS_NOTE_TARGET_MIGRATED', IS_TASK_TARGET_MIGRATED = 'IS_TASK_TARGET_MIGRATED', IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED', + IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED', + IS_OTHER_FILE_MIGRATED = 'IS_OTHER_FILE_MIGRATED', IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED = 'IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED', IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED', IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED', diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/controllers/files-field.controller.ts b/packages/twenty-server/src/engine/core-modules/file/controllers/file-by-id.controller.ts similarity index 74% rename from packages/twenty-server/src/engine/core-modules/file/files-field/controllers/files-field.controller.ts rename to packages/twenty-server/src/engine/core-modules/file/controllers/file-by-id.controller.ts index f6e51f6692a..cf0cd23aa21 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/controllers/files-field.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/file/controllers/file-by-id.controller.ts @@ -19,30 +19,35 @@ import { FileException, FileExceptionCode, } from 'src/engine/core-modules/file/file.exception'; -import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; -import { FilesFieldGuard } from 'src/engine/core-modules/file/files-field/guards/files-field.guard'; import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter'; +import { + FileByIdGuard, + SupportedFileFolder, +} from 'src/engine/core-modules/file/guards/file-by-id.guard'; +import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; -@Controller('files-field') +@Controller('file') @UseFilters(FileApiExceptionFilter) -export class FilesFieldController { - constructor(private readonly filesFieldService: FilesFieldService) {} +export class FileByIdController { + constructor(private readonly fileService: FileService) {} - @Get(':id') - @UseGuards(FilesFieldGuard, NoPermissionGuard) + @Get(':fileFolder/:id') + @UseGuards(FileByIdGuard, NoPermissionGuard) async getFileById( @Res() res: Response, @Req() req: Request, + @Param('fileFolder') fileFolder: SupportedFileFolder, @Param('id') fileId: string, ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const workspaceId = (req as any)?.workspaceId; try { - const fileStream = await this.filesFieldService.getFileStream({ + const fileStream = await this.fileService.getFileStreamById({ fileId, workspaceId, + fileFolder, }); fileStream.on('error', () => { diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts b/packages/twenty-server/src/engine/core-modules/file/dtos/file-with-sign-url.dto.ts similarity index 83% rename from packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts rename to packages/twenty-server/src/engine/core-modules/file/dtos/file-with-sign-url.dto.ts index b176ac97699..6b8fd9b1e20 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/dtos/files-field-file.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/file/dtos/file-with-sign-url.dto.ts @@ -2,8 +2,8 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -@ObjectType('FilesFieldFile') -export class FilesFieldFileDTO { +@ObjectType('FileWithSignedUrl') +export class FileWithSignedUrlDto { @Field(() => UUIDScalarType) id: string; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts new file mode 100644 index 00000000000..fc545afac5c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.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 { FileCorePictureResolver } from 'src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; +import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; + +@Module({ + imports: [ + JwtModule, + TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]), + PermissionsModule, + FileStorageModule, + ApplicationModule, + FileUrlModule, + SecureHttpClientModule, + ], + providers: [FileCorePictureService, FileCorePictureResolver], + exports: [FileCorePictureService], +}) +export class FileCorePictureModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts new file mode 100644 index 00000000000..3e4bdf0545e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver.ts @@ -0,0 +1,66 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Mutation } from '@nestjs/graphql'; + +import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { PermissionFlagType } from 'twenty-shared/constants'; + +import type { FileUpload } from 'graphql-upload/processRequest.mjs'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; +import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; + +@UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) +@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter) +@MetadataResolver() +export class FileCorePictureResolver { + constructor( + private readonly fileCorePictureService: FileCorePictureService, + ) {} + + @Mutation(() => FileWithSignedUrlDto) + @UseGuards( + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.WORKSPACE), + ) + async uploadWorkspaceLogo( + @AuthWorkspace() workspace: WorkspaceEntity, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename }: FileUpload, + ): Promise { + const buffer = await streamToBuffer(createReadStream()); + + return await this.fileCorePictureService.uploadWorkspacePicture({ + file: buffer, + filename, + workspace, + }); + } + + @Mutation(() => FileWithSignedUrlDto) + @UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard) + async uploadWorkspaceMemberProfilePicture( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename }: FileUpload, + ): Promise { + const buffer = await streamToBuffer(createReadStream()); + + return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture( + { + file: buffer, + filename, + workspaceId, + }, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts new file mode 100644 index 00000000000..7c3995404eb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts @@ -0,0 +1,259 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { buffer as streamToBuffer } from 'node:stream/consumers'; + +import { isNonEmptyString } from '@sniptt/guards'; +import FileType from 'file-type'; +import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { Like, type QueryRunner, Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +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 { 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 { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; +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 { getImageBufferFromUrl } from 'src/utils/image'; + +@Injectable() +export class FileCorePictureService { + constructor( + private readonly fileStorageService: FileStorageService, + private readonly applicationService: ApplicationService, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + private readonly fileUrlService: FileUrlService, + private readonly secureHttpClientService: SecureHttpClientService, + ) {} + + private async uploadCorePicture({ + file, + filename, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }: { + file: Buffer; + filename: string; + workspaceId: string; + applicationUniversalIdentifier?: string; + queryRunner?: QueryRunner; + }): Promise { + const { mimeType, ext } = await extractFileInfo({ file, filename }); + const sanitizedFile = sanitizeFile({ file, ext, mimeType }); + + const fileId = v4(); + const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`; + + const universalIdentifier = + applicationUniversalIdentifier ?? + ( + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ) + ).workspaceCustomFlatApplication.universalIdentifier; + + const savedFile = await this.fileStorageService.writeFile({ + sourceFile: sanitizedFile, + resourcePath: `${FileFolder.CorePicture}/${finalName}`, + mimeType, + fileFolder: FileFolder.CorePicture, + applicationUniversalIdentifier: universalIdentifier, + workspaceId, + fileId, + settings: { + isTemporaryFile: false, + toDelete: false, + }, + queryRunner, + }); + + return savedFile; + } + + async uploadWorkspacePicture({ + file, + filename, + workspace, + }: { + file: Buffer; + filename: string; + workspace: WorkspaceEntity; + }): Promise { + const savedFile = await this.uploadCorePicture({ + file, + filename, + workspaceId: workspace.id, + }); + + await this.workspaceRepository.update(workspace.id, { + logoFileId: savedFile.id, + }); + + if (isDefined(workspace.logoFileId)) { + await this.deleteCorePicture({ + fileId: workspace.logoFileId, + workspaceId: workspace.id, + }); + } + + const url = this.fileUrlService.signFileByIdUrl({ + fileId: savedFile.id, + fileFolder: FileFolder.CorePicture, + workspaceId: workspace.id, + }); + + return { + ...savedFile, + url, + }; + } + + async uploadWorkspaceMemberProfilePicture({ + file, + filename, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }: { + file: Buffer; + filename: string; + workspaceId: string; + applicationUniversalIdentifier?: string; + queryRunner?: QueryRunner; + }): Promise { + const savedFile = await this.uploadCorePicture({ + file, + filename, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }); + + const url = this.fileUrlService.signFileByIdUrl({ + fileId: savedFile.id, + workspaceId, + fileFolder: FileFolder.CorePicture, + }); + + return { + ...savedFile, + url, + }; + } + + async deleteCorePicture({ + fileId, + workspaceId, + }: { + fileId: string; + workspaceId: string; + }): Promise { + const file = await this.fileRepository.findOneOrFail({ + where: { + id: fileId, + path: Like(`${FileFolder.CorePicture}/%`), + workspaceId, + }, + }); + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + await this.fileStorageService.delete({ + workspaceId, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + fileFolder: FileFolder.CorePicture, + resourcePath: removeFileFolderFromFileEntityPath(file.path), + }); + } + + async uploadWorkspaceMemberProfilePictureFromUrl({ + imageUrl, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }: { + imageUrl: string; + workspaceId: string; + applicationUniversalIdentifier?: string; + queryRunner?: QueryRunner; + }): Promise { + const httpClient = this.secureHttpClientService.getHttpClient(); + const buffer = await getImageBufferFromUrl(imageUrl, httpClient); + + const type = await FileType.fromBuffer(buffer); + + if (!isDefined(type) || !type.mime.startsWith('image/')) { + return; + } + + return this.uploadWorkspaceMemberProfilePicture({ + file: buffer, + filename: `avatar.${type.ext}`, + workspaceId, + applicationUniversalIdentifier, + queryRunner, + }); + } + + async copyWorkspaceMemberProfilePicture({ + sourceWorkspaceId, + sourceFileId, + targetWorkspaceId, + targetApplicationUniversalIdentifier, + queryRunner, + }: { + sourceWorkspaceId: string; + sourceFileId: string; + targetWorkspaceId: string; + targetApplicationUniversalIdentifier?: string; + queryRunner?: QueryRunner; + }): Promise { + const sourceFile = await this.fileRepository.findOneOrFail({ + where: { + id: sourceFileId, + workspaceId: sourceWorkspaceId, + path: Like(`${FileFolder.CorePicture}/%`), + }, + }); + + const { workspaceCustomFlatApplication: sourceApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId: sourceWorkspaceId, + }, + ); + + const fileStream = await this.fileStorageService.readFile({ + workspaceId: sourceWorkspaceId, + applicationUniversalIdentifier: sourceApplication.universalIdentifier, + fileFolder: FileFolder.CorePicture, + resourcePath: removeFileFolderFromFileEntityPath(sourceFile.path), + }); + + const filename = sourceFile.path.split('/').pop() ?? ''; + + return this.uploadWorkspaceMemberProfilePicture({ + file: await streamToBuffer(fileStream), + filename, + workspaceId: targetWorkspaceId, + applicationUniversalIdentifier: targetApplicationUniversalIdentifier, + queryRunner, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.module.ts new file mode 100644 index 00000000000..8472c2b4ca0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; + +import { FileUrlService } from './file-url.service'; + +@Module({ + imports: [JwtModule], + providers: [FileUrlService], + exports: [FileUrlService], +}) +export class FileUrlModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts new file mode 100644 index 00000000000..bde9007f7b1 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-url/file-url.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; + +import { + FileTokenJwtPayload, + JwtTokenTypeEnum, +} from 'src/engine/core-modules/auth/types/auth-context.type'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +@Injectable() +export class FileUrlService { + constructor( + private readonly jwtWrapperService: JwtWrapperService, + private readonly twentyConfigService: TwentyConfigService, + ) {} + + signFileByIdUrl({ + fileId, + workspaceId, + fileFolder, + }: { + fileId: string; + workspaceId: string; + fileFolder: FileFolder; + }): string { + const fileTokenExpiresIn = this.twentyConfigService.get( + 'FILE_TOKEN_EXPIRES_IN', + ); + + const payload: FileTokenJwtPayload = { + workspaceId, + fileId, + sub: workspaceId, + type: JwtTokenTypeEnum.FILE, + }; + + const secret = this.jwtWrapperService.generateAppSecret( + payload.type, + workspaceId, + ); + + const token = this.jwtWrapperService.sign(payload, { + secret, + expiresIn: fileTokenExpiresIn, + }); + + const serverUrl = this.twentyConfigService.get('SERVER_URL'); + + return `${serverUrl}/file/${fileFolder}/${fileId}?token=${token}`; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-workflow/file-workflow.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-workflow/file-workflow.module.ts new file mode 100644 index 00000000000..a3683e0a580 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-workflow/file-workflow.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module'; +import { FileWorkflowResolver } from 'src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver'; +import { FileWorkflowService } from 'src/engine/core-modules/file/file-workflow/services/file-workflow.service'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; + +@Module({ + imports: [FileUrlModule, ApplicationModule, PermissionsModule], + providers: [FileWorkflowService, FileWorkflowResolver], + exports: [FileWorkflowService], +}) +export class FileWorkflowModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver.ts new file mode 100644 index 00000000000..97c45554be3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-workflow/resolvers/file-workflow.resolver.ts @@ -0,0 +1,44 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Mutation } from '@nestjs/graphql'; + +import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { PermissionFlagType } from 'twenty-shared/constants'; + +import type { FileUpload } from 'graphql-upload/processRequest.mjs'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FileWorkflowService } from 'src/engine/core-modules/file/file-workflow/services/file-workflow.service'; +import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { streamToBuffer } from 'src/utils/stream-to-buffer'; + +@UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) +@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter) +@MetadataResolver() +export class FileWorkflowResolver { + constructor(private readonly fileWorkflowService: FileWorkflowService) {} + + @Mutation(() => FileWithSignedUrlDto) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + async uploadWorkflowFile( + @AuthWorkspace() + { id: workspaceId }: WorkspaceEntity, + @Args({ name: 'file', type: () => GraphQLUpload }) + { createReadStream, filename }: FileUpload, + ): Promise { + const stream = createReadStream(); + const buffer = await streamToBuffer(stream); + + return await this.fileWorkflowService.uploadFile({ + file: buffer, + filename, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts new file mode 100644 index 00000000000..e837355865a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { FileFolder } from 'twenty-shared/types'; +import { v4 } from 'uuid'; + +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; +import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils'; +import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; + +@Injectable() +export class FileWorkflowService { + constructor( + private readonly fileStorageService: FileStorageService, + private readonly applicationService: ApplicationService, + private readonly fileUrlService: FileUrlService, + ) {} + + async uploadFile({ + file, + filename, + workspaceId, + }: { + file: Buffer; + filename: string; + workspaceId: string; + }): Promise { + const { mimeType, ext } = await extractFileInfo({ + file, + filename, + }); + + const sanitizedFile = sanitizeFile({ file, ext, mimeType }); + + const fileId = v4(); + const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`; + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + const savedFile = await this.fileStorageService.writeFile({ + sourceFile: sanitizedFile, + resourcePath: name, + mimeType, + fileFolder: FileFolder.Workflow, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + workspaceId, + fileId, + settings: { + isTemporaryFile: true, + toDelete: false, + }, + }); + + return { + ...savedFile, + url: this.fileUrlService.signFileByIdUrl({ + fileId, + workspaceId, + fileFolder: FileFolder.Workflow, + }), + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file.exception.ts b/packages/twenty-server/src/engine/core-modules/file/file.exception.ts index bc885c1c192..7ee7de51d76 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.exception.ts @@ -11,6 +11,8 @@ import { export const FileExceptionCode = appendCommonExceptionCode({ UNAUTHENTICATED: 'UNAUTHENTICATED', FILE_NOT_FOUND: 'FILE_NOT_FOUND', + INVALID_FILE_FOLDER: 'INVALID_FILE_FOLDER', + TEMPORARY_FILE_NOT_ALLOWED: 'TEMPORARY_FILE_NOT_ALLOWED', } as const); const getFileExceptionUserFriendlyMessage = ( @@ -21,6 +23,10 @@ const getFileExceptionUserFriendlyMessage = ( return msg`Authentication is required.`; case FileExceptionCode.FILE_NOT_FOUND: return msg`File not found.`; + case FileExceptionCode.INVALID_FILE_FOLDER: + return msg`Invalid file folder.`; + case FileExceptionCode.TEMPORARY_FILE_NOT_ALLOWED: + return msg`Temporary file cannot be downloaded.`; case FileExceptionCode.INTERNAL_SERVER_ERROR: return STANDARD_ERROR_MESSAGE; default: diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts index 1fb3646c65d..454dc96e894 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts @@ -13,10 +13,15 @@ import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-clie import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { FileByIdController } from './controllers/file-by-id.controller'; import { FileController } from './controllers/file.controller'; import { FileEntity } from './entities/file.entity'; +import { FileCorePictureModule } from './file-core-picture/file-core-picture.module'; import { FileUploadService } from './file-upload/services/file-upload.service'; +import { FileUrlModule } from './file-url/file-url.module'; +import { FileWorkflowModule } from './file-workflow/file-workflow.module'; import { FilesFieldModule } from './files-field/files-field.module'; +import { FileByIdGuard } from './guards/file-by-id.guard'; import { FileResolver } from './resolvers/file.resolver'; import { FileMetadataService } from './services/file-metadata.service'; import { FileService } from './services/file.service'; @@ -27,7 +32,10 @@ import { FileService } from './services/file.service'; TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]), PermissionsModule, FileStorageModule, + FileUrlModule, FilesFieldModule, + FileCorePictureModule, + FileWorkflowModule, SecureHttpClientModule, ], providers: [ @@ -35,13 +43,22 @@ import { FileService } from './services/file.service'; FileMetadataService, FileResolver, FilePathGuard, + FileByIdGuard, FileAttachmentListener, FileWorkspaceMemberListener, FileWorkspaceFolderDeletionJob, FileDeletionJob, FileUploadService, ], - exports: [FileService, FileMetadataService], - controllers: [FileController], + exports: [ + FileService, + FileMetadataService, + FileUrlModule, + FilesFieldModule, + FileCorePictureModule, + FileWorkflowModule, + FileUploadService, + ], + controllers: [FileController, FileByIdController], }) export class FileModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts index f6b9fcc8829..afa7598629b 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.module.ts @@ -3,13 +3,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; -import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; -import { FilesFieldController } from 'src/engine/core-modules/file/files-field/controllers/files-field.controller'; -import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; -import { FilesFieldGuard } from 'src/engine/core-modules/file/files-field/guards/files-field.guard'; +import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module'; import { FilesFieldDeletionJob } from 'src/engine/core-modules/file/files-field/jobs/files-field-deletion.job'; import { FilesFieldDeletionListener } from 'src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener'; import { FilesFieldResolver } from 'src/engine/core-modules/file/files-field/resolvers/files-field.resolver'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -20,23 +18,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi imports: [ JwtModule, TypeOrmModule.forFeature([ - FileEntity, WorkspaceEntity, ApplicationEntity, FieldMetadataEntity, ]), PermissionsModule, FileStorageModule, + FileUrlModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, ], providers: [ FilesFieldService, FilesFieldResolver, - FilesFieldGuard, FilesFieldDeletionListener, FilesFieldDeletionJob, ], exports: [FilesFieldService], - controllers: [FilesFieldController], }) export class FilesFieldModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts index ddc5b59317f..72044799d39 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts @@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts index c1f6bd7e873..e17065b8f53 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/resolvers/files-field.resolver.ts @@ -6,15 +6,15 @@ import { PermissionFlagType } from 'twenty-shared/constants'; import type { FileUpload } from 'graphql-upload/processRequest.mjs'; -import { FilesFieldFileDTO } from 'src/engine/core-modules/file/files-field/dtos/files-field-file.dto'; -import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/services/files-field.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; @UseGuards(WorkspaceAuthGuard) @@ -24,7 +24,7 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer'; export class FilesFieldResolver { constructor(private readonly filesFieldService: FilesFieldService) {} - @Mutation(() => FilesFieldFileDTO) + @Mutation(() => FileWithSignedUrlDto) @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) async uploadFilesFieldFile( @AuthWorkspace() @@ -37,7 +37,7 @@ export class FilesFieldResolver { nullable: false, }) fieldMetadataId: string, - ): Promise { + ): Promise { const stream = createReadStream(); const buffer = await streamToBuffer(stream); diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts similarity index 51% rename from packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts rename to packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts index 3267dea2a1b..303b267ed90 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts @@ -1,33 +1,23 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Readable } from 'stream'; - import { msg } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { FileFolder } from 'twenty-shared/types'; -import { Like, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { v4 } from 'uuid'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { - FilesFieldTokenJwtPayload, - JwtTokenTypeEnum, -} from 'src/engine/core-modules/auth/types/auth-context.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 { FilesFieldFileDTO } from 'src/engine/core-modules/file/files-field/dtos/files-field-file.dto'; -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 { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; -import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; - +import { FileWithSignedUrlDto } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { FilesFieldException, FilesFieldExceptionCode, -} from './files-field.exception'; +} from 'src/engine/core-modules/file/files-field/files-field.exception'; +import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils'; +import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @Injectable() export class FilesFieldService { @@ -37,10 +27,7 @@ export class FilesFieldService { private readonly applicationRepository: Repository, @InjectRepository(FieldMetadataEntity) private readonly fieldMetadataRepository: Repository, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, - private readonly twentyConfigService: TwentyConfigService, - private readonly jwtWrapperService: JwtWrapperService, + private readonly fileUrlService: FileUrlService, ) {} async uploadFile({ @@ -53,7 +40,7 @@ export class FilesFieldService { filename: string; workspaceId: string; fieldMetadataId: string; - }): Promise { + }): Promise { const { mimeType, ext } = await extractFileInfo({ file, filename, @@ -95,7 +82,11 @@ export class FilesFieldService { return { ...savedFile, - url: this.signFileUrl({ fileId, workspaceId }), + url: this.fileUrlService.signFileByIdUrl({ + fileId, + workspaceId, + fileFolder: FileFolder.FilesField, + }), }; } @@ -122,70 +113,4 @@ export class FilesFieldService { ); } } - - async getFileStream({ - fileId, - workspaceId, - }: { - fileId: string; - workspaceId: string; - }): Promise { - const file = await this.fileRepository.findOneOrFail({ - where: { - id: fileId, - path: Like(`${FileFolder.FilesField}/%`), - workspaceId, - }, - }); - - if (file.settings?.isTemporaryFile === true) { - throw new FilesFieldException( - `File ${fileId} is not associated with a permanent files field`, - FilesFieldExceptionCode.TEMPORARY_FILE_NOT_ALLOWED, - { - userFriendlyMessage: msg`File ${fileId} is not associated with a files field. It can't be downloaded.`, - }, - ); - } - - const application = await this.applicationRepository.findOneOrFail({ - where: { - id: file.applicationId, - workspaceId, - }, - }); - - return await this.fileStorageService.readFile({ - resourcePath: removeFileFolderFromFileEntityPath(file.path), - fileFolder: FileFolder.FilesField, - applicationUniversalIdentifier: application.universalIdentifier, - workspaceId, - }); - } - - signFileUrl( - payloadToEncode: Omit, - ) { - const fileTokenExpiresIn = this.twentyConfigService.get( - 'FILE_TOKEN_EXPIRES_IN', - ); - - const payload: FilesFieldTokenJwtPayload = { - ...payloadToEncode, - sub: payloadToEncode.workspaceId, - type: JwtTokenTypeEnum.FILE, - }; - - const secret = this.jwtWrapperService.generateAppSecret( - payload.type, - payloadToEncode.workspaceId, - ); - - const token = this.jwtWrapperService.sign(payload, { - secret, - expiresIn: fileTokenExpiresIn, - }); - - return `${process.env.SERVER_URL}/files-field/${payloadToEncode.fileId}?token=${token}`; - } } diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/utils/__tests__/extract-file-id-from-url.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/utils/__tests__/extract-file-id-from-url.util.spec.ts new file mode 100644 index 00000000000..faa165cca1b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/utils/__tests__/extract-file-id-from-url.util.spec.ts @@ -0,0 +1,57 @@ +import { FileFolder } from 'twenty-shared/types'; + +import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util'; + +describe('extractFileIdFromUrl', () => { + const validUuid = '550e8400-e29b-41d4-a716-446655440000'; + + it('should extract valid UUID from URL with matching file folder', () => { + const url = `https://example.com/file/${FileFolder.FilesField}/${validUuid}`; + + expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(validUuid); + }); + + it('should return null for invalid URL', () => { + expect(extractFileIdFromUrl('not-a-valid-url', FileFolder.FilesField)).toBe( + null, + ); + }); + + it('should return null for external link with different path', () => { + const url = `https://example.com/external-path/${validUuid}`; + + expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null); + }); + + it('should return null when fileId is not a valid UUID', () => { + const url = `https://example.com/file/${FileFolder.FilesField}/not-a-uuid`; + + expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null); + }); + + it('should return null when pathname has no fileId segment', () => { + const url = `https://example.com/file/${FileFolder.FilesField}/`; + + expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null); + }); + + it('should work with different file folders', () => { + const corePictureUrl = `https://example.com/file/${FileFolder.CorePicture}/${validUuid}`; + + expect(extractFileIdFromUrl(corePictureUrl, FileFolder.CorePicture)).toBe( + validUuid, + ); + }); + + it('should work with query params', () => { + const url = `http://localhost:3000/file/${FileFolder.FilesField}/${validUuid}?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3b3Jrc3BhY2VJZCI6IjNmZWNhYzJkLWMbOWEtNGExOC1hZjVlLTk0NjYwMTNhODFkMSIsImZpbGVJZCI6IjVlYmJjODQ0LTAzYTUtNGEyNS05NDliLWE2NWNmMjkzMWExOSIsInN1YiI6IjNmZWNhYzJkLWMwOWEtNGExOC1hZjVlLTk0NjYwMTNhODFkMSIsInR5cGUiOiJGSUxFIiwiaWF0IjoxNzcxMjYzNjEwLCJleHAiOjE3NzEzNTAwMTB9.qBy0SvkAuaq-KwWIALasRVJwSkN9Llu15LXUnVZMy-Y`; + + expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(validUuid); + }); + + it('should return null when file folder does not match', () => { + const url = `https://example.com/file/${FileFolder.CorePicture}/${validUuid}`; + + expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts index d542906fa03..6c89f0947d5 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util.ts @@ -1,6 +1,10 @@ +import { type FileFolder } from 'twenty-shared/types'; import { isDefined, isValidUuid } from 'twenty-shared/utils'; -export const extractFileIdFromUrl = (url: string): string | null => { +export const extractFileIdFromUrl = ( + url: string, + fileFolder: FileFolder, +): string | null => { let parsedUrl: URL; try { @@ -10,13 +14,13 @@ export const extractFileIdFromUrl = (url: string): string | null => { } const pathname = parsedUrl.pathname; - const isLinkExternal = !pathname.startsWith('/files-field/'); + const isLinkExternal = !pathname.startsWith(`/file/${fileFolder}/`); if (isLinkExternal) { return null; } - const fileId = pathname.match(/files-field\/([^/]+)/)?.[1]; + const fileId = pathname.match(`/${fileFolder}/([^/]+)`)?.[1]; return isDefined(fileId) && isValidUuid(fileId) ? fileId : null; }; diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/guards/files-field.guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts similarity index 50% rename from packages/twenty-server/src/engine/core-modules/file/files-field/guards/files-field.guard.ts rename to packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts index e54e9c20328..01885343ffa 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/guards/files-field.guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-by-id.guard.ts @@ -1,25 +1,33 @@ -import { - type CanActivate, - type ExecutionContext, - Injectable, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { FileFolder } from 'twenty-shared/types'; import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; -import { FilesFieldTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; +export const SUPPORTED_FILE_FOLDERS = [ + FileFolder.CorePicture, + FileFolder.FilesField, + FileFolder.Workflow, +] as const; + +export type SupportedFileFolder = (typeof SUPPORTED_FILE_FOLDERS)[number]; + @Injectable() -export class FilesFieldGuard implements CanActivate { +export class FileByIdGuard implements CanActivate { constructor(private readonly jwtWrapperService: JwtWrapperService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - - const fileToken = request.query.token; const fileId = request.params.id; + const fileFolder = request.params.fileFolder as FileFolder; + const fileToken = request.query.token; + + if (!this.isSupportedFileFolder(fileFolder)) { + return false; + } if (!fileToken) { return false; @@ -27,8 +35,7 @@ export class FilesFieldGuard implements CanActivate { try { const payload = await this.jwtWrapperService.verifyJwtToken(fileToken, { - ignoreExpiration: - fileFolderConfigs[FileFolder.FilesField].ignoreExpirationToken, + ignoreExpiration: fileFolderConfigs[fileFolder].ignoreExpirationToken, }); if (!payload.workspaceId) { @@ -38,10 +45,12 @@ export class FilesFieldGuard implements CanActivate { return false; } - const decodedPayload = - this.jwtWrapperService.decode(fileToken, { + const decodedPayload = this.jwtWrapperService.decode( + fileToken, + { json: true, - }); + }, + ); request.workspaceId = decodedPayload.workspaceId; @@ -51,4 +60,10 @@ export class FilesFieldGuard implements CanActivate { return true; } + + private isSupportedFileFolder( + fileFolder: string, + ): fileFolder is SupportedFileFolder { + return SUPPORTED_FILE_FOLDERS.includes(fileFolder as SupportedFileFolder); + } } diff --git a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts index 1ba260fd3a6..c893e6b6fcf 100644 --- a/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts +++ b/packages/twenty-server/src/engine/core-modules/file/guards/file-path-guard.ts @@ -4,7 +4,7 @@ import { Injectable, } from '@nestjs/common'; -import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/auth-context.type'; import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; @@ -39,12 +39,10 @@ export class FilePathGuard implements CanActivate { return false; } - const decodedPayload = this.jwtWrapperService.decode( - fileSignature, - { + const decodedPayload = + this.jwtWrapperService.decode(fileSignature, { json: true, - }, - ); + }); request.workspaceId = decodedPayload.workspaceId; diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts index e35e4ebbee0..ddd5628a494 100644 --- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts @@ -24,6 +24,9 @@ export const fileFolderConfigs: Record = { [FileFolder.PersonPicture]: { ignoreExpirationToken: false, }, + [FileFolder.CorePicture]: { + ignoreExpirationToken: true, + }, [FileFolder.File]: { ignoreExpirationToken: false, }, @@ -48,6 +51,9 @@ export const fileFolderConfigs: Record = { [FileFolder.Dependencies]: { ignoreExpirationToken: false, }, + [FileFolder.Workflow]: { + ignoreExpirationToken: false, + }, }; export type AllowedFolders = KebabCase; diff --git a/packages/twenty-server/src/engine/core-modules/file/resolvers/file.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/resolvers/file.resolver.ts index 7174f01e22a..ed5d325ec16 100644 --- a/packages/twenty-server/src/engine/core-modules/file/resolvers/file.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/file/resolvers/file.resolver.ts @@ -6,6 +6,7 @@ import { PermissionFlagType } from 'twenty-shared/constants'; import type { FileUpload } from 'graphql-upload/processRequest.mjs'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service'; @@ -15,7 +16,6 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; @UseGuards(WorkspaceAuthGuard) @@ -25,7 +25,9 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer'; export class FileResolver { constructor(private readonly fileMetadataService: FileMetadataService) {} - @Mutation(() => FileDTO) + @Mutation(() => FileDTO, { + deprecationReason: 'Use specific file service instead', + }) @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) async createFile( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @@ -43,7 +45,9 @@ export class FileResolver { }); } - @Mutation(() => FileDTO) + @Mutation(() => FileDTO, { + deprecationReason: '', + }) @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) async deleteFile( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file-metadata.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file-metadata.service.ts index 194a0f36d38..a428de4d3a3 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file-metadata.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file-metadata.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { FileFolder } from 'twenty-shared/types'; import { extractFolderPathFilenameAndTypeOrThrow } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; -import { FileFolder } from 'twenty-shared/types'; import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; @@ -20,6 +20,9 @@ export class FileMetadataService { private readonly fileUploadService: FileUploadService, ) {} + /** + * @deprecated + */ async createFile({ file, filename, @@ -54,6 +57,9 @@ export class FileMetadataService { return savedFile; } + /** + * @deprecated + */ async deleteFileById( id: string, workspaceId: string, diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts index d5fed4cb4a8..7a4def59cf9 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts @@ -1,6 +1,9 @@ import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -32,6 +35,14 @@ describe('FileService', () => { provide: JwtWrapperService, useValue: {}, }, + { + provide: getRepositoryToken(FileEntity), + useValue: {}, + }, + { + provide: getRepositoryToken(ApplicationEntity), + useValue: {}, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index e54e7f38c0f..854692abe19 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -1,20 +1,26 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { basename, dirname, extname } from 'path'; import { type Readable } from 'stream'; import { isNonEmptyString } from '@sniptt/guards'; +import { FileFolder } from 'twenty-shared/types'; import { buildSignedPath, extractFolderPathFilenameAndTypeOrThrow, } from 'twenty-shared/utils'; +import { Like, Repository } from 'typeorm'; import { v4 as uuidV4 } from 'uuid'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { - type FileTokenJwtPayload, + type FileTokenJwtPayloadLegacy, JwtTokenTypeEnum, } from 'src/engine/core-modules/auth/types/auth-context.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 { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -24,6 +30,10 @@ export class FileService { private readonly jwtWrapperService: JwtWrapperService, private readonly fileStorageService: FileStorageService, private readonly twentyConfigService: TwentyConfigService, + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, ) {} async getFileStream( @@ -38,6 +48,40 @@ export class FileService { }); } + async getFileStreamById({ + fileId, + workspaceId, + fileFolder, + }: { + fileId: string; + workspaceId: string; + fileFolder: FileFolder; + }): Promise { + { + const file = await this.fileRepository.findOneOrFail({ + where: { + id: fileId, + workspaceId, + path: Like(`${fileFolder}/%`), + }, + }); + + const application = await this.applicationRepository.findOneOrFail({ + where: { + id: file.applicationId, + workspaceId, + }, + }); + + return this.fileStorageService.readFile({ + resourcePath: removeFileFolderFromFileEntityPath(file.path), + fileFolder, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + }); + } + } + signFileUrl({ url, workspaceId }: { url: string; workspaceId: string }) { if (!isNonEmptyString(url)) { return url; @@ -52,12 +96,14 @@ export class FileService { }); } - encodeFileToken(payloadToEncode: Omit) { + encodeFileToken( + payloadToEncode: Omit, + ) { const fileTokenExpiresIn = this.twentyConfigService.get( 'FILE_TOKEN_EXPIRES_IN', ); - const payload: FileTokenJwtPayload = { + const payload: FileTokenJwtPayloadLegacy = { ...payloadToEncode, sub: payloadToEncode.workspaceId, type: JwtTokenTypeEnum.FILE, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 8ae06cf2c2f..a03175740bd 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +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 { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; @@ -19,6 +20,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou MessagingImportManagerModule, MessagingSendManagerModule, TypeOrmModule.forFeature([FileEntity]), + FeatureFlagModule, FileModule, JwtModule, SecureHttpClientModule, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index 7f8727ff1bb..4d8186ff346 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -7,8 +7,8 @@ import { type CodeExecutionFile, type CodeExecutionState, } from 'twenty-shared/ai'; -import { v4 } from 'uuid'; import { FileFolder } from 'twenty-shared/types'; +import { v4 } from 'uuid'; import { type InputFile, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts index 0a1878af873..47918b70c40 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts @@ -1,17 +1,23 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Readable } from 'stream'; + import { render, toPlainText } from '@react-email/render'; import DOMPurify from 'dompurify'; import { reactMarkupFromJSON } from 'twenty-emails'; +import { FileFolder } from 'twenty-shared/types'; import { extractFolderPathFilenameAndTypeOrThrow, isDefined, isValidUuid, } from 'twenty-shared/utils'; +import { WorkflowAttachment } from 'twenty-shared/workflow'; import { In, type Repository } from 'typeorm'; import { z } from 'zod'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { @@ -40,8 +46,16 @@ export class EmailComposerService { @InjectRepository(FileEntity) private readonly fileRepository: Repository, private readonly fileService: FileService, + private readonly featureFlagService: FeatureFlagService, ) {} + private async isOtherFileMigrated(workspaceId: string): Promise { + return this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_OTHER_FILE_MIGRATED, + workspaceId, + ); + } + private async getConnectedAccount( connectedAccountId: string, workspaceId: string, @@ -166,7 +180,7 @@ export class EmailComposerService { } private async getAttachments( - files: Array<{ id: string; name: string; type: string }>, + files: Array, workspaceId: string, ): Promise { if (files.length === 0) { @@ -207,11 +221,23 @@ export class EmailComposerService { fileEntity.path, ); - const stream = await this.fileService.getFileStream( - folderPath, - filename, - workspaceId, - ); + const isOtherFileMigrated = await this.isOtherFileMigrated(workspaceId); + + let stream: Readable; + + if (isOtherFileMigrated) { + stream = await this.fileService.getFileStreamById({ + fileId: fileMetadata.id, + workspaceId, + fileFolder: FileFolder.Workflow, + }); + } else { + stream = await this.fileService.getFileStream( + folderPath, + filename, + workspaceId, + ); + } const buffer = await streamToBuffer(stream); diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index f4ffa5b23bb..1781ac439e0 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -7,6 +7,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; @@ -49,6 +50,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works TokenModule, PermissionsModule, OnboardingModule, + FeatureFlagModule, ], services: [UserWorkspaceService], }), diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts index b5d956ad349..70764864414 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.resolver.ts @@ -21,7 +21,7 @@ export class UserWorkspaceResolver { @Mutation(() => SignedFileDTO) @UseGuards(WorkspaceAuthGuard, UploadProfilePicturePermissionGuard) - async uploadWorkspaceMemberProfilePicture( + async uploadWorkspaceMemberProfilePictureLegacy( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, @Args({ name: 'file', type: () => GraphQLUpload }) { createReadStream, filename, mimetype }: FileUpload, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 32dc54311aa..d3f57a5344c 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -1,15 +1,17 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { type DataSource, type Repository } from 'typeorm'; import { FileFolder } from 'twenty-shared/types'; +import { type DataSource, type Repository } from 'typeorm'; import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service'; import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { FileUploadService, type SignedFilesResult, @@ -118,6 +120,10 @@ describe('UserWorkspaceService', () => { assignRoleToManyUserWorkspace: jest.fn(), }, }, + { + provide: FileCorePictureService, + useValue: {}, + }, { provide: FileStorageService, useValue: { @@ -146,6 +152,12 @@ describe('UserWorkspaceService', () => { setOnboardingCreateProfilePending: jest.fn(), }, }, + { + provide: FeatureFlagService, + useValue: { + isFeatureEnabled: jest.fn(), + }, + }, ], }).compile(); diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 0bce22a49df..3bb03ee99ab 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -2,9 +2,9 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; -import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; -import { type QueryRunner, IsNull, Not, type Repository } from 'typeorm'; import { FileFolder } from 'twenty-shared/types'; +import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; +import { IsNull, Not, type QueryRunner, type Repository } from 'typeorm'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; @@ -17,7 +17,11 @@ import { import { type AvailableWorkspace } from 'src/engine/core-modules/auth/dto/available-workspaces.output'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; @@ -53,9 +57,11 @@ export class UserWorkspaceService extends TypeOrmQueryService { @@ -79,6 +87,8 @@ export class UserWorkspaceService extends TypeOrmQueryService { FeatureFlagService, ExceptionHandlerService, PermissionsService, + FileCorePictureService, ].map((service) => ({ provide: service, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 0e883fe9dce..7fc4a973cff 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -18,6 +18,7 @@ import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custo import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { FileWorkspaceFolderDeletionJob, type FileWorkspaceFolderDeletionJobData, @@ -108,6 +109,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly subdomainManagerService: SubdomainManagerService, private readonly workspaceDataSourceService: WorkspaceDataSourceService, private readonly customDomainManagerService: CustomDomainManagerService, + private readonly fileCorePictureService: FileCorePictureService, @InjectMessageQueue(MessageQueue.deleteCascadeQueue) private readonly messageQueueService: MessageQueueService, @InjectDataSource() @@ -214,8 +216,10 @@ export class WorkspaceService extends TypeOrmQueryService { ); } + let updatedWorkspace: WorkspaceEntity; + try { - return await this.workspaceRepository.save({ + updatedWorkspace = await this.workspaceRepository.save({ ...workspace, ...payload, }); @@ -230,6 +234,15 @@ export class WorkspaceService extends TypeOrmQueryService { } throw error; } + + if (payload.logo === null && isDefined(workspace.logoFileId)) { + await this.fileCorePictureService.deleteCorePicture({ + fileId: workspace.logoFileId, + workspaceId: workspace.id, + }); + } + + return updatedWorkspace; } async activateWorkspace( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index f41a646a1d0..a430af19249 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -13,6 +13,7 @@ import { JoinColumn, ManyToOne, OneToMany, + OneToOne, PrimaryGeneratedColumn, type Relation, UpdateDateColumn, @@ -26,6 +27,7 @@ import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/applica import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { PostgresCredentialsEntity } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity'; @@ -72,10 +74,22 @@ export class WorkspaceEntity { @Column({ nullable: true }) displayName?: string; + //deprecated @Field({ nullable: true }) @Column({ nullable: true }) logo?: string; + @Field(() => UUIDScalarType, { nullable: true }) + @Column({ nullable: true, type: 'uuid' }) + logoFileId: string | null; + + @OneToOne(() => FileEntity, { + onDelete: 'SET NULL', + nullable: true, + }) + @JoinColumn({ name: 'logoFileId' }) + logoFile: Relation; + @Field({ nullable: true }) @Column({ nullable: true }) inviteHash?: string; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index 5c1c3a2df66..2580a02e44b 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -16,9 +16,10 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import type { FileUpload } from 'graphql-upload/processRequest.mjs'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; -import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { fromFlatApplicationToApplicationDto } from 'src/engine/core-modules/application/utils/from-flat-application-to-application-dto.util'; import { BillingEntitlementDTO } from 'src/engine/core-modules/billing/dtos/billing-entitlement.dto'; import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; @@ -32,6 +33,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { SignedFileDTO } from 'src/engine/core-modules/file/file-upload/dtos/signed-file.dto'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; @@ -71,7 +73,6 @@ import { RoleService } from 'src/engine/metadata-modules/role/role.service'; import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util'; import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { getRequest } from 'src/utils/extract-request'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; const OriginHeader = createParamDecorator( @@ -96,6 +97,7 @@ export class WorkspaceResolver { private readonly twentyConfigService: TwentyConfigService, private readonly fileUploadService: FileUploadService, private readonly fileService: FileService, + private readonly fileUrlService: FileUrlService, private readonly billingSubscriptionService: BillingSubscriptionService, private readonly featureFlagService: FeatureFlagService, private readonly roleService: RoleService, @@ -159,7 +161,7 @@ export class WorkspaceResolver { WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE), ) - async uploadWorkspaceLogo( + async uploadWorkspaceLogoLegacy( @AuthWorkspace() { id }: WorkspaceEntity, @Args({ name: 'file', type: () => GraphQLUpload }) { createReadStream, filename, mimetype }: FileUpload, @@ -301,6 +303,23 @@ export class WorkspaceResolver { @ResolveField(() => String) async logo(@Parent() workspace: WorkspaceEntity): Promise { + if ( + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + workspace.id, + ) + ) { + if (!isDefined(workspace.logoFileId)) { + return ''; + } + + return this.fileUrlService.signFileByIdUrl({ + fileId: workspace.logoFileId, + workspaceId: workspace.id, + fileFolder: FileFolder.CorePicture, + }); + } + if (workspace.logo) { try { return this.fileService.signFileUrl({ diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts index 35fac9b7274..09ad02859c3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.resolver.ts @@ -4,6 +4,7 @@ import { Args, Context, Mutation, Parent, ResolveField } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { ForbiddenError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @@ -12,7 +13,6 @@ import { I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.typ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { CreateOneFieldMetadataInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 41e4db7decd..e6b6950716d 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -245,6 +245,8 @@ describe('WorkspaceEntityManager', () => { IS_MARKETPLACE_ENABLED: false, IS_FILES_FIELD_MIGRATED: false, IS_DRAFT_EMAIL_ENABLED: false, + IS_CORE_PICTURE_MIGRATED: false, + IS_OTHER_FILE_MIGRATED: false, }, userWorkspaceRoleMap: {}, eventEmitterService: { diff --git a/packages/twenty-server/src/modules/modules.module.ts b/packages/twenty-server/src/modules/modules.module.ts index 68973d0d965..6a0e2c96baa 100644 --- a/packages/twenty-server/src/modules/modules.module.ts +++ b/packages/twenty-server/src/modules/modules.module.ts @@ -6,6 +6,7 @@ import { FavoriteFolderModule } from 'src/modules/favorite-folder/favorite-folde import { FavoriteModule } from 'src/modules/favorite/favorite.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { WorkflowModule } from 'src/modules/workflow/workflow.module'; +import { WorkspaceMemberModule } from 'src/modules/workspace-member/workspace-member.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module'; WorkflowModule, FavoriteFolderModule, FavoriteModule, + WorkspaceMemberModule, ], providers: [], exports: [], diff --git a/packages/twenty-server/src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener.ts b/packages/twenty-server/src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener.ts new file mode 100644 index 00000000000..486c8708653 --- /dev/null +++ b/packages/twenty-server/src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; + +import { + ObjectRecordDeleteEvent, + ObjectRecordDestroyEvent, + ObjectRecordUpdateEvent, +} from 'twenty-shared/database-events'; +import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; +import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/utils/extract-file-id-from-url.util'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@Injectable() +export class WorkspaceMemberAvatarFileDeletionListener { + constructor( + private readonly featureFlagService: FeatureFlagService, + private readonly fileCorePictureService: FileCorePictureService, + ) {} + + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.UPDATED) + async handleUpdate( + payload: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + if ( + !(await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_CORE_PICTURE_MIGRATED, + payload.workspaceId, + )) + ) { + return; + } + + const fileIdsToDelete = this.getFileIdsToDeleteFromUpdateEvent(payload); + + this.deleteCorePictures(fileIdsToDelete, payload.workspaceId); + } + + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED) + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DELETED) + async handleDestroyOrDeleteEvent( + payload: WorkspaceEventBatch< + | ObjectRecordDestroyEvent + | ObjectRecordDeleteEvent + >, + ) { + const fileIdsToDelete = + this.getFileIdsToDeleteFromDestroyOrDeleteEvent(payload); + + await this.deleteCorePictures(fileIdsToDelete, payload.workspaceId); + } + + private async deleteCorePictures( + fileIds: string[], + workspaceId: string, + ): Promise { + for (const fileId of fileIds) { + await this.fileCorePictureService.deleteCorePicture({ + workspaceId, + fileId, + }); + } + } + + private getFileIdsToDeleteFromUpdateEvent( + payload: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ): string[] { + return payload.events + .map((event) => { + const beforeAvatarUrl = event.properties.before.avatarUrl; + + if (!isDefined(beforeAvatarUrl)) { + return undefined; + } + + const beforeFileId = extractFileIdFromUrl( + beforeAvatarUrl, + FileFolder.CorePicture, + ); + const afterFileId = extractFileIdFromUrl( + event.properties.after.avatarUrl ?? '', + FileFolder.CorePicture, + ); + + return beforeFileId !== afterFileId ? beforeFileId : undefined; + }) + .filter(isDefined); + } + + private getFileIdsToDeleteFromDestroyOrDeleteEvent( + payload: + | WorkspaceEventBatch< + ObjectRecordDestroyEvent + > + | WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ): string[] { + return payload.events + .map((event) => + isDefined(event.properties.before.avatarUrl) + ? extractFileIdFromUrl( + event.properties.before.avatarUrl, + FileFolder.CorePicture, + ) + : undefined, + ) + .filter(isDefined); + } +} diff --git a/packages/twenty-server/src/modules/workspace-member/workspace-member.module.ts b/packages/twenty-server/src/modules/workspace-member/workspace-member.module.ts new file mode 100644 index 00000000000..3f1303c4bb7 --- /dev/null +++ b/packages/twenty-server/src/modules/workspace-member/workspace-member.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { FileModule } from 'src/engine/core-modules/file/file.module'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; +import { WorkspaceMemberAvatarFileDeletionListener } from 'src/modules/workspace-member/listeners/workspace-member-avatar-file-deletion.listener'; + +@Module({ + imports: [TwentyORMModule, FeatureFlagModule, FileModule], + + providers: [WorkspaceMemberAvatarFileDeletionListener], +}) +export class WorkspaceMemberModule {} diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts index 0f2c1aa9666..16df5230662 100644 --- a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-download.integration-spec.ts @@ -57,7 +57,7 @@ const deleteFile = async (fileId: string): Promise => { }); }; -describe('files-field.controller - GET /files-field/:id', () => { +describe('file-by-id.controller - GET /file/:fileFolder/:id', () => { let createdObjectMetadataId = ''; let createdFieldMetadataId = ''; let uploadedFiles: UploadedFile[] = []; @@ -296,7 +296,7 @@ describe('files-field.controller - GET /files-field/:id', () => { const fileId = createdRecord.filesField[0].fileId; const downloadResponse = await request(global.app.getHttpServer()).get( - `/files-field/${fileId}`, + `/file/files-field/${fileId}`, ); expect(downloadResponse.status).toBe(403); @@ -341,7 +341,7 @@ describe('files-field.controller - GET /files-field/:id', () => { const fileId = createdRecord.filesField[0].fileId; const downloadResponse = await request(global.app.getHttpServer()) - .get(`/files-field/${fileId}`) + .get(`/file/files-field/${fileId}`) .query({ token: 'invalid-token-12345' }); expect(downloadResponse.status).toBe(403); diff --git a/packages/twenty-shared/src/individual-entry.ts b/packages/twenty-shared/src/individual-entry.ts index d2217191fe8..bbb0efaca30 100644 --- a/packages/twenty-shared/src/individual-entry.ts +++ b/packages/twenty-shared/src/individual-entry.ts @@ -9,3 +9,4 @@ export * from './types'; export * from './utils'; export * from './workflow'; export * from './workspace'; + diff --git a/packages/twenty-shared/src/types/FileFolder.ts b/packages/twenty-shared/src/types/FileFolder.ts index 4da6cea08c7..6afa31565cc 100644 --- a/packages/twenty-shared/src/types/FileFolder.ts +++ b/packages/twenty-shared/src/types/FileFolder.ts @@ -1,9 +1,10 @@ export enum FileFolder { - ProfilePicture = 'profile-picture', - WorkspaceLogo = 'workspace-logo', - Attachment = 'attachment', - PersonPicture = 'person-picture', - File = 'file', + ProfilePicture = 'profile-picture', // replaced by core-picture + WorkspaceLogo = 'workspace-logo', // replaced by core-picture + Attachment = 'attachment', // replaced by files-field + PersonPicture = 'person-picture', // replaced by files-field + CorePicture = 'core-picture', + File = 'file', // removed AgentChat = 'agent-chat', BuiltLogicFunction = 'built-logic-function', BuiltFrontComponent = 'built-front-component', @@ -11,4 +12,5 @@ export enum FileFolder { Source = 'source', FilesField = 'files-field', Dependencies = 'dependencies', + Workflow = 'workflow', } diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 6ad1bce9a8d..615c45e7f04 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -74,6 +74,7 @@ export type { InputSchema, } from './types/InputSchema'; export type { StepIfElseBranch } from './types/StepIfElseBranch'; +export type { WorkflowAttachment } from './types/WorkflowAttachment'; export type { BodyType } from './types/workflowHttpRequestStep'; export type { WorkflowRunStepInfo, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType.ts b/packages/twenty-shared/src/workflow/types/WorkflowAttachment.ts similarity index 68% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType.ts rename to packages/twenty-shared/src/workflow/types/WorkflowAttachment.ts index 3806888fc28..77e98907f11 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/types/WorkflowAttachmentType.ts +++ b/packages/twenty-shared/src/workflow/types/WorkflowAttachment.ts @@ -1,4 +1,4 @@ -export type WorkflowAttachmentType = { +export type WorkflowAttachment = { id: string; name: string; size: number;