Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c65e5ec9c | ||
|
|
ee917c2c7d | ||
|
|
b7ee3242a8 | ||
|
|
ff53e15a8a | ||
|
|
25109dfb72 | ||
|
|
f78440dbe0 | ||
|
|
2ee6e0a6a8 | ||
|
|
7eb82b0181 | ||
|
|
39390988fa | ||
|
|
6613b01bab |
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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;
|
||||
};
|
||||
|
||||
+7
-9
@@ -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}
|
||||
|
||||
+47
-31
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+10
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+55
-21
@@ -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,
|
||||
|
||||
+34
-12
@@ -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 () => {
|
||||
|
||||
-1
@@ -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';
|
||||
|
||||
-10
@@ -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[];
|
||||
};
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import {
|
||||
type WorkflowDraftEmailAction,
|
||||
type WorkflowSendEmailAction,
|
||||
} from '@/workflow/types/Workflow';
|
||||
|
||||
export type WorkflowEmailAction =
|
||||
| WorkflowSendEmailAction
|
||||
| WorkflowDraftEmailAction;
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+10
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+9
-5
@@ -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({
|
||||
|
||||
+225
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+320
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -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 {}
|
||||
|
||||
+6
-3
@@ -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 = {
|
||||
|
||||
+19
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
+4
-4
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
+9
-4
@@ -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({
|
||||
|
||||
+13
-5
@@ -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,
|
||||
|
||||
+39
-1
@@ -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;
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+13
-8
@@ -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
-2
@@ -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;
|
||||
|
||||
+22
@@ -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 {}
|
||||
+126
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
+45
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
@@ -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 {}
|
||||
+167
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+66
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+14
@@ -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 {}
|
||||
+44
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -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 {}
|
||||
|
||||
+2
-6
@@ -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 {}
|
||||
|
||||
+10
-84
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
-54
@@ -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;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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);
|
||||
|
||||
|
||||
+51
@@ -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);
|
||||
});
|
||||
});
|
||||
+7
-3
@@ -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;
|
||||
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+12
-73
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-6
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+14
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+2
@@ -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: [],
|
||||
|
||||
+120
@@ -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 {}
|
||||
+1
-1
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
export type WorkflowAttachmentType = {
|
||||
export type WorkflowAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
Reference in New Issue
Block a user