Compare commits

...
Author SHA1 Message Date
Etienne 7c65e5ec9c wip 2026-02-16 13:23:21 +01:00
Etienne ee917c2c7d fix 2026-02-16 10:50:24 +01:00
Etienne b7ee3242a8 doc 2026-02-16 10:50:24 +01:00
Etienne ff53e15a8a fix-after-rebase 2026-02-16 10:50:24 +01:00
Etienne 25109dfb72 fix 2026-02-16 10:50:24 +01:00
Etienne f78440dbe0 migrate-workflow-attachment 2026-02-16 10:50:24 +01:00
Etienne 2ee6e0a6a8 fic 2026-02-16 10:50:23 +01:00
Etienne 7eb82b0181 fix-gql-schema-split 2026-02-16 10:50:23 +01:00
Etienne 39390988fa fix 2026-02-16 10:50:23 +01:00
Etienne 6613b01bab add 2026-02-16 10:50:23 +01:00
77 changed files with 2248 additions and 448 deletions
@@ -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,
@@ -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;
@@ -1481,6 +1481,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',
@@ -1491,6 +1492,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',
@@ -1629,6 +1631,7 @@ export enum FileFolder {
Attachment = 'Attachment',
BuiltFrontComponent = 'BuiltFrontComponent',
BuiltLogicFunction = 'BuiltLogicFunction',
CorePicture = 'CorePicture',
Dependencies = 'Dependencies',
File = 'File',
FilesField = 'FilesField',
@@ -1636,16 +1639,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'];
@@ -1653,6 +1652,11 @@ export type FilesFieldFile = {
url: Scalars['String'];
};
export type FilesConfiguration = {
__typename?: 'FilesConfiguration';
configurationType: WidgetConfigurationType;
};
export type FindAvailableSsoidpOutput = {
__typename?: 'FindAvailableSSOIDPOutput';
id: Scalars['UUID'];
@@ -2166,6 +2170,7 @@ export type Mutation = {
createCoreViewSort: CoreViewSort;
createDatabaseConfigVariable: Scalars['Boolean'];
createEmailingDomain: EmailingDomain;
/** @deprecated Use specfic file service instead */
createFile: File;
createFrontComponent: FrontComponent;
createManyCoreViewFieldGroups: Array<CoreViewFieldGroup>;
@@ -2201,6 +2206,7 @@ export type Mutation = {
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteEmailingDomain: Scalars['Boolean'];
/** @deprecated */
deleteFile: File;
deleteFrontComponent: FrontComponent;
deleteJobs: DeleteJobsResponse;
@@ -2302,13 +2308,17 @@ export type Mutation = {
updateWorkspace: Workspace;
updateWorkspaceFeatureFlag: Scalars['Boolean'];
updateWorkspaceMemberRole: WorkspaceMember;
uploadAgentChatFile: FileWithSignedUrl;
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<FieldPermission>;
upsertObjectPermissions: Array<ObjectPermission>;
upsertPermissionFlags: Array<PermissionFlag>;
@@ -3102,6 +3112,11 @@ export type MutationUpdateWorkspaceMemberRoleArgs = {
};
export type MutationUploadAgentChatFileArgs = {
file: Scalars['Upload'];
};
export type MutationUploadApplicationFileArgs = {
applicationUniversalIdentifier: Scalars['String'];
file: Scalars['Upload'];
@@ -3128,16 +3143,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;
};
@@ -5076,6 +5106,7 @@ export type Workspace = {
isPublicInviteLinkEnabled: Scalars['Boolean'];
isTwoFactorAuthenticationEnforced: Scalars['Boolean'];
logo?: Maybe<Scalars['String']>;
logoFileId?: Maybe<Scalars['String']>;
metadataVersion: Scalars['Float'];
routerModel: Scalars['String'];
smartModel: Scalars['String'];
@@ -5718,13 +5749,27 @@ export type DeleteFileMutationVariables = Exact<{
export type DeleteFileMutation = { __typename?: 'Mutation', deleteFile: { __typename?: 'File', id: string, path: string, size: number, createdAt: string } };
export type UploadAgentChatFileMutationVariables = Exact<{
file: Scalars['Upload'];
}>;
export type UploadAgentChatFileMutation = { __typename?: 'Mutation', uploadAgentChatFile: { __typename?: 'FileWithSignedUrl', id: string, path: string, size: number, createdAt: string, url: string } };
export type UploadFilesFieldFileMutationVariables = Exact<{
file: Scalars['Upload'];
fieldMetadataId: Scalars['String'];
}>;
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; }>;
@@ -6211,7 +6256,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'];
@@ -6730,7 +6782,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; }>;
@@ -10388,6 +10447,43 @@ export function useDeleteFileMutation(baseOptions?: Apollo.MutationHookOptions<D
export type DeleteFileMutationHookResult = ReturnType<typeof useDeleteFileMutation>;
export type DeleteFileMutationResult = Apollo.MutationResult<DeleteFileMutation>;
export type DeleteFileMutationOptions = Apollo.BaseMutationOptions<DeleteFileMutation, DeleteFileMutationVariables>;
export const UploadAgentChatFileDocument = gql`
mutation UploadAgentChatFile($file: Upload!) {
uploadAgentChatFile(file: $file) {
id
path
size
createdAt
url
}
}
`;
export type UploadAgentChatFileMutationFn = Apollo.MutationFunction<UploadAgentChatFileMutation, UploadAgentChatFileMutationVariables>;
/**
* __useUploadAgentChatFileMutation__
*
* To run a mutation, you first call `useUploadAgentChatFileMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUploadAgentChatFileMutation` 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 [uploadAgentChatFileMutation, { data, loading, error }] = useUploadAgentChatFileMutation({
* variables: {
* file: // value for 'file'
* },
* });
*/
export function useUploadAgentChatFileMutation(baseOptions?: Apollo.MutationHookOptions<UploadAgentChatFileMutation, UploadAgentChatFileMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadAgentChatFileMutation, UploadAgentChatFileMutationVariables>(UploadAgentChatFileDocument, options);
}
export type UploadAgentChatFileMutationHookResult = ReturnType<typeof useUploadAgentChatFileMutation>;
export type UploadAgentChatFileMutationResult = Apollo.MutationResult<UploadAgentChatFileMutation>;
export type UploadAgentChatFileMutationOptions = Apollo.BaseMutationOptions<UploadAgentChatFileMutation, UploadAgentChatFileMutationVariables>;
export const UploadFilesFieldFileDocument = gql`
mutation UploadFilesFieldFile($file: Upload!, $fieldMetadataId: String!) {
uploadFilesFieldFile(file: $file, fieldMetadataId: $fieldMetadataId) {
@@ -10426,6 +10522,43 @@ export function useUploadFilesFieldFileMutation(baseOptions?: Apollo.MutationHoo
export type UploadFilesFieldFileMutationHookResult = ReturnType<typeof useUploadFilesFieldFileMutation>;
export type UploadFilesFieldFileMutationResult = Apollo.MutationResult<UploadFilesFieldFileMutation>;
export type UploadFilesFieldFileMutationOptions = Apollo.BaseMutationOptions<UploadFilesFieldFileMutation, UploadFilesFieldFileMutationVariables>;
export const UploadWorkflowFileDocument = gql`
mutation UploadWorkflowFile($file: Upload!) {
uploadWorkflowFile(file: $file) {
id
path
size
createdAt
url
}
}
`;
export type UploadWorkflowFileMutationFn = Apollo.MutationFunction<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>;
/**
* __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<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>(UploadWorkflowFileDocument, options);
}
export type UploadWorkflowFileMutationHookResult = ReturnType<typeof useUploadWorkflowFileMutation>;
export type UploadWorkflowFileMutationResult = Apollo.MutationResult<UploadWorkflowFileMutation>;
export type UploadWorkflowFileMutationOptions = Apollo.BaseMutationOptions<UploadWorkflowFileMutation, UploadWorkflowFileMutationVariables>;
export const FindManyFrontComponentsDocument = gql`
query FindManyFrontComponents {
frontComponents {
@@ -13080,8 +13213,7 @@ export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptio
export const UploadWorkspaceMemberProfilePictureDocument = gql`
mutation UploadWorkspaceMemberProfilePicture($file: Upload!) {
uploadWorkspaceMemberProfilePicture(file: $file) {
path
token
url
}
}
`;
@@ -13111,6 +13243,40 @@ export function useUploadWorkspaceMemberProfilePictureMutation(baseOptions?: Apo
export type UploadWorkspaceMemberProfilePictureMutationHookResult = ReturnType<typeof useUploadWorkspaceMemberProfilePictureMutation>;
export type UploadWorkspaceMemberProfilePictureMutationResult = Apollo.MutationResult<UploadWorkspaceMemberProfilePictureMutation>;
export type UploadWorkspaceMemberProfilePictureMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UploadWorkspaceMemberProfilePictureLegacyDocument = gql`
mutation UploadWorkspaceMemberProfilePictureLegacy($file: Upload!) {
uploadWorkspaceMemberProfilePictureLegacy(file: $file) {
path
token
}
}
`;
export type UploadWorkspaceMemberProfilePictureLegacyMutationFn = Apollo.MutationFunction<UploadWorkspaceMemberProfilePictureLegacyMutation, UploadWorkspaceMemberProfilePictureLegacyMutationVariables>;
/**
* __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<UploadWorkspaceMemberProfilePictureLegacyMutation, UploadWorkspaceMemberProfilePictureLegacyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadWorkspaceMemberProfilePictureLegacyMutation, UploadWorkspaceMemberProfilePictureLegacyMutationVariables>(UploadWorkspaceMemberProfilePictureLegacyDocument, options);
}
export type UploadWorkspaceMemberProfilePictureLegacyMutationHookResult = ReturnType<typeof useUploadWorkspaceMemberProfilePictureLegacyMutation>;
export type UploadWorkspaceMemberProfilePictureLegacyMutationResult = Apollo.MutationResult<UploadWorkspaceMemberProfilePictureLegacyMutation>;
export type UploadWorkspaceMemberProfilePictureLegacyMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceMemberProfilePictureLegacyMutation, UploadWorkspaceMemberProfilePictureLegacyMutationVariables>;
export const UpdateUserEmailDocument = gql`
mutation UpdateUserEmail($newEmail: String!, $verifyEmailRedirectPath: String) {
updateUserEmail(
@@ -15512,8 +15678,7 @@ export type UpdateWorkspaceMutationOptions = Apollo.BaseMutationOptions<UpdateWo
export const UploadWorkspaceLogoDocument = gql`
mutation UploadWorkspaceLogo($file: Upload!) {
uploadWorkspaceLogo(file: $file) {
path
token
url
}
}
`;
@@ -15543,6 +15708,40 @@ export function useUploadWorkspaceLogoMutation(baseOptions?: Apollo.MutationHook
export type UploadWorkspaceLogoMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoMutation>;
export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult<UploadWorkspaceLogoMutation>;
export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceLogoMutation, UploadWorkspaceLogoMutationVariables>;
export const UploadWorkspaceLogoLegacyDocument = gql`
mutation UploadWorkspaceLogoLegacy($file: Upload!) {
uploadWorkspaceLogoLegacy(file: $file) {
path
token
}
}
`;
export type UploadWorkspaceLogoLegacyMutationFn = Apollo.MutationFunction<UploadWorkspaceLogoLegacyMutation, UploadWorkspaceLogoLegacyMutationVariables>;
/**
* __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<UploadWorkspaceLogoLegacyMutation, UploadWorkspaceLogoLegacyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UploadWorkspaceLogoLegacyMutation, UploadWorkspaceLogoLegacyMutationVariables>(UploadWorkspaceLogoLegacyDocument, options);
}
export type UploadWorkspaceLogoLegacyMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoLegacyMutation>;
export type UploadWorkspaceLogoLegacyMutationResult = Apollo.MutationResult<UploadWorkspaceLogoLegacyMutation>;
export type UploadWorkspaceLogoLegacyMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceLogoLegacyMutation, UploadWorkspaceLogoLegacyMutationVariables>;
export const CheckCustomDomainValidRecordsDocument = gql`
mutation CheckCustomDomainValidRecords {
checkCustomDomainValidRecords {
@@ -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;
};
@@ -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 ? (
<StyledChipsContainer>
{files.map((file: WorkflowAttachmentType) => (
{files.map((file: WorkflowAttachment) => (
<WorkflowAttachmentChip
key={file.id}
file={file}
@@ -1,31 +1,33 @@
import { MAX_ATTACHMENT_SIZE } from '@/advanced-text-editor/utils/MaxAttachmentSize';
import { formatFileSize } from '@/file/utils/formatFileSize';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useApolloClient } from '@apollo/client';
import { t } from '@lingui/core/macro';
import {
extractFolderPathFilenameAndTypeOrThrow,
isDefined,
} from 'twenty-shared/utils';
import { useCreateFileMutation } from '~/generated-metadata/graphql';
import { type WorkflowAttachment } from 'twenty-shared/workflow';
import {
FeatureFlagKey,
useCreateFileMutation,
useUploadWorkflowFileMutation,
} from '~/generated-metadata/graphql';
import { logError } from '~/utils/logError';
type WorkflowFile = {
id: string;
name: string;
size: number;
type: string;
createdAt: string;
};
export const useUploadWorkflowFile = () => {
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<WorkflowFile | null> => {
): Promise<WorkflowAttachment | null> => {
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`,
@@ -1,20 +1,16 @@
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useApolloClient } from '@apollo/client';
import { useLingui } from '@lingui/react/macro';
import { type FileUIPart } from 'ai';
import { useRecoilState } from 'recoil';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import {
FileFolder,
useUploadFileMutation,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { useUploadAgentChatFileMutation } from '~/generated-metadata/graphql';
export const useAIChatFileUpload = () => {
const coreClient = useApolloCoreClient();
const [uploadFile] = useUploadFileMutation({ client: coreClient });
const apolloClient = useApolloClient();
const [uploadFile] = useUploadAgentChatFileMutation({ client: apolloClient });
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const [agentChatSelectedFiles, setAgentChatSelectedFiles] = useRecoilState(
@@ -27,30 +23,23 @@ export const useAIChatFileUpload = () => {
const sendFile = async (file: File): Promise<FileUIPart | null> => {
try {
const result = await uploadFile({
variables: {
file,
fileFolder: FileFolder.AgentChat,
},
variables: { file },
});
const response = result?.data?.uploadFile;
const response = result?.data?.uploadAgentChatFile;
if (!isDefined(response)) {
throw new Error(t`Couldn't upload the file.`);
}
const signedPath = buildSignedPath({
path: response.path,
token: response.token,
});
setAgentChatSelectedFiles(
agentChatSelectedFiles.filter((f) => f.name !== file.name),
);
return {
filename: file.name,
mediaType: file.type,
url: `${REACT_APP_SERVER_BASE_URL}/files/${signedPath}`,
url: response.url,
type: 'file',
};
} catch {
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const UPLOAD_AGENT_CHAT_FILE = gql`
mutation UploadAgentChatFile($file: Upload!) {
uploadAgentChatFile(file: $file) {
id
path
size
createdAt
url
}
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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<string | null>(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,
@@ -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 () => {
@@ -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';
@@ -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<EmailRecipients>;
subject: string;
body: string;
files: WorkflowAttachmentType[];
};
@@ -1,8 +0,0 @@
import {
type WorkflowDraftEmailAction,
type WorkflowSendEmailAction,
} from '@/workflow/types/Workflow';
export type WorkflowEmailAction =
| WorkflowSendEmailAction
| WorkflowDraftEmailAction;
@@ -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
}
}
`;
@@ -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
}
}
`;
@@ -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<string, unknown>) || {};
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({
@@ -0,0 +1,225 @@
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 { 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 { 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<WorkspaceEntity>,
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<void> {
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<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersions = await workflowVersionRepository.find({
select: ['id', 'steps'],
where: {
status: In([
WorkflowVersionStatus.DRAFT,
WorkflowVersionStatus.ACTIVE,
WorkflowVersionStatus.DEACTIVATED,
]),
},
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fileRepository = this.coreDataSource.getRepository(FileEntity);
for (const workflowVersion of workflowVersions) {
const steps = workflowVersion.steps;
if (!isNonEmptyArray(steps)) {
continue;
}
const updatedSteps = [...steps];
let hasChanges = false;
for (const step of updatedSteps) {
if (step.type !== WorkflowActionType.SEND_EMAIL) {
continue;
}
const sendEmailStep = step as SendEmailStep;
const files = sendEmailStep.settings?.input?.files;
if (!isNonEmptyArray(files)) {
continue;
}
const updatedFiles: WorkflowFile[] = [];
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`,
);
updatedFiles.push(file);
continue;
}
if (fileEntity.path.startsWith(FileFolder.Workflow)) {
this.logger.log(
`File ${file.id} already in Workflow folder, skipping copy`,
);
updatedFiles.push(file);
continue;
}
const newFileId = v4();
const newResourcePath = `${newFileId}${isNonEmptyString(file.type) ? `.${file.type}` : ''}`;
const newPath = `${FileFolder.Workflow}/${newResourcePath}`;
if (!isDryRun) {
try {
await this.fileStorageService.copyLegacy({
from: {
folderPath: fileEntity.path,
filename: file.name,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newPath,
},
});
} catch (error) {
this.logger.error(
`Failed to migrate file ${file.id} in workspace ${workspaceId}: ${error.message}`,
);
updatedFiles.push(file);
continue;
}
} else {
this.logger.log(
`[DRY RUN] Would migrate file ${file.id} from ${fileEntity.path} to ${newPath}`,
);
}
updatedFiles.push({
...file,
id: newFileId,
});
hasChanges = true;
}
sendEmailStep.settings.input.files = updatedFiles;
}
if (hasChanges && !isDryRun) {
await workflowVersionRepository.update(
{ id: workflowVersion.id },
{ steps: updatedSteps },
);
this.logger.log(
`Updated workflow version ${workflowVersion.id} with migrated file paths`,
);
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Completed workflow send email attachments migration for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,320 @@
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, Or, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { 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<WorkspaceEntity>,
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<void> {
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<FileEntity>;
}): Promise<void> {
const workspace = await this.workspaceRepository.findOne({
where: {
id: workspaceId,
logo: Not(IsNull()),
logoFileId: Or(IsNull(), Not(Like(`%${FileFolder.CorePicture}%`))),
},
});
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<FileEntity>;
}): Promise<void> {
const { flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
const workspaceMemberObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.workspaceMember.universalIdentifier,
});
if (!isDefined(workspaceMemberObjectMetadata)) {
this.logger.warn(
`Workspace member object metadata not found for workspace ${workspaceId}, skipping member avatar migration`,
);
return;
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembers = await workspaceMemberRepository.find({
where: {
avatarUrl: And(Not(IsNull()), Not(Like(`%${FileFolder.CorePicture}%`))),
},
select: ['id', 'avatarUrl'],
});
if (workspaceMembers.length === 0) {
this.logger.log(
`No workspace member avatars to migrate for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`Found ${workspaceMembers.length} workspace member avatar(s) to migrate in workspace ${workspaceId}`,
);
for (const workspaceMember of workspaceMembers) {
if (!isNonEmptyString(workspaceMember.avatarUrl)) {
this.logger.warn(
`Skipping workspace member ${workspaceMember.id} - invalid avatarUrl`,
);
continue;
}
try {
const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow(
workspaceMember.avatarUrl,
);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
const newResourcePath = `${FileFolder.CorePicture}/${newFilename}`;
if (!isDryRun) {
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: workspaceMember.avatarUrl,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
filename: newResourcePath,
},
});
const fileEntity = fileRepository.create({
id: fileId,
path: newResourcePath,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
size: -1,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await fileRepository.save(fileEntity);
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
await workspaceMemberRepository.update(
{ id: workspaceMember.id },
{
avatarUrl: signedUrl,
},
);
}
this.logger.log(
`Migrated workspace member avatar ${workspaceMember.id}`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace member avatar ${workspaceMember.id} in workspace ${workspaceId}: ${error.message}`,
);
throw error;
}
}
}
}
@@ -6,18 +6,20 @@ import { BackfillMessageChannelThrottleRetryAfterCommand } from 'src/database/co
import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command';
import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command';
import { 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
imports: [
@@ -27,28 +29,31 @@ import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/perso
PersonWorkspaceEntity,
FileEntity,
AttachmentWorkspaceEntity,
WorkspaceMemberWorkspaceEntity,
]),
DataSourceModule,
FeatureFlagModule,
FileStorageModule.forRoot(),
WorkspaceCacheModule,
FieldMetadataModule,
ApplicationModule,
FilesFieldModule,
FileModule,
],
providers: [
MigratePersonAvatarFilesCommand,
MigrateAttachmentFilesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateWorkspacePicturesCommand,
MigrateActivityRichTextAttachmentFileIdsCommand,
BackfillMessageChannelThrottleRetryAfterCommand,
MigrateWorkflowSendEmailAttachmentsCommand,
],
exports: [
MigratePersonAvatarFilesCommand,
MigrateAttachmentFilesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateActivityRichTextAttachmentFileIdsCommand,
BackfillMessageChannelThrottleRetryAfterCommand,
MigrateWorkspacePicturesCommand,
BackfillFileSizeAndMimeTypeCommand,
MigrateWorkflowSendEmailAttachmentsCommand,
],
})
export class V1_18_UpgradeVersionCommandModule {}
@@ -20,10 +20,11 @@ import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
import { MigrateWorkflowCodeStepsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-workflow-code-steps.command';
import { BackfillFileSizeAndMimeTypeCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-file-size-and-mime-type.command';
import { BackfillMessageChannelThrottleRetryAfterCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-backfill-message-channel-throttle-retry-after.command';
import { MigrateActivityRichTextAttachmentFileIdsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-activity-rich-text-attachment-file-ids.command';
import { MigrateAttachmentFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-attachment-files.command';
import { 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';
@@ -60,7 +61,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillFileSizeAndMimeTypeCommand: BackfillFileSizeAndMimeTypeCommand,
protected readonly migrateAttachmentFilesCommand: MigrateAttachmentFilesCommand,
protected readonly migrateActivityRichTextAttachmentFileIdsCommand: MigrateActivityRichTextAttachmentFileIdsCommand,
protected readonly backfillMessageChannelThrottleRetryAfterCommand: BackfillMessageChannelThrottleRetryAfterCommand,
protected readonly migrateWorkspacePicturesCommand: MigrateWorkspacePicturesCommand,
protected readonly migrateWorkflowSendEmailAttachmentsCommand: MigrateWorkflowSendEmailAttachmentsCommand,
) {
super(
workspaceRepository,
@@ -90,8 +92,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.migratePersonAvatarFilesCommand,
this.migrateAttachmentFilesCommand,
this.migrateActivityRichTextAttachmentFileIdsCommand,
this.migrateWorkspacePicturesCommand,
this.migrateWorkflowSendEmailAttachmentsCommand,
this.backfillFileSizeAndMimeTypeCommand,
this.backfillMessageChannelThrottleRetryAfterCommand,
];
this.allCommands = {
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddLogoFileIdOnWorkspaceTable1770915831440
implements MigrationInterface
{
name = 'AddLogoFileIdOnWorkspaceTable1770915831440';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "logoFileId" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "logoFileId"`,
);
}
}
@@ -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,
),
],
@@ -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,
);
});
@@ -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({
@@ -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 {
@@ -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,
@@ -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,
@@ -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);
@@ -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;
@@ -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',
@@ -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', () => {
@@ -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;
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileAgentChatResolver } from 'src/engine/core-modules/file/file-agent-chat/resolvers/file-agent-chat.resolver';
import { FileAgentChatService } from 'src/engine/core-modules/file/file-agent-chat/file-agent-chat.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 { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
JwtModule,
PermissionsModule,
FileStorageModule,
ApplicationModule,
FileUrlModule,
],
providers: [FileAgentChatService, FileAgentChatResolver],
exports: [FileAgentChatService],
})
export class FileAgentChatModule {}
@@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
import path from 'path';
import { isNonEmptyString } from '@sniptt/guards';
import { CodeExecutionFile } from 'twenty-shared/ai';
import { FileFolder } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { OutputFile } from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
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 FileAgentChatService {
private readonly logger = new Logger(FileAgentChatService.name);
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
) {}
async uploadFile(
file: OutputFile,
workspaceId: string,
executionId: string,
): Promise<CodeExecutionFile | null> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const sanitizedFilename = path.basename(file.filename);
try {
const savedFile = await this.fileStorageService.writeFile({
sourceFile: file.content,
fileFolder: FileFolder.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
resourcePath: `${subFolder}/${sanitizedFilename}`,
mimeType: file.mimeType,
workspaceId,
settings: {
isTemporaryFile: true,
toDelete: false,
},
});
const signedUrl = this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.AgentChat,
});
return {
filename: sanitizedFilename,
url: signedUrl,
mimeType: file.mimeType,
};
} catch (error) {
this.logger.warn(`Failed to upload output file ${file.filename}`, error);
return null;
}
}
async uploadFileFromClient({
file,
filename,
mimeType,
workspaceId,
}: {
file: Buffer;
filename: string;
mimeType: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDto> {
const { 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.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: true,
toDelete: false,
},
});
return {
...savedFile,
url: this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.AgentChat,
}),
};
}
}
@@ -0,0 +1,45 @@
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 { FileAgentChatService } from 'src/engine/core-modules/file/file-agent-chat/file-agent-chat.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 FileAgentChatResolver {
constructor(private readonly fileAgentChatService: FileAgentChatService) {}
@Mutation(() => FileWithSignedUrlDto)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
async uploadAgentChatFile(
@AuthWorkspace()
{ id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, filename, mimetype }: FileUpload,
): Promise<FileWithSignedUrlDto> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return await this.fileAgentChatService.uploadFileFromClient({
file: buffer,
filename,
mimeType: mimetype,
workspaceId,
});
}
}
@@ -0,0 +1,27 @@
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 { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/file-core-picture.service';
import { FileCorePictureResolver } from 'src/engine/core-modules/file/file-core-picture/resolvers/file-core-picture.resolver';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.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,
],
providers: [FileCorePictureService, FileCorePictureResolver],
exports: [FileCorePictureService],
})
export class FileCorePictureModule {}
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Like, 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class FileCorePictureService {
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly fileUrlService: FileUrlService,
) {}
async uploadCorePicture({
file,
filename,
workspaceId,
}: {
file: Buffer;
filename: string;
workspaceId: string;
}): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({ file, filename });
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: `${FileFolder.CorePicture}/${finalName}`,
mimeType,
fileFolder: FileFolder.CorePicture,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
return savedFile;
}
async uploadWorkspacePicture({
file,
filename,
workspace,
}: {
file: Buffer;
filename: string;
workspace: WorkspaceEntity;
}): Promise<FileWithSignedUrlDto> {
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,
}: {
file: Buffer;
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDto> {
const savedFile = await this.uploadCorePicture({
file,
filename,
workspaceId,
});
const url = this.fileUrlService.signFileByIdUrl({
fileId: savedFile.id,
workspaceId,
fileFolder: FileFolder.CorePicture,
});
return {
...savedFile,
url,
};
}
async deleteCorePicture({
fileId,
workspaceId,
}: {
fileId: string;
workspaceId: string;
}): Promise<void> {
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),
});
}
}
@@ -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/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<FileWithSignedUrlDto> {
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<FileWithSignedUrlDto> {
const buffer = await streamToBuffer(createReadStream());
return await this.fileCorePictureService.uploadWorkspaceMemberProfilePicture(
{
file: buffer,
filename,
workspaceId,
},
);
}
}
@@ -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 {}
@@ -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}`;
}
}
@@ -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 {}
@@ -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<FileWithSignedUrlDto> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
return await this.fileWorkflowService.uploadFile({
file: buffer,
filename,
workspaceId,
});
}
}
@@ -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<FileWithSignedUrlDto> {
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.FilesField,
}),
};
}
}
@@ -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:
@@ -3,6 +3,7 @@ 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 { FileAgentChatModule } from 'src/engine/core-modules/file/file-agent-chat/file-agent-chat.module';
import { FilePathGuard } from 'src/engine/core-modules/file/guards/file-path-guard';
import { FileDeletionJob } from 'src/engine/core-modules/file/jobs/file-deletion.job';
import { FileWorkspaceFolderDeletionJob } from 'src/engine/core-modules/file/jobs/file-workspace-folder-deletion.job';
@@ -13,10 +14,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 +33,11 @@ import { FileService } from './services/file.service';
TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]),
PermissionsModule,
FileStorageModule,
FileUrlModule,
FilesFieldModule,
FileCorePictureModule,
FileAgentChatModule,
FileWorkflowModule,
SecureHttpClientModule,
],
providers: [
@@ -35,13 +45,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,
FileAgentChatModule,
],
controllers: [FileController, FileByIdController],
})
export class FileModule {}
@@ -3,10 +3,8 @@ 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 { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
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 { 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';
@@ -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 {}
@@ -1,27 +1,18 @@
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 { 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 { 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 {
@@ -37,10 +28,7 @@ export class FilesFieldService {
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly fileUrlService: FileUrlService,
) {}
async uploadFile({
@@ -53,7 +41,7 @@ export class FilesFieldService {
filename: string;
workspaceId: string;
fieldMetadataId: string;
}): Promise<FilesFieldFileDTO> {
}): Promise<FileWithSignedUrlDto> {
const { mimeType, ext } = await extractFileInfo({
file,
filename,
@@ -95,7 +83,11 @@ export class FilesFieldService {
return {
...savedFile,
url: this.signFileUrl({ fileId, workspaceId }),
url: this.fileUrlService.signFileByIdUrl({
fileId,
workspaceId,
fileFolder: FileFolder.FilesField,
}),
};
}
@@ -122,70 +114,4 @@ export class FilesFieldService {
);
}
}
async getFileStream({
fileId,
workspaceId,
}: {
fileId: string;
workspaceId: string;
}): Promise<Readable> {
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<FilesFieldTokenJwtPayload, 'type' | 'sub'>,
) {
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}`;
}
}
@@ -1,54 +0,0 @@
import {
type CanActivate,
type 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 { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class FilesFieldGuard implements CanActivate {
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const fileToken = request.query.token;
const fileId = request.params.id;
if (!fileToken) {
return false;
}
try {
const payload = await this.jwtWrapperService.verifyJwtToken(fileToken, {
ignoreExpiration:
fileFolderConfigs[FileFolder.FilesField].ignoreExpirationToken,
});
if (!payload.workspaceId) {
return false;
}
} catch {
return false;
}
const decodedPayload =
this.jwtWrapperService.decode<FilesFieldTokenJwtPayload>(fileToken, {
json: true,
});
request.workspaceId = decodedPayload.workspaceId;
if (decodedPayload.fileId !== fileId) {
return false;
}
return true;
}
}
@@ -6,7 +6,8 @@ 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 { 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/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';
@@ -14,7 +15,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)
@@ -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<FilesFieldFileDTO> {
): Promise<FileWithSignedUrlDto> {
const stream = createReadStream();
const buffer = await streamToBuffer(stream);
@@ -0,0 +1,51 @@
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/${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/${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/${FileFolder.FilesField}/`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null);
});
it('should work with different file folders', () => {
const corePictureUrl = `https://example.com/${FileFolder.CorePicture}/${validUuid}`;
expect(extractFileIdFromUrl(corePictureUrl, FileFolder.CorePicture)).toBe(
validUuid,
);
});
it('should return null when file folder does not match', () => {
const url = `https://example.com/${FileFolder.CorePicture}/${validUuid}`;
expect(extractFileIdFromUrl(url, FileFolder.FilesField)).toBe(null);
});
});
@@ -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;
};
@@ -0,0 +1,70 @@
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 { 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,
FileFolder.AgentChat,
] as const;
export type SupportedFileFolder = (typeof SUPPORTED_FILE_FOLDERS)[number];
@Injectable()
export class FileByIdGuard implements CanActivate {
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
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;
}
try {
const payload = await this.jwtWrapperService.verifyJwtToken(fileToken, {
ignoreExpiration: fileFolderConfigs[fileFolder].ignoreExpirationToken,
});
if (!payload.workspaceId) {
return false;
}
} catch {
return false;
}
const decodedPayload = this.jwtWrapperService.decode<FileTokenJwtPayload>(
fileToken,
{
json: true,
},
);
request.workspaceId = decodedPayload.workspaceId;
if (decodedPayload.fileId !== fileId) {
return false;
}
return true;
}
private isSupportedFileFolder(
fileFolder: string,
): fileFolder is SupportedFileFolder {
return SUPPORTED_FILE_FOLDERS.includes(fileFolder as SupportedFileFolder);
}
}
@@ -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<FileTokenJwtPayload>(
fileSignature,
{
const decodedPayload =
this.jwtWrapperService.decode<FileTokenJwtPayloadLegacy>(fileSignature, {
json: true,
},
);
});
request.workspaceId = decodedPayload.workspaceId;
@@ -24,6 +24,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.PersonPicture]: {
ignoreExpirationToken: false,
},
[FileFolder.CorePicture]: {
ignoreExpirationToken: true,
},
[FileFolder.File]: {
ignoreExpirationToken: false,
},
@@ -48,6 +51,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.Dependencies]: {
ignoreExpirationToken: false,
},
[FileFolder.Workflow]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -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 specfic 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,
@@ -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,
@@ -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<FileEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async getFileStream(
@@ -38,6 +48,40 @@ export class FileService {
});
}
async getFileStreamById({
fileId,
workspaceId,
fileFolder,
}: {
fileId: string;
workspaceId: string;
fileFolder: FileFolder;
}): Promise<Readable> {
{
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<FileTokenJwtPayload, 'type' | 'sub'>) {
encodeFileToken(
payloadToEncode: Omit<FileTokenJwtPayloadLegacy, 'type' | 'sub'>,
) {
const fileTokenExpiresIn = this.twentyConfigService.get(
'FILE_TOKEN_EXPIRES_IN',
);
const payload: FileTokenJwtPayload = {
const payload: FileTokenJwtPayloadLegacy = {
...payloadToEncode,
sub: payloadToEncode.workspaceId,
type: JwtTokenTypeEnum.FILE,
@@ -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,
@@ -7,8 +7,8 @@ import {
type CodeExecutionFile,
type CodeExecutionState,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { FileFolder } from 'twenty-shared/types';
import {
type InputFile,
@@ -20,8 +20,7 @@ import {
JwtTokenTypeEnum,
} from 'src/engine/core-modules/auth/types/auth-context.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { FileAgentChatService } from 'src/engine/core-modules/file/file-agent-chat/file-agent-chat.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { CodeInterpreterInputZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema';
@@ -47,8 +46,7 @@ export class CodeInterpreterTool implements Tool {
constructor(
private readonly codeInterpreterService: CodeInterpreterService,
private readonly fileStorageService: FileStorageService,
private readonly fileService: FileService,
private readonly fileAgentChatService: FileAgentChatService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtWrapperService: JwtWrapperService,
@@ -154,7 +152,7 @@ export class CodeInterpreterTool implements Tool {
);
},
onResult: async (outputFile: OutputFile) => {
const uploadedFile = await this.uploadSingleFile(
const uploadedFile = await this.fileAgentChatService.uploadFile(
outputFile,
workspaceId,
executionId,
@@ -342,56 +340,15 @@ export class CodeInterpreterTool implements Tool {
});
}
private async uploadSingleFile(
file: OutputFile,
workspaceId: string,
executionId: string,
): Promise<CodeExecutionFile | null> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const folder = `workspace-${workspaceId}/${subFolder}`;
const sanitizedFilename = path.basename(file.filename);
try {
await this.fileStorageService.writeFileLegacy({
file: file.content,
name: sanitizedFilename,
mimeType: file.mimeType,
folder,
});
const filePath = `${subFolder}/${sanitizedFilename}`;
const signedPath = this.fileService.signFileUrl({
url: filePath,
workspaceId,
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return {
filename: sanitizedFilename,
url: `${serverUrl}/files/${signedPath}`,
mimeType: file.mimeType,
};
} catch (error) {
this.logger.warn(`Failed to upload output file ${file.filename}`, error);
return null;
}
}
private async uploadOutputFiles(
files: OutputFile[],
workspaceId: string,
executionId: string,
alreadyUploadedFiles: CodeExecutionFile[],
): Promise<CodeExecutionFile[]> {
const subFolder = `${FileFolder.AgentChat}/code-interpreter/${executionId}`;
const folder = `workspace-${workspaceId}/${subFolder}`;
const outputFileUrls: CodeExecutionFile[] = [...alreadyUploadedFiles];
const uploadedFilenames = new Set(
alreadyUploadedFiles.map((f) => f.filename),
alreadyUploadedFiles.map((file) => file.filename),
);
for (const file of files) {
@@ -401,32 +358,14 @@ export class CodeInterpreterTool implements Tool {
continue;
}
try {
await this.fileStorageService.writeFileLegacy({
file: file.content,
name: sanitizedFilename,
mimeType: file.mimeType,
folder,
});
const uploadedFile = await this.fileAgentChatService.uploadFile(
file,
workspaceId,
executionId,
);
const filePath = `${subFolder}/${sanitizedFilename}`;
const signedPath = this.fileService.signFileUrl({
url: filePath,
workspaceId,
});
const serverUrl = this.twentyConfigService.get('SERVER_URL');
outputFileUrls.push({
filename: sanitizedFilename,
url: `${serverUrl}/files/${signedPath}`,
mimeType: file.mimeType,
});
} catch (error) {
this.logger.warn(
`Failed to upload output file ${file.filename}`,
error,
);
if (isDefined(uploadedFile)) {
outputFileUrls.push(uploadedFile);
}
}
@@ -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<FileEntity>,
private readonly fileService: FileService,
private readonly featureFlagService: FeatureFlagService,
) {}
private async isOtherFileMigrated(workspaceId: string): Promise<boolean> {
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<WorkflowAttachment>,
workspaceId: string,
): Promise<MessageAttachment[]> {
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);
@@ -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,
@@ -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/file-core-picture.service';
import {
FileWorkspaceFolderDeletionJob,
type FileWorkspaceFolderDeletionJobData,
@@ -108,6 +109,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
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<WorkspaceEntity> {
);
}
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<WorkspaceEntity> {
}
throw error;
}
if (payload.logo === null && isDefined(workspace.logoFileId)) {
await this.fileCorePictureService.deleteCorePicture({
fileId: workspace.logoFileId,
workspaceId: workspace.id,
});
}
return updatedWorkspace;
}
async activateWorkspace(
@@ -72,10 +72,15 @@ export class WorkspaceEntity {
@Column({ nullable: true })
displayName?: string;
//deprecated
@Field({ nullable: true })
@Column({ nullable: true })
logo?: string;
@Field({ nullable: true })
@Column({ nullable: true })
logoFileId?: string;
@Field({ nullable: true })
@Column({ nullable: true })
inviteHash?: string;
@@ -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<string> {
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({
@@ -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';
@@ -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: {
@@ -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: [],
@@ -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/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<WorkspaceMemberWorkspaceEntity>
>,
) {
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<WorkspaceMemberWorkspaceEntity>
| ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
const fileIdsToDelete =
this.getFileIdsToDeleteFromDestroyOrDeleteEvent(payload);
await this.deleteCorePictures(fileIdsToDelete, payload.workspaceId);
}
private async deleteCorePictures(
fileIds: string[],
workspaceId: string,
): Promise<void> {
for (const fileId of fileIds) {
await this.fileCorePictureService.deleteCorePicture({
workspaceId,
fileId,
});
}
}
private getFileIdsToDeleteFromUpdateEvent(
payload: WorkspaceEventBatch<
ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
>,
): 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<WorkspaceMemberWorkspaceEntity>
>
| WorkspaceEventBatch<
ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
>,
): string[] {
return payload.events
.map((event) =>
isDefined(event.properties.before.avatarUrl)
? extractFileIdFromUrl(
event.properties.before.avatarUrl,
FileFolder.CorePicture,
)
: undefined,
)
.filter(isDefined);
}
}
@@ -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 {}
@@ -57,7 +57,7 @@ const deleteFile = async (fileId: string): Promise<void> => {
});
};
describe('files-field.controller - GET /files-field/:id', () => {
describe('file-by-id.controller - GET /file/:fileFolder/:id', () => {
let createdObjectMetadataId = '';
let createdFieldMetadataId = '';
let uploadedFiles: UploadedFile[] = [];
@@ -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',
}
@@ -66,8 +66,10 @@ export { workflowRunStateStepInfosSchema } from './schemas/workflow-run-state-st
export { workflowRunStatusSchema } from './schemas/workflow-run-status-schema';
export { workflowRunStepStatusSchema } from './schemas/workflow-run-step-status-schema';
export { workflowTriggerSchema } from './schemas/workflow-trigger-schema';
export type { EmailFormData } from './types/EmailFormData';
export type { EmailRecipients } from './types/EmailRecipients';
export type { StepIfElseBranch } from './types/StepIfElseBranch';
export type { WorkflowAttachment } from './types/WorkflowAttachment';
export type { BodyType } from './types/workflowHttpRequestStep';
export type {
WorkflowRunStepInfo,
@@ -0,0 +1,10 @@
import { type EmailRecipients } from '@/workflow/types/EmailRecipients';
import { type WorkflowAttachment } from '@/workflow/types/WorkflowAttachment';
export type EmailFormData = {
connectedAccountId: string;
recipients: Required<EmailRecipients>;
subject: string;
body: string;
files: WorkflowAttachment[];
};
@@ -1,4 +1,4 @@
export type WorkflowAttachmentType = {
export type WorkflowAttachment = {
id: string;
name: string;
size: number;