diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index af7d56eea4..b16647e030 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1562,7 +1562,6 @@ export enum FileFolder { ProfilePicture = 'ProfilePicture', PublicAsset = 'PublicAsset', Source = 'Source', - TemporaryFilesField = 'TemporaryFilesField', WorkspaceLogo = 'WorkspaceLogo' } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 25aad9513c..800d4f223f 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1529,7 +1529,6 @@ export enum FileFolder { ProfilePicture = 'ProfilePicture', PublicAsset = 'PublicAsset', Source = 'Source', - TemporaryFilesField = 'TemporaryFilesField', WorkspaceLogo = 'WorkspaceLogo' } diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1769434782880-add-file-settings-column-on-file-table.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769434782880-add-file-settings-column-on-file-table.ts new file mode 100644 index 0000000000..31b3d0cee5 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1769434782880-add-file-settings-column-on-file-table.ts @@ -0,0 +1,15 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddFileSettingsColumnOnFileTable1769434782880 + implements MigrationInterface +{ + name = 'AddFileSettingsColumnOnFileTable1769434782880'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "core"."file" ADD "settings" jsonb`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "core"."file" DROP COLUMN "settings"`); + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts index bb06119545..9e4c365e9c 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/data-arg.processor.ts @@ -251,7 +251,7 @@ export class DataArgProcessor { const validatedValue = validateFilesFieldOrThrow( value, key, - fieldMetadata.settings as FieldMetadataSettingsMapping['FILES'], + fieldMetadata.settings as FieldMetadataSettingsMapping[FieldMetadataType.FILES], ); return transformRawJsonField(validatedValue); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts new file mode 100644 index 0000000000..43488dce6b --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type.ts @@ -0,0 +1,4 @@ +export type FileItemInput = { + fileId: string; + label: string; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts index 72d1bd48d9..706fc8b10f 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/__tests__/validate-files-field-or-throw.util.spec.ts @@ -2,43 +2,68 @@ import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-pro import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; describe('validateFilesFieldOrThrow', () => { + const mockSettings = { maxNumberOfValues: 10 }; + describe('valid inputs', () => { it('should return null when value is null', () => { - const result = validateFilesFieldOrThrow(null, 'testField', { - maxNumberOfValues: 10, - }); + const result = validateFilesFieldOrThrow(null, 'testField', mockSettings); expect(result).toBeNull(); }); - it('should return the files array when all fields are valid', () => { + it('should return the files array when value is valid', () => { const filesValue = [ - { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' }, - { fileId: '660e8400-e29b-41d4-a716-446655440001', label: 'Document 2' }, + { + fileId: '550e8400-e29b-41d4-a716-446655440000', + label: 'Document 1', + }, + { + fileId: '660e8400-e29b-41d4-a716-446655440001', + label: 'Document 2', + }, ]; - const result = validateFilesFieldOrThrow(filesValue, 'testField', { - maxNumberOfValues: 10, - }); + const result = validateFilesFieldOrThrow( + filesValue, + 'testField', + mockSettings, + ); expect(result).toEqual(filesValue); }); it('should return an empty array when value is an empty array', () => { - const result = validateFilesFieldOrThrow([], 'testField', { - maxNumberOfValues: 10, - }); + const result = validateFilesFieldOrThrow([], 'testField', mockSettings); expect(result).toEqual([]); }); it('should parse and return valid stringified JSON array', () => { const filesValue = [ - { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' }, + { + fileId: '550e8400-e29b-41d4-a716-446655440000', + label: 'Document 1', + }, ]; const stringifiedValue = JSON.stringify(filesValue); - const result = validateFilesFieldOrThrow(stringifiedValue, 'testField', { - maxNumberOfValues: 10, - }); + const result = validateFilesFieldOrThrow( + stringifiedValue, + 'testField', + mockSettings, + ); + + expect(result).toEqual(filesValue); + }); + + it('should accept files array up to max limit', () => { + const filesValue = Array.from({ length: 10 }, (_, index) => ({ + fileId: `550e8400-e29b-41d4-a716-44665544000${index}`, + label: `Document ${index}`, + })); + const result = validateFilesFieldOrThrow( + filesValue, + 'testField', + mockSettings, + ); expect(result).toEqual(filesValue); }); @@ -47,51 +72,39 @@ describe('validateFilesFieldOrThrow', () => { describe('invalid inputs', () => { it('should throw when value is an invalid JSON string', () => { expect(() => - validateFilesFieldOrThrow('not valid json', 'testField', { - maxNumberOfValues: 10, - }), + validateFilesFieldOrThrow('not valid json', 'testField', mockSettings), ).toThrow(CommonQueryRunnerException); }); - it('should throw when value is not an array', () => { + it('should throw when value is an object instead of array', () => { expect(() => validateFilesFieldOrThrow( { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' }, 'testField', - { maxNumberOfValues: 10 }, + mockSettings, ), ).toThrow(CommonQueryRunnerException); }); - it('should throw when value is undefined', () => { - expect(() => - validateFilesFieldOrThrow(undefined, 'testField', { - maxNumberOfValues: 10, - }), - ).toThrow(CommonQueryRunnerException); - }); - it('should throw when array item is not an object', () => { expect(() => - validateFilesFieldOrThrow(['not an object'], 'testField', { - maxNumberOfValues: 10, - }), + validateFilesFieldOrThrow(['not an object'], 'testField', mockSettings), ).toThrow(CommonQueryRunnerException); }); it('should throw when array item is null', () => { expect(() => - validateFilesFieldOrThrow([null], 'testField', { - maxNumberOfValues: 10, - }), + validateFilesFieldOrThrow([null], 'testField', mockSettings), ).toThrow(CommonQueryRunnerException); }); it('should throw when fileId key is missing', () => { expect(() => - validateFilesFieldOrThrow([{ label: 'test' }], 'testField', { - maxNumberOfValues: 10, - }), + validateFilesFieldOrThrow( + [{ label: 'test' }], + 'testField', + mockSettings, + ), ).toThrow(CommonQueryRunnerException); }); @@ -100,12 +113,12 @@ describe('validateFilesFieldOrThrow', () => { validateFilesFieldOrThrow( [{ fileId: '550e8400-e29b-41d4-a716-446655440000' }], 'testField', - { maxNumberOfValues: 10 }, + mockSettings, ), ).toThrow(CommonQueryRunnerException); }); - it('should throw when extra keys are present', () => { + it('should throw when extra keys are present in file item', () => { expect(() => validateFilesFieldOrThrow( [ @@ -116,7 +129,7 @@ describe('validateFilesFieldOrThrow', () => { }, ], 'testField', - { maxNumberOfValues: 10 }, + mockSettings, ), ).toThrow(CommonQueryRunnerException); }); @@ -126,7 +139,7 @@ describe('validateFilesFieldOrThrow', () => { validateFilesFieldOrThrow( [{ fileId: 'not-a-uuid', label: 'test' }], 'testField', - { maxNumberOfValues: 10 }, + mockSettings, ), ).toThrow(CommonQueryRunnerException); }); @@ -136,7 +149,7 @@ describe('validateFilesFieldOrThrow', () => { validateFilesFieldOrThrow( [{ fileId: 12345, label: 'test' }], 'testField', - { maxNumberOfValues: 10 }, + mockSettings, ), ).toThrow(CommonQueryRunnerException); }); @@ -146,20 +159,19 @@ describe('validateFilesFieldOrThrow', () => { validateFilesFieldOrThrow( [{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }], 'testField', - { maxNumberOfValues: 10 }, + mockSettings, ), ).toThrow(CommonQueryRunnerException); }); - it('should throw when max number of files is exceeded', () => { + + it('should throw when number of files exceeds max limit', () => { + const filesValue = Array.from({ length: 11 }, (_, index) => ({ + fileId: `550e8400-e29b-41d4-a716-44665544000${index}`, + label: `Document ${index}`, + })); + expect(() => - validateFilesFieldOrThrow( - [ - { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' }, - { fileId: '550e8400-e29b-41d4-a716-446655440001', label: 'test' }, - ], - 'testField', - { maxNumberOfValues: 1 }, - ), + validateFilesFieldOrThrow(filesValue, 'testField', mockSettings), ).toThrow(CommonQueryRunnerException); }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts index 67ae44113f..9e19b393be 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts @@ -2,15 +2,19 @@ import { inspect } from 'util'; import { msg } from '@lingui/core/macro'; import { isNull } from '@sniptt/guards'; +import { + type FieldMetadataSettingsMapping, + type FieldMetadataType, +} from 'twenty-shared/types'; import { z } from 'zod'; -import { type FieldMetadataSettingsMapping } from 'twenty-shared/types'; +import { type FileItemInput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type'; import { CommonQueryRunnerException, CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; -export const fileItemSchema = z +const fileItemSchema = z .object({ fileId: z.string().uuidv4(), label: z.string(), @@ -19,13 +23,11 @@ export const fileItemSchema = z export const filesFieldSchema = z.array(fileItemSchema); -export type FileItem = z.infer; - export const validateFilesFieldOrThrow = ( value: unknown, fieldName: string, - settings: FieldMetadataSettingsMapping['FILES'], -): FileItem[] | null => { + settings: FieldMetadataSettingsMapping[FieldMetadataType.FILES], +): FileItemInput[] | null => { if (isNull(value)) return null; let parsedValue: unknown = value; @@ -67,10 +69,10 @@ export const validateFilesFieldOrThrow = ( const maxNumberOfValues = settings.maxNumberOfValues; throw new CommonQueryRunnerException( - `Max number of files is ${maxNumberOfValues}`, + `Max number of files is ${maxNumberOfValues} for field "${fieldName}"`, CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA, { - userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues}`, + userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues} for field "${fieldName}"`, }, ); } diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts index bbea2e9203..91a587c43e 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts @@ -6,7 +6,6 @@ import { ObjectRecord } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm'; -import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type'; import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util'; @@ -31,6 +30,7 @@ import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runne import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts index 29d5149000..e2dad154fb 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-update-many-query-runner.service.ts @@ -5,7 +5,6 @@ import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants'; import { ObjectRecord } from 'twenty-shared/types'; import { FindOptionsRelations, ObjectLiteral } from 'typeorm'; -import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; import { CommonQueryRunnerException, @@ -22,6 +21,7 @@ import { } from 'src/engine/api/common/types/common-query-args.type'; import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; +import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts index 29cec4067e..b4ca9af46e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type.ts @@ -7,12 +7,12 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -const FileInputType = new GraphQLInputObjectType({ - name: 'FileInput', +const FileItemInputType = new GraphQLInputObjectType({ + name: 'FileItemInput', fields: { fileId: { type: new GraphQLNonNull(UUIDScalarType) }, label: { type: new GraphQLNonNull(GraphQLString) }, }, }); -export const FilesInputType = new GraphQLList(FileInputType); +export const FilesInputType = new GraphQLList(FileItemInputType); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts index fc4f6a3737..12dc821a01 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type.ts @@ -1,29 +1,20 @@ import { - GraphQLEnumType, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLString, } from 'graphql'; -import { FILE_CATEGORIES } from 'twenty-shared/types'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; -const FileCategoryEnumType = new GraphQLEnumType({ - name: 'FileCategory', - values: Object.fromEntries( - Object.values(FILE_CATEGORIES).map((value) => [value, { value }]), - ), -}); - const FileObjectType = new GraphQLObjectType({ name: 'FileObject', fields: { fileId: { type: new GraphQLNonNull(UUIDScalarType) }, label: { type: new GraphQLNonNull(GraphQLString) }, - fileCategory: { type: FileCategoryEnumType }, + extension: { type: GraphQLString }, //TODO: Will be made non-nullable in a future PR - // fileCategory: { type: new GraphQLNonNull(FileCategoryEnumType) }, + // extension: { type: new GraphQLNonNull(GraphQLString) }, }, }); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 6b387f534e..6ea852ccc3 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -110,7 +110,11 @@ export class TypeMapperService { }: { fieldMetadataType: FieldMetadataType; typeOptions?: TypeOptions; - }): GraphQLScalarType | GraphQLList | undefined { + }): + | GraphQLScalarType + | GraphQLList + | GraphQLInputObjectType + | undefined { if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) { return GraphQLID; } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index dce64ebb6d..4cc32acb49 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -4,11 +4,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { type Readable } from 'stream'; import { FileFolder, Sources } from 'twenty-shared/types'; -import { Repository } from 'typeorm'; +import { Like, Repository } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; @Injectable() //TODO: Implement storage driver interface when removing v1 @@ -50,6 +51,7 @@ export class FileStorageService { applicationUniversalIdentifier, workspaceId, fileId, + settings, }: { sourceFile: string | Buffer | Uint8Array; destinationPath: string; @@ -58,6 +60,7 @@ export class FileStorageService { applicationUniversalIdentifier: string; workspaceId: string; fileId?: string; + settings: FileSettings; }): Promise { const driver = this.fileStorageDriverFactory.getCurrentDriver(); @@ -85,6 +88,7 @@ export class FileStorageService { typeof sourceFile === 'string' ? Buffer.byteLength(sourceFile) : sourceFile.length, + settings, }); return fileEntity; @@ -136,6 +140,32 @@ export class FileStorageService { return driver.delete(params); } + async deleteByFileId({ + fileId, + workspaceId, + fileFolder, + }: { + fileId: string; + workspaceId: string; + fileFolder: FileFolder; + }): Promise { + const file = await this.fileRepository.findOneOrFail({ + where: { + id: fileId, + workspaceId, + path: Like(`${fileFolder}/%`), + }, + }); + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + await driver.delete({ + folderPath: `${file.workspaceId}/${file.applicationId}`, + filename: file.path, + }); + + await this.fileRepository.delete(fileId); + } + move(params: { from: { folderPath: string; filename?: string }; to: { folderPath: string; filename?: string }; @@ -154,6 +184,50 @@ export class FileStorageService { return driver.copy(params); } + async moveFile({ + from, + to, + workspaceId, + }: { + from: { + applicationId: string; + fileFolder: FileFolder; + destinationPath: string; + }; + to: { + applicationId: string; + fileFolder: FileFolder; + destinationPath: string; + }; + workspaceId: string; + }): Promise { + const file = await this.fileRepository.findOneOrFail({ + where: { + workspaceId, + applicationId: from.applicationId, + path: `${from.fileFolder}/${from.destinationPath}`, + }, + }); + + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + await driver.move({ + from: { + folderPath: `${file.workspaceId}/${from.applicationId}/${from.fileFolder}`, + filename: from.destinationPath, + }, + to: { + folderPath: `${file.workspaceId}/${to.applicationId}/${to.fileFolder}`, + filename: to.destinationPath, + }, + }); + + await this.fileRepository.update(file.id, { + applicationId: to.applicationId, + path: `${to.fileFolder}/${to.destinationPath}`, + }); + } + download(params: { from: { folderPath: string; filename?: string }; to: { folderPath: string; filename?: string }; diff --git a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts index 293cbf0ff2..3d6afe1f8f 100644 --- a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts @@ -12,6 +12,7 @@ import { } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; @Entity('file') @@ -46,4 +47,7 @@ export class FileEntity extends WorkspaceRelatedEntity { @Column({ nullable: false, default: false }) isStaticAsset: boolean; + + @Column({ nullable: true, type: 'jsonb' }) + settings: FileSettings | null; } diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index 27175e2152..8c2bc4784e 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -256,6 +256,10 @@ export class FileUploadService { applicationUniversalIdentifier: application.universalIdentifier, workspaceId, fileId, + settings: { + isTemporaryFile: true, + toDelete: false, + }, }); } } diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts index 4c6a70251d..a6db918773 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts @@ -3,6 +3,9 @@ import { Module } from '@nestjs/common'; 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 { 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 { 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'; @@ -10,11 +13,13 @@ import { FileAttachmentListener } from 'src/engine/core-modules/file/listeners/f import { FileWorkspaceMemberListener } from 'src/engine/core-modules/file/listeners/file-workspace-member.listener'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { FileController } from './controllers/file.controller'; import { FileEntity } from './entities/file.entity'; import { FileUploadService } from './file-upload/services/file-upload.service'; +import { FilesFieldService } from './files-field/files-field.service'; import { FileResolver } from './resolvers/file.resolver'; import { FileMetadataService } from './services/file-metadata.service'; import { FileService } from './services/file.service'; @@ -25,19 +30,24 @@ import { FileService } from './services/file.service'; TypeOrmModule.forFeature([FileEntity, WorkspaceEntity, ApplicationEntity]), HttpModule, PermissionsModule, + FileStorageModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], providers: [ FileService, FileMetadataService, + FilesFieldService, FileResolver, FilePathGuard, FileAttachmentListener, FileWorkspaceMemberListener, + FilesFieldDeletionListener, FileWorkspaceFolderDeletionJob, FileDeletionJob, + FilesFieldDeletionJob, FileUploadService, ], - exports: [FileService, FileMetadataService], + exports: [FileService, FileMetadataService, FilesFieldService], controllers: [FileController], }) export class FileModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts new file mode 100644 index 0000000000..69b4f3c58b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.exception.ts @@ -0,0 +1,19 @@ +import { type MessageDescriptor } from '@lingui/core'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum FilesFieldExceptionCode { + FILE_DELETION_FAILED = 'FILE_DELETION_FAILED', +} + +export class FilesFieldException extends CustomException { + constructor( + message: string, + code: FilesFieldExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor }, + ) { + super(message, code, { + userFriendlyMessage, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts new file mode 100644 index 0000000000..48383eca45 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/files-field.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { msg } from '@lingui/core/macro'; +import { FileFolder } from 'twenty-shared/types'; + +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; + +import { + FilesFieldException, + FilesFieldExceptionCode, +} from './files-field.exception'; + +@Injectable() +export class FilesFieldService { + constructor(private readonly fileStorageService: FileStorageService) {} + + async deleteFilesFieldFile({ + fileId, + workspaceId, + }: { + fileId: string; + workspaceId: string; + }): Promise { + try { + await this.fileStorageService.deleteByFileId({ + fileId, + workspaceId, + fileFolder: FileFolder.FilesField, + }); + } catch (error) { + throw new FilesFieldException( + `Failed to delete file ${fileId}: ${error.message}`, + FilesFieldExceptionCode.FILE_DELETION_FAILED, + { + userFriendlyMessage: msg`Failed to delete file ${fileId}`, + }, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts new file mode 100644 index 0000000000..ddc5b59317 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/jobs/files-field-deletion.job.ts @@ -0,0 +1,40 @@ +import { Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { FilesFieldService } from 'src/engine/core-modules/file/files-field/files-field.service'; +import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; +import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; + +export type FilesFieldDeletionJobData = { + workspaceId: string; + fileIds: string[]; +}; + +@Processor(MessageQueue.deleteCascadeQueue) +export class FilesFieldDeletionJob { + private readonly logger = new Logger(FilesFieldDeletionJob.name); + + constructor(private readonly filesFieldService: FilesFieldService) {} + + @Process(FilesFieldDeletionJob.name) + async handle(data: FilesFieldDeletionJobData): Promise { + const { workspaceId, fileIds } = data; + + if (!isDefined(fileIds) || fileIds.length === 0) { + return; + } + + for (const fileId of fileIds) { + try { + await this.filesFieldService.deleteFilesFieldFile({ + fileId, + workspaceId, + }); + } catch { + this.logger.log(`Failed to delete file ${fileId}`); + } + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener.ts new file mode 100644 index 0000000000..c7db1e7d64 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/listeners/files-field-deletion.listener.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; + +import { type ObjectRecordDestroyEvent } from 'twenty-shared/database-events'; +import { FieldMetadataType } 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 { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { + FilesFieldDeletionJob, + FilesFieldDeletionJobData, +} from 'src/engine/core-modules/file/files-field/jobs/files-field-deletion.job'; +import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; +import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; +import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; + +type FileItem = { + fileId: string; + label: string; + extension?: string; +}; + +@Injectable() +export class FilesFieldDeletionListener { + constructor( + @InjectMessageQueue(MessageQueue.deleteCascadeQueue) + private readonly messageQueueService: MessageQueueService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + @OnDatabaseBatchEvent('*', DatabaseEventAction.DESTROYED) + async handleDestroyedEvent( + payload: WorkspaceEventBatch, + ) { + const workspaceId = payload.workspaceId; + const objectMetadata = payload.objectMetadata; + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const { idByNameSingular } = buildObjectIdByNameMaps( + flatObjectMetadataMaps, + ); + + const objectId = idByNameSingular[objectMetadata.nameSingular]; + + if (!isDefined(objectId)) { + return; + } + + const flatObjectMetadata = flatObjectMetadataMaps.byId[objectId]; + + if (!isDefined(flatObjectMetadata)) { + return; + } + + const objectFields = getFlatFieldsFromFlatObjectMetadata( + flatObjectMetadata, + flatFieldMetadataMaps, + ); + + const filesFields = objectFields.filter( + (field) => field.type === FieldMetadataType.FILES, + ); + + if (filesFields.length === 0) { + return; + } + + const fileIds = new Set(); + + for (const event of payload.events) { + const recordBefore = event.properties.before as Record; + + for (const filesField of filesFields) { + const filesValue = recordBefore[filesField.name]; + + if (!isDefined(filesValue)) { + continue; + } + + const fileItems = filesValue as FileItem[]; + + for (const fileItem of fileItems) { + if (!isDefined(fileItem.fileId)) { + continue; + } + + fileIds.add(fileItem.fileId); + } + } + } + + if (fileIds.size > 0) { + await this.messageQueueService.add( + FilesFieldDeletionJob.name, + { + workspaceId, + fileIds: Array.from(fileIds), + }, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts index 4c54ae370b..0318cd2697 100644 --- a/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file/interfaces/file-folder.interface.ts @@ -51,9 +51,6 @@ export const fileFolderConfigs: Record = { [FileFolder.FilesField]: { ignoreExpirationToken: false, }, - [FileFolder.TemporaryFilesField]: { - ignoreExpirationToken: false, - }, }; export type AllowedFolders = KebabCase; diff --git a/packages/twenty-server/src/engine/core-modules/file/types/file-settings.types.ts b/packages/twenty-server/src/engine/core-modules/file/types/file-settings.types.ts new file mode 100644 index 0000000000..5b1512824f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/types/file-settings.types.ts @@ -0,0 +1,6 @@ +export type FileFieldSettings = { + isTemporaryFile: boolean; + toDelete: boolean; +}; + +export type FileSettings = FileFieldSettings; diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/remove-file-folder-from-file-entity-path.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/remove-file-folder-from-file-entity-path.utils.spec.ts new file mode 100644 index 0000000000..d2c96bb076 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/utils/__tests__/remove-file-folder-from-file-entity-path.utils.spec.ts @@ -0,0 +1,45 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; + +import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; + +describe('removeFileFolderFromFileEntityPath', () => { + it('should remove file folder prefix from path', () => { + expect( + removeFileFolderFromFileEntityPath(`${FileFolder.Attachment}/file.txt`), + ).toBe('file.txt'); + }); + + it('should handle nested paths correctly', () => { + expect( + removeFileFolderFromFileEntityPath( + `${FileFolder.Attachment}/subfolder/file.txt`, + ), + ).toBe('subfolder/file.txt'); + }); + + it('should work with different valid file folders', () => { + expect( + removeFileFolderFromFileEntityPath( + `${FileFolder.ProfilePicture}/avatar.png`, + ), + ).toBe('avatar.png'); + + expect( + removeFileFolderFromFileEntityPath( + `${FileFolder.WorkspaceLogo}/logo.svg`, + ), + ).toBe('logo.svg'); + + expect( + removeFileFolderFromFileEntityPath(`${FileFolder.FilesField}/doc.pdf`), + ).toBe('doc.pdf'); + }); + + it('should throw BadRequestException for invalid file folder', () => { + expect(() => + removeFileFolderFromFileEntityPath('invalid-folder/file.txt'), + ).toThrow(BadRequestException); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils.ts b/packages/twenty-server/src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils.ts new file mode 100644 index 0000000000..3c3194090b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils.ts @@ -0,0 +1,12 @@ +import { BadRequestException } from '@nestjs/common'; + +import { FileFolder } from 'twenty-shared/types'; + +export const removeFileFolderFromFileEntityPath = (path: string): string => { + const [fileFolder, ..._path] = path.split('/'); + + if (!Object.values(FileFolder).includes(fileFolder as FileFolder)) + throw new BadRequestException(`File folder ${fileFolder} is not allowed`); + + return path.replace(`${fileFolder}/`, ''); +}; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 743d3b383a..b97ce00a2e 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -234,6 +234,12 @@ describe('WorkspaceEntityManager', () => { emitDatabaseBatchEvent: jest.fn(), emitCustomBatchEvent: jest.fn(), } as any, + coreDataSource: { + getRepository: jest.fn(() => ({ + find: jest.fn(), + softDelete: jest.fn(), + })), + } as any, } as WorkspaceInternalContext; mockDataSource = { @@ -250,6 +256,7 @@ describe('WorkspaceEntityManager', () => { }, permissionsPerRoleId: {}, eventEmitterService: mockInternalContext.eventEmitterService, + coreDataSource: mockInternalContext.coreDataSource, } as GlobalWorkspaceDataSource; mockPermissionOptions = { @@ -305,6 +312,7 @@ describe('WorkspaceEntityManager', () => { findColumnWithPropertyPath: jest.fn(), }), eventEmitterService: mockInternalContext.eventEmitterService, + coreDataSource: mockInternalContext.coreDataSource, createQueryBuilder: jest.fn().mockReturnValue({ delete: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index d0a74ae3bd..98b13c5cf9 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -50,8 +50,9 @@ import { type DeepPartialWithNestedRelationFields } from 'src/engine/twenty-orm/ import { type QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type'; import { getEntityTarget } from 'src/engine/twenty-orm/entity-manager/utils/get-entity-target'; import { computeTwentyORMException } from 'src/engine/twenty-orm/error-handling/compute-twenty-orm-exception'; +import { FilesFieldSync } from 'src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync'; +import { RelationNestedQueries } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries'; import { type GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource'; -import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries'; import { type OperationType, validateOperationIsPermittedOrThrow, @@ -111,6 +112,7 @@ export class WorkspaceEntityManager extends EntityManager { featureFlagsMap: context.featureFlagsMap, userWorkspaceRoleMap: context.userWorkspaceRoleMap, eventEmitterService: this.eventEmitterService, + coreDataSource: this.connection.coreDataSource, }; } @@ -1216,6 +1218,37 @@ export class WorkspaceEntityManager extends EntityManager { {} as Record, ); + const filesFieldSync = new FilesFieldSync(this.internalContext); + + let filesFieldDiffByEntityIndex = null; + let filesFieldFileIds = null; + let fileIdToApplicationId = new Map(); + + filesFieldDiffByEntityIndex = + filesFieldSync.computeFilesFieldDiffBeforeUpsert( + entityWithConnectedRelations, + entityTarget, + beforeUpdateMapById, + ); + + if (isDefined(filesFieldDiffByEntityIndex)) { + const result = await filesFieldSync.enrichFilesFields({ + entities: entityWithConnectedRelations, + filesFieldDiffByEntityIndex, + workspaceId: this.internalContext.workspaceId, + target: entityTarget, + }); + + filesFieldFileIds = result.fileIds; + fileIdToApplicationId = result.fileIdToApplicationId; + + entityWithConnectedRelations.splice( + 0, + entityWithConnectedRelations.length, + ...result.entities, + ); + } + const objectMetadataItem = getObjectMetadataFromEntityTarget( entityTarget, this.internalContext, @@ -1251,6 +1284,13 @@ export class WorkspaceEntityManager extends EntityManager { .then(() => formattedEntityOrEntities as Entity[]) .finally(() => queryRunnerForEntityPersistExecutor.release()); + if (isDefined(filesFieldFileIds)) { + await filesFieldSync.updateFileEntityRecords( + filesFieldFileIds, + fileIdToApplicationId, + ); + } + const resultArray = Array.isArray(result) ? result : [result]; let formattedResult = formatResult( diff --git a/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts b/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts new file mode 100644 index 0000000000..7f7336dc6f --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts @@ -0,0 +1,616 @@ +import path from 'path'; + +import { msg } from '@lingui/core/macro'; +import { + FieldMetadataType, + type FieldMetadataSettingsMapping, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { + In, + type EntityTarget, + type ObjectLiteral, + type Repository, +} from 'typeorm'; +import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; + +import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; +import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { + TwentyORMException, + TwentyORMExceptionCode, +} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; +import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util'; + +type FileItem = { + fileId: string; + label: string; + extension?: string; +}; + +type FilesFieldDiff = { + toAdd: FileItem[]; + toUpdate: FileItem[]; + toRemove: FileItem[]; +}; + +type FilesFieldDiffByEntityIndex = { + [entityIndex: number]: { + [fieldName: string]: FilesFieldDiff; + }; +}; + +export class FilesFieldSync { + private readonly internalContext: WorkspaceInternalContext; + private readonly fileRepository: Repository; + + constructor(internalContext: WorkspaceInternalContext) { + this.internalContext = internalContext; + this.fileRepository = + internalContext.coreDataSource.getRepository(FileEntity); + } + + prepareFilesFieldSyncBeforeUpdate( + entities: QueryDeepPartialEntity[], + target: EntityTarget, + existingRecords: ObjectLiteral[], + ): FilesFieldDiffByEntityIndex | null { + return this.computeFilesFieldDiffBeforeUpdate( + entities, + target, + existingRecords, + ); + } + + computeFilesFieldDiffBeforeUpdate( + entities: QueryDeepPartialEntity[], + target: EntityTarget, + existingRecords: ObjectLiteral[], + ): FilesFieldDiffByEntityIndex | null { + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const filesFields = this.getFilesFields(objectMetadata.id); + + if (filesFields.length === 0) { + return null; + } + + const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {}; + + entities.forEach((entity, index) => { + const entityWithId = entity as { id?: string }; + const existingRecord = existingRecords?.find( + (existing) => + isDefined(existing.id) && + isDefined(entityWithId.id) && + existing.id === entityWithId.id, + ); + + for (const filesField of filesFields) { + const existingFilesValue = (existingRecord?.[filesField.name] ?? + []) as FileItem[]; + + const diff = this.validateAndComputeFilesFieldDiff( + entity as Record, + filesField, + existingFilesValue, + ); + + if (diff) { + if (!filesFieldDiffByEntityIndex[index]) { + filesFieldDiffByEntityIndex[index] = {}; + } + filesFieldDiffByEntityIndex[index][filesField.name] = diff; + } + } + }); + + return Object.keys(filesFieldDiffByEntityIndex).length > 0 + ? filesFieldDiffByEntityIndex + : null; + } + + computeFilesFieldDiffBeforeUpdateOne( + updatePayload: QueryDeepPartialEntity, + target: EntityTarget, + existingRecords: ObjectLiteral[], + ): FilesFieldDiffByEntityIndex | null { + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const filesFields = this.getFilesFields(objectMetadata.id); + + if (filesFields.length === 0) { + return null; + } + + if (existingRecords.length !== 1) { + throw new TwentyORMException( + `Cannot update multiple records with files field at once`, + TwentyORMExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: msg`You can only update one record with files field at once.`, + }, + ); + } + + const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {}; + const existingRecord = existingRecords[0]; + + for (const filesField of filesFields) { + const existingFilesValue = (existingRecord?.[filesField.name] ?? + []) as FileItem[]; + + const diff = this.validateAndComputeFilesFieldDiff( + updatePayload as Record, + filesField, + existingFilesValue, + ); + + if (diff) { + if (!filesFieldDiffByEntityIndex[0]) { + filesFieldDiffByEntityIndex[0] = {}; + } + filesFieldDiffByEntityIndex[0][filesField.name] = diff; + } + } + + return Object.keys(filesFieldDiffByEntityIndex).length > 0 + ? filesFieldDiffByEntityIndex + : null; + } + + computeFilesFieldDiffBeforeInsert( + entities: QueryDeepPartialEntity[], + target: EntityTarget, + ): FilesFieldDiffByEntityIndex | null { + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const filesFields = this.getFilesFields(objectMetadata.id); + + if (filesFields.length === 0) { + return null; + } + + const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {}; + + entities.forEach((entity, index) => { + for (const filesField of filesFields) { + const diff = this.validateAndComputeFilesFieldDiff( + entity as Record, + filesField, + [], + ); + + if (diff) { + if (!filesFieldDiffByEntityIndex[index]) { + filesFieldDiffByEntityIndex[index] = {}; + } + filesFieldDiffByEntityIndex[index][filesField.name] = diff; + } + } + }); + + return Object.keys(filesFieldDiffByEntityIndex).length > 0 + ? filesFieldDiffByEntityIndex + : null; + } + + computeFilesFieldDiffBeforeUpsert( + entities: QueryDeepPartialEntity[], + target: EntityTarget, + existingRecordsMapById: Record, + ): FilesFieldDiffByEntityIndex | null { + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const filesFields = this.getFilesFields(objectMetadata.id); + + if (filesFields.length === 0) { + return null; + } + + const filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex = {}; + + entities.forEach((entity, index) => { + const entityWithId = entity as { id?: string }; + const existingRecord = isDefined(entityWithId.id) + ? existingRecordsMapById[entityWithId.id] + : undefined; + + for (const filesField of filesFields) { + const existingFilesValue = existingRecord + ? ((existingRecord[filesField.name] ?? []) as FileItem[]) + : []; + + const diff = this.validateAndComputeFilesFieldDiff( + entity as Record, + filesField, + existingFilesValue, + ); + + if (diff) { + if (!filesFieldDiffByEntityIndex[index]) { + filesFieldDiffByEntityIndex[index] = {}; + } + filesFieldDiffByEntityIndex[index][filesField.name] = diff; + } + } + }); + + return Object.keys(filesFieldDiffByEntityIndex).length > 0 + ? filesFieldDiffByEntityIndex + : null; + } + + private validateFilesFieldMaxValues( + filesField: FlatFieldMetadata, + newFilesValue: FileItem[], + ): void { + const filesFieldMaxNumberOfValues = ( + filesField.settings as FieldMetadataSettingsMapping[FieldMetadataType.FILES] + ).maxNumberOfValues; + + if (newFilesValue.length > filesFieldMaxNumberOfValues) { + throw new TwentyORMException( + `Max number of files is ${filesFieldMaxNumberOfValues}`, + TwentyORMExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: msg`Max number of files is ${filesFieldMaxNumberOfValues}`, + }, + ); + } + } + + private validateAndComputeFilesFieldDiff( + entity: Record, + filesField: FlatFieldMetadata, + existingFilesValue: FileItem[], + ): FilesFieldDiff | null { + const newFilesValue = entity[filesField.name] as + | FileItem[] + | null + | undefined; + + if (!isDefined(newFilesValue)) { + return null; + } + + this.validateFilesFieldMaxValues(filesField, newFilesValue); + + const diff = this.computeFilesFieldDiff(existingFilesValue, newFilesValue); + + if ( + diff.toAdd.length > 0 || + diff.toUpdate.length > 0 || + diff.toRemove.length > 0 + ) { + return diff; + } + + return null; + } + + private computeFilesFieldDiff( + existingFiles: FileItem[], + newFiles: FileItem[], + ): FilesFieldDiff { + const existingFileMap = new Map( + existingFiles.map((file) => [file.fileId, file]), + ); + const newFileMap = new Map(newFiles.map((file) => [file.fileId, file])); + + const toAdd: FileItem[] = []; + const toUpdate: FileItem[] = []; + const toRemove: FileItem[] = []; + + for (const newFile of newFiles) { + const existingFile = existingFileMap.get(newFile.fileId); + + if (!isDefined(existingFile)) { + toAdd.push(newFile); + } else { + toUpdate.push({ + ...existingFile, + label: newFile.label, + }); + } + } + + for (const existingFile of existingFiles) { + if (!newFileMap.has(existingFile.fileId)) { + toRemove.push(existingFile); + } + } + + return { toAdd, toUpdate, toRemove }; + } + + async enrichFilesFields({ + entities, + filesFieldDiffByEntityIndex, + workspaceId, + target, + }: { + entities: QueryDeepPartialEntity[]; + filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex; + workspaceId: string; + target: EntityTarget; + }): Promise<{ + entities: QueryDeepPartialEntity[]; + fileIds: { + toAdd: Set; + toUpdate: Set; + toRemove: Set; + }; + fileIdToApplicationId: Map; + }> { + if (Object.keys(filesFieldDiffByEntityIndex).length === 0) { + return { + entities, + fileIds: { + toAdd: new Set(), + toUpdate: new Set(), + toRemove: new Set(), + }, + fileIdToApplicationId: new Map(), + }; + } + + const objectMetadata = getObjectMetadataFromEntityTarget( + target, + this.internalContext, + ); + + const { toAdd, toUpdate, toRemove, fileIdToApplicationId } = + await this.validateAndEnrichFileDiffs( + filesFieldDiffByEntityIndex, + workspaceId, + objectMetadata.id, + ); + + const updatedEntities = this.updateEntitiesWithEnrichedFilesFieldValues( + entities, + filesFieldDiffByEntityIndex, + ); + + return { + entities: updatedEntities, + fileIds: { toAdd, toUpdate, toRemove }, + fileIdToApplicationId, + }; + } + + private async validateAndEnrichFileDiffs( + filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex, + workspaceId: string, + objectMetadataId: string, + ): Promise<{ + toAdd: Set; + toUpdate: Set; + toRemove: Set; + fileIdToApplicationId: Map; + }> { + const allFileIds = { + toAdd: new Set(), + toUpdate: new Set(), + toRemove: new Set(), + }; + + const fileIdToApplicationId = new Map(); + const allFileIdsToFetch = new Set(); + + const filesFields = this.getFilesFields(objectMetadataId); + const fieldNameToApplicationId = new Map( + filesFields.map((field) => [field.name, field.applicationId]), + ); + + for (const entityDiffs of Object.values(filesFieldDiffByEntityIndex)) { + for (const [fieldName, diff] of Object.entries(entityDiffs)) { + const fieldApplicationId = fieldNameToApplicationId.get(fieldName); + + diff.toAdd.forEach((file) => { + allFileIds.toAdd.add(file.fileId); + allFileIdsToFetch.add(file.fileId); + if (fieldApplicationId) { + fileIdToApplicationId.set(file.fileId, fieldApplicationId); + } + }); + diff.toUpdate.forEach((file) => { + allFileIds.toUpdate.add(file.fileId); + allFileIdsToFetch.add(file.fileId); + }); + diff.toRemove.forEach((file) => { + allFileIds.toRemove.add(file.fileId); + }); + } + } + + if (allFileIdsToFetch.size === 0 && allFileIds.toRemove.size === 0) { + return { ...allFileIds, fileIdToApplicationId }; + } + + const existingFiles = await this.fileRepository.find({ + where: { + id: In([...allFileIdsToFetch, ...allFileIds.toRemove]), + workspaceId, + }, + select: ['id', 'path', 'settings'], + }); + + const existingFileMap = new Map( + existingFiles.map((file) => [file.id, file]), + ); + + for (const entityDiffs of Object.values(filesFieldDiffByEntityIndex)) { + for (const diff of Object.values(entityDiffs)) { + for (const file of diff.toAdd) { + const fileEntity = existingFileMap.get(file.fileId); + + if (!fileEntity) { + throw new TwentyORMException( + `File not found: ${file.fileId}`, + TwentyORMExceptionCode.INVALID_INPUT, + ); + } + + if (!fileEntity.settings?.isTemporaryFile) { + const fileId = file.fileId; + + throw new TwentyORMException( + `File ${fileId} is already associated with a permanent files field`, + TwentyORMExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: msg`File ${fileId} is already associated with a permanent files field. Please re-upload the file.`, + }, + ); + } + + file.extension = path.extname(fileEntity.path); + } + + for (const file of diff.toUpdate) { + const fileEntity = existingFileMap.get(file.fileId); + + if (!fileEntity) { + throw new TwentyORMException( + `File not found: ${file.fileId}`, + TwentyORMExceptionCode.INVALID_INPUT, + ); + } + + if (fileEntity.settings?.isTemporaryFile) { + throw new TwentyORMException( + `File ${file.fileId} to update should not be a temporary file`, + TwentyORMExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: STANDARD_ERROR_MESSAGE, + }, + ); + } + } + } + } + + return { ...allFileIds, fileIdToApplicationId }; + } + + async updateFileEntityRecords( + fileIds: { + toAdd: Set; + toUpdate: Set; + toRemove: Set; + }, + fileIdToApplicationId: Map, + ): Promise { + if (fileIds.toAdd.size > 0) { + const fileIdsByApplicationId = Array.from(fileIds.toAdd).reduce( + (acc, fileId) => { + const applicationId = fileIdToApplicationId.get(fileId); + + if (!applicationId) { + throw new TwentyORMException( + `Application ID not found for file ${fileId}`, + TwentyORMExceptionCode.INVALID_INPUT, + ); + } + + acc[applicationId] = [...(acc[applicationId] || []), fileId]; + + return acc; + }, + {} as Record, + ); + + for (const [applicationId, fileIds] of Object.entries( + fileIdsByApplicationId, + )) { + await this.fileRepository.update( + { id: In(fileIds) }, + { + settings: { + isTemporaryFile: false, + toDelete: false, + }, + applicationId, + }, + ); + } + } + + if (fileIds.toRemove.size > 0) { + await this.fileRepository.softDelete([...fileIds.toRemove]); + } + } + + private updateEntitiesWithEnrichedFilesFieldValues< + Entity extends ObjectLiteral, + >( + entities: QueryDeepPartialEntity[], + filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex, + ): QueryDeepPartialEntity[] { + return entities.map((entity, index) => { + const entityDiffs = filesFieldDiffByEntityIndex[index]; + + if (!entityDiffs) { + return entity; + } + + const updatedEntity = { ...entity }; + const updatedEntityAny = updatedEntity as Record; + + for (const [fieldName, diff] of Object.entries(entityDiffs)) { + const entityAny = entity as Record; + const currentFiles = (entityAny[fieldName] ?? []) as FileItem[]; + + const toAddMap = new Map(diff.toAdd.map((file) => [file.fileId, file])); + const toUpdateMap = new Map( + diff.toUpdate.map((file) => [file.fileId, file]), + ); + + const updatedFiles = currentFiles.map((file) => { + const enrichedFile = + toAddMap.get(file.fileId) || toUpdateMap.get(file.fileId); + + return enrichedFile || file; + }); + + updatedEntityAny[fieldName] = updatedFiles; + } + + return updatedEntity; + }); + } + + private getFilesFields(objectMetadataId: string): FlatFieldMetadata[] { + const objectMetadata = + this.internalContext.flatObjectMetadataMaps.byId[objectMetadataId]; + + if (!objectMetadata) { + return []; + } + + const objectFields = getFlatFieldsFromFlatObjectMetadata( + objectMetadata, + this.internalContext.flatFieldMetadataMaps, + ); + + return objectFields.filter( + (field) => field.type === FieldMetadataType.FILES, + ); + } +} diff --git a/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/relation-nested-queries.ts b/packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries.ts similarity index 98% rename from packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/relation-nested-queries.ts rename to packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries.ts index da1ef88fdd..0ceac5109f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/relation-nested-queries.ts +++ b/packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries.ts @@ -19,7 +19,7 @@ import { TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; -import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util'; +import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util'; import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { computeRelationConnectQueryConfigs } from 'src/engine/twenty-orm/utils/compute-relation-connect-query-configs.util'; import { createSqlWhereTupleInClause } from 'src/engine/twenty-orm/utils/create-sql-where-tuple-in-clause.utils'; diff --git a/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/utils/__tests__/formatConnectRecordNotFoundErrorMessage.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/utils/__tests__/formatConnectRecordNotFoundErrorMessage.util.spec.ts similarity index 89% rename from packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/utils/__tests__/formatConnectRecordNotFoundErrorMessage.util.spec.ts rename to packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/utils/__tests__/formatConnectRecordNotFoundErrorMessage.util.spec.ts index 12a7cdda81..ed2be44c5e 100644 --- a/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/utils/__tests__/formatConnectRecordNotFoundErrorMessage.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/utils/__tests__/formatConnectRecordNotFoundErrorMessage.util.spec.ts @@ -1,4 +1,4 @@ -import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util'; +import { formatConnectRecordNotFoundErrorMessage } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util'; describe('formatConnectRecordNotFoundErrorMessage', () => { it('should format the error message correctly', () => { diff --git a/packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util.ts b/packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util.ts similarity index 100% rename from packages/twenty-server/src/engine/twenty-orm/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util.ts rename to packages/twenty-server/src/engine/twenty-orm/field-operations/relation-nested-queries/utils/formatConnectRecordNotFoundErrorMessage.util.ts diff --git a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.service.ts b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.service.ts index fb9bfadc95..863c6e6051 100644 --- a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.service.ts +++ b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.service.ts @@ -3,8 +3,10 @@ import { OnApplicationShutdown, OnModuleInit, } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; +import { DataSource } from 'typeorm'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { GlobalWorkspaceDataSource } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource'; @@ -21,6 +23,8 @@ export class GlobalWorkspaceDataSourceService constructor( private readonly twentyConfigService: TwentyConfigService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, + @InjectDataSource() + private readonly coreDataSource: DataSource, ) {} async onModuleInit(): Promise { @@ -49,6 +53,7 @@ export class GlobalWorkspaceDataSourceService }, }, this.workspaceEventEmitter, + this.coreDataSource, ); await this.globalWorkspaceDataSource.initialize(); @@ -83,6 +88,7 @@ export class GlobalWorkspaceDataSourceService }, }, this.workspaceEventEmitter, + this.coreDataSource, ); await this.globalWorkspaceDataSourceReplica.initialize(); } diff --git a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.ts b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.ts index 732261d74c..c9e713039e 100644 --- a/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.ts +++ b/packages/twenty-server/src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.ts @@ -33,15 +33,18 @@ type CreateQueryBuilderOptions = { export class GlobalWorkspaceDataSource extends DataSource { readonly eventEmitterService: WorkspaceEventEmitter; + readonly coreDataSource: DataSource; private _isConstructing = true; dataSourceWithOverridenCreateQueryBuilder: GlobalWorkspaceDataSource; constructor( options: DataSourceOptions, eventEmitterService: WorkspaceEventEmitter, + coreDataSource: DataSource, ) { super(options); this.eventEmitterService = eventEmitterService; + this.coreDataSource = coreDataSource; this._isConstructing = false; Object.defineProperty(this, 'manager', { diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts index aff02b1434..d907bcb9b9 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-internal-context.interface.ts @@ -1,3 +1,5 @@ +import { type DataSource } from 'typeorm'; + import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -18,4 +20,5 @@ export interface WorkspaceInternalContext { featureFlagsMap: Record; userWorkspaceRoleMap: Record; eventEmitterService: WorkspaceEventEmitter; + coreDataSource: DataSource; } diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts index d18ac2d940..24527be146 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts @@ -22,7 +22,8 @@ import { TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; -import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries'; +import { FilesFieldSync } from 'src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync'; +import { RelationNestedQueries } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; @@ -46,6 +47,7 @@ export class WorkspaceInsertQueryBuilder< private relationNestedConfig: | [RelationConnectQueryConfig[], RelationDisconnectQueryFieldsByEntityIndex] | null; + private filesFieldSync: FilesFieldSync; constructor( queryBuilder: InsertQueryBuilder, @@ -64,6 +66,7 @@ export class WorkspaceInsertQueryBuilder< this.relationNestedQueries = new RelationNestedQueries( this.internalContext, ); + this.filesFieldSync = new FilesFieldSync(this.internalContext); } override clone(): this { @@ -158,6 +161,38 @@ export class WorkspaceInsertQueryBuilder< this.internalContext, ); + let filesFieldFileIds = null; + let fileIdToApplicationId = new Map(); + + const entities = Array.isArray(this.expressionMap.valuesSet) + ? this.expressionMap.valuesSet + : [this.expressionMap.valuesSet]; + + const filesFieldDiffByEntityIndex = + this.filesFieldSync.computeFilesFieldDiffBeforeInsert( + entities as QueryDeepPartialEntityWithNestedRelationFields[], + mainAliasTarget, + ); + + if (isDefined(filesFieldDiffByEntityIndex)) { + const result = await this.filesFieldSync.enrichFilesFields({ + entities: + entities as QueryDeepPartialEntityWithNestedRelationFields[], + filesFieldDiffByEntityIndex, + workspaceId: this.internalContext.workspaceId, + target: mainAliasTarget, + }); + + filesFieldFileIds = result.fileIds; + fileIdToApplicationId = result.fileIdToApplicationId; + + this.expressionMap.valuesSet = Array.isArray( + this.expressionMap.valuesSet, + ) + ? result.entities + : result.entities[0]; + } + if (isDefined(this.relationNestedConfig)) { const nestedRelationQueryBuilder = new WorkspaceSelectQueryBuilder( this as unknown as WorkspaceSelectQueryBuilder, @@ -183,6 +218,13 @@ export class WorkspaceInsertQueryBuilder< this.validateRLSPredicatesForInsert(); const result = await super.execute(); + + if (isDefined(filesFieldFileIds)) { + await this.filesFieldSync.updateFileEntityRecords( + filesFieldFileIds, + fileIdToApplicationId, + ); + } const eventSelectQueryBuilder = ( this.connection.manager as WorkspaceEntityManager ).createQueryBuilder( diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts index 54424f7caf..9b78bda2c1 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -26,7 +26,8 @@ import { TwentyORMException, TwentyORMExceptionCode, } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; -import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries'; +import { FilesFieldSync } from 'src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync'; +import { RelationNestedQueries } from 'src/engine/twenty-orm/field-operations/relation-nested-queries/relation-nested-queries'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; @@ -57,6 +58,7 @@ export class WorkspaceUpdateQueryBuilder< criteria: string; partialEntity: QueryDeepPartialEntity; }[]; + private filesFieldSync: FilesFieldSync; constructor( queryBuilder: UpdateQueryBuilder, @@ -75,6 +77,7 @@ export class WorkspaceUpdateQueryBuilder< this.relationNestedQueries = new RelationNestedQueries( this.internalContext, ); + this.filesFieldSync = new FilesFieldSync(this.internalContext); } override clone(): this { @@ -154,6 +157,44 @@ export class WorkspaceUpdateQueryBuilder< this.featureFlagMap, ); + const formattedBefore = formatResult( + before, + objectMetadata, + this.internalContext.flatObjectMetadataMaps, + this.internalContext.flatFieldMetadataMaps, + ); + + let filesFieldDiffByEntityIndex = null; + let filesFieldFileIds = null; + let fileIdToApplicationId = new Map(); + + const updatePayload = Array.isArray(this.expressionMap.valuesSet) + ? (this.expressionMap.valuesSet[0] ?? {}) + : (this.expressionMap.valuesSet ?? {}); + + filesFieldDiffByEntityIndex = + this.filesFieldSync.computeFilesFieldDiffBeforeUpdateOne( + updatePayload, + mainAliasTarget, + formattedBefore, + ); + + if (isDefined(filesFieldDiffByEntityIndex)) { + const entities = formattedBefore.map(() => updatePayload); + + const result = await this.filesFieldSync.enrichFilesFields({ + entities, + filesFieldDiffByEntityIndex, + workspaceId: this.internalContext.workspaceId, + target: mainAliasTarget, + }); + + filesFieldFileIds = result.fileIds; + fileIdToApplicationId = result.fileIdToApplicationId; + + this.expressionMap.valuesSet = result.entities[0]; + } + if (isDefined(this.relationNestedConfig)) { const updatedValues = await this.relationNestedQueries.processRelationNestedQueries({ @@ -168,13 +209,6 @@ export class WorkspaceUpdateQueryBuilder< updatedValues.length === 1 ? updatedValues[0] : updatedValues; } - const formattedBefore = formatResult( - before, - objectMetadata, - this.internalContext.flatObjectMetadataMaps, - this.internalContext.flatFieldMetadataMaps, - ); - this.applyRowLevelPermissionPredicates(); const valuesSet = this.expressionMap.valuesSet ?? {}; @@ -194,6 +228,13 @@ export class WorkspaceUpdateQueryBuilder< const result = await super.execute(); + if (isDefined(filesFieldFileIds)) { + await this.filesFieldSync.updateFileEntityRecords( + filesFieldFileIds, + fileIdToApplicationId, + ); + } + const after = await eventSelectQueryBuilder.getMany(); const formattedAfter = formatResult( @@ -324,6 +365,36 @@ export class WorkspaceUpdateQueryBuilder< mainAliasTarget, ); + let filesFieldDiffByEntityIndex = null; + let filesFieldFileIds = null; + let fileIdToApplicationId = null; + + const entities = this.manyInputs.map((input) => input.partialEntity); + + filesFieldDiffByEntityIndex = + this.filesFieldSync.computeFilesFieldDiffBeforeUpdate( + entities, + mainAliasTarget, + formattedBefore, + ); + + if (isDefined(filesFieldDiffByEntityIndex)) { + const result = await this.filesFieldSync.enrichFilesFields({ + entities, + filesFieldDiffByEntityIndex, + workspaceId: this.internalContext.workspaceId, + target: mainAliasTarget, + }); + + filesFieldFileIds = result.fileIds; + fileIdToApplicationId = result.fileIdToApplicationId; + + this.manyInputs = result.entities.map((updatedEntity, index) => ({ + criteria: this.manyInputs[index].criteria, + partialEntity: updatedEntity, + })); + } + if (isDefined(this.relationNestedConfig)) { const updatedValues = await this.relationNestedQueries.processRelationNestedQueries({ @@ -373,6 +444,13 @@ export class WorkspaceUpdateQueryBuilder< results.push(result); } + if (isDefined(filesFieldFileIds) && isDefined(fileIdToApplicationId)) { + await this.filesFieldSync.updateFileEntityRecords( + filesFieldFileIds, + fileIdToApplicationId, + ); + } + const afterRecords = await eventSelectQueryBuilder.getMany(); const formattedAfter = formatResult( diff --git a/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts new file mode 100644 index 0000000000..8fef488f12 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/files-field/files-field-sync.integration-spec.ts @@ -0,0 +1,575 @@ +import gql from 'graphql-tag'; +import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; + +const uploadWorkspaceFieldFileMutation = gql` + mutation UploadFilesFieldFile($file: Upload!) { + uploadFilesFieldFile(file: $file) { + id + path + size + createdAt + } + } +`; + +const deleteFileMutation = gql` + mutation DeleteFile($fileId: UUID!) { + deleteFile(fileId: $fileId) { + id + } + } +`; + +const createRecordsQuery = gql` + mutation CreateRecords( + $data: [FileSyncTestObjectCreateInput!]! + $upsert: Boolean + ) { + createFileSyncTestObjects(data: $data, upsert: $upsert) { + id + name + filesField { + fileId + label + extension + } + } + } +`; + +const updateRecordQuery = gql` + mutation UpdateFileSyncTestObject( + $fileSyncTestObjectId: UUID! + $data: FileSyncTestObjectUpdateInput! + ) { + updateFileSyncTestObject(id: $fileSyncTestObjectId, data: $data) { + id + name + filesField { + fileId + label + extension + } + } + } +`; + +const deleteRecordsQuery = gql` + mutation DeleteRecords($filter: FileSyncTestObjectFilterInput!) { + deleteFileSyncTestObjects(filter: $filter) { + id + } + } +`; + +type UploadedFile = { + id: string; + contentType: string; +}; + +const uploadFile = async ( + filename: string, + content: string, + contentType: string, +): Promise => { + const response = await makeGraphqlAPIRequestWithFileUpload( + { + query: uploadWorkspaceFieldFileMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: Buffer.from(content), + filename, + contentType, + }, + ); + + expect(response.body.errors).toBeUndefined(); + + return { + id: response.body.data.uploadFilesFieldFile.id, + contentType, + }; +}; + +const deleteFile = async (fileId: string): Promise => { + await makeGraphqlAPIRequest({ + query: deleteFileMutation, + variables: { fileId }, + }); +}; + +describe('fileFieldSync - FILES field <> files sync', () => { + let createdObjectMetadataId = ''; + let uploadedFiles: UploadedFile[] = []; + + const checkFileExistsInDB = async (fileId: string): Promise => { + const result = await global.testDataSource.query( + 'SELECT id FROM core."file" WHERE id = $1 AND "deletedAt" IS NULL', + [fileId], + ); + + return result.length > 0; + }; + + const checkFileIsInPermanentStorage = async ( + fileId: string, + ): Promise => { + const result = await global.testDataSource.query( + 'SELECT settings FROM core."file" WHERE id = $1', + [fileId], + ); + + if (result.length === 0) { + return false; + } + + return result[0].settings.isTemporaryFile === false; + }; + + const checkFileIsTemporary = async (fileId: string): Promise => { + const result = await global.testDataSource.query( + 'SELECT settings FROM core."file" WHERE id = $1', + [fileId], + ); + + if (result.length === 0) { + return false; + } + + return result[0].settings.isTemporaryFile === true; + }; + + beforeAll(async () => { + jest.useRealTimers(); + + await makeGraphqlAPIRequest( + updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + FeatureFlagKey.IS_FILES_FIELD_ENABLED, + true, + ), + ); + + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'fileSyncTestObject', + namePlural: 'fileSyncTestObjects', + labelSingular: 'File Sync Test Object', + labelPlural: 'File Sync Test Objects', + icon: 'IconFile', + }, + }); + + createdObjectMetadataId = objectMetadataId; + + await createOneFieldMetadata({ + input: { + name: 'filesField', + label: 'Files Field', + type: FieldMetadataType.FILES, + objectMetadataId: createdObjectMetadataId, + settings: { maxNumberOfValues: 5 }, + }, + gqlFields: ` + id + name + label + type + `, + }); + }); + + afterEach(async () => { + for (const file of uploadedFiles) { + await deleteFile(file.id); + } + uploadedFiles = []; + }); + + afterAll(async () => { + jest.useFakeTimers(); + + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: createdObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + + await makeGraphqlAPIRequest( + updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + FeatureFlagKey.IS_FILES_FIELD_ENABLED, + false, + ), + ); + }); + + it('createMany without upsert - files sync successfully', async () => { + const imageFile = await uploadFile( + 'test-image.png', + 'fake image content', + 'image/png', + ); + const textFile = await uploadFile( + 'test-text.txt', + 'fake text content', + 'text/plain', + ); + + uploadedFiles.push(imageFile, textFile); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(true); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileIsTemporary(imageFile.id)).toBe(true); + expect(await checkFileIsTemporary(textFile.id)).toBe(true); + + const response = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record with image', + filesField: [ + { + fileId: imageFile.id, + label: 'test-image.png', + }, + { + fileId: textFile.id, + label: 'test-text.txt', + }, + ], + }, + ], + upsert: false, + }, + }); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + + const createdRecord = response.body.data.createFileSyncTestObjects[0]; + + expect(createdRecord.filesField).toHaveLength(2); + expect(createdRecord.filesField[0].fileId).toBe(imageFile.id); + expect(createdRecord.filesField[0].extension).toBe('.png'); + expect(createdRecord.filesField[1].fileId).toBe(textFile.id); + expect(createdRecord.filesField[1].extension).toBe('.txt'); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(true); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: createdRecord.id } }, + }, + }); + }); + + it('createMany with upsert - files sync successfully', async () => { + const imageFile = await uploadFile( + 'test-image.png', + 'fake image content', + 'image/png', + ); + const textFile = await uploadFile( + 'test-text.txt', + 'fake text content', + 'text/plain', + ); + const anotherImageFile = await uploadFile( + 'test-another-image.png', + 'fake another image content', + 'image/png', + ); + + uploadedFiles.push(imageFile, textFile, anotherImageFile); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(true); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileExistsInDB(anotherImageFile.id)).toBe(true); + expect(await checkFileIsTemporary(imageFile.id)).toBe(true); + expect(await checkFileIsTemporary(textFile.id)).toBe(true); + expect(await checkFileIsTemporary(anotherImageFile.id)).toBe(true); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record to upsert', + filesField: [ + { + fileId: imageFile.id, + label: 'imageFile-label.png', + }, + { + fileId: anotherImageFile.id, + label: 'anotherImageFile-label.png', + }, + ], + }, + ], + upsert: false, + }, + }); + + const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; + const recordId = createdRecord.id; + + expect(createdRecord.filesField[0].extension).toBe('.png'); + + const upsertResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + id: recordId, + name: 'Record updated via upsert', + filesField: [ + { + fileId: textFile.id, + label: 'new-added-text-file.txt', + }, + { + fileId: imageFile.id, + label: 'imageFile-label.png', + }, + { + fileId: anotherImageFile.id, + label: 'updated-anotherImageFile-label.png', + }, + ], + }, + ], + upsert: true, + }, + }); + + expect(upsertResponse.body.errors).toBeUndefined(); + + const updatedRecord = upsertResponse.body.data.createFileSyncTestObjects[0]; + + expect(updatedRecord.id).toBe(recordId); + expect(updatedRecord.name).toBe('Record updated via upsert'); + expect(updatedRecord.filesField).toHaveLength(3); + expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); + expect(updatedRecord.filesField[1].label).toBe('imageFile-label.png'); + expect(updatedRecord.filesField[1].extension).toBe('.png'); + expect(updatedRecord.filesField[2].fileId).toBe(anotherImageFile.id); + expect(updatedRecord.filesField[2].label).toBe( + 'updated-anotherImageFile-label.png', + ); + expect(updatedRecord.filesField[2].extension).toBe('.png'); + expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); + expect(updatedRecord.filesField[0].label).toBe('new-added-text-file.txt'); + expect(updatedRecord.filesField[0].extension).toBe('.txt'); + + expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(anotherImageFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: recordId } }, + }, + }); + }); + + it('updateOne - files sync successfully', async () => { + const imageFile = await uploadFile( + 'test-image.png', + 'fake image content', + 'image/png', + ); + const textFile = await uploadFile( + 'test-text.txt', + 'fake text content', + 'text/plain', + ); + + uploadedFiles.push(imageFile, textFile); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(true); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileIsTemporary(imageFile.id)).toBe(true); + expect(await checkFileIsTemporary(textFile.id)).toBe(true); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record for updateOne test', + filesField: [ + { + fileId: imageFile.id, + label: 'original-image.png', + }, + ], + }, + ], + upsert: false, + }, + }); + + expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); + + const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; + const recordId = createdRecord.id; + + expect(createdRecord.filesField[0].extension).toBe('.png'); + + const updateResponse = await makeGraphqlAPIRequest({ + query: updateRecordQuery, + variables: { + fileSyncTestObjectId: recordId, + data: { + name: 'Record updated via updateOne', + filesField: [ + { + fileId: textFile.id, + label: 'added-text.txt', + }, + { + fileId: imageFile.id, + label: 'original-image.png', + }, + ], + }, + }, + }); + + expect(updateResponse.body.errors).toBeUndefined(); + + const updatedRecord = updateResponse.body.data.updateFileSyncTestObject; + + expect(updatedRecord.id).toBe(recordId); + expect(updatedRecord.name).toBe('Record updated via updateOne'); + expect(updatedRecord.filesField).toHaveLength(2); + expect(updatedRecord.filesField[1].fileId).toBe(imageFile.id); + expect(updatedRecord.filesField[1].label).toBe('original-image.png'); + expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); + expect(updatedRecord.filesField[0].label).toBe('added-text.txt'); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(true); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: recordId } }, + }, + }); + }); + + it('updateOne with removeFiles - verifies files can be removed', async () => { + const imageFile = await uploadFile( + 'test-image-to-delete.png', + 'fake image content', + 'image/png', + ); + const textFile = await uploadFile( + 'test-text-to-keep.txt', + 'fake text content', + 'text/plain', + ); + + uploadedFiles.push(imageFile, textFile); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(true); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileIsTemporary(imageFile.id)).toBe(true); + expect(await checkFileIsTemporary(textFile.id)).toBe(true); + + const createResponse = await makeGraphqlAPIRequest({ + query: createRecordsQuery, + variables: { + data: [ + { + name: 'Record for file deletion test', + filesField: [ + { + fileId: imageFile.id, + label: 'image-to-delete.png', + }, + { + fileId: textFile.id, + label: 'text-to-keep.txt', + }, + ], + }, + ], + upsert: false, + }, + }); + + expect(await checkFileIsInPermanentStorage(imageFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + + const createdRecord = createResponse.body.data.createFileSyncTestObjects[0]; + const recordId = createdRecord.id; + + const updateResponse = await makeGraphqlAPIRequest({ + query: updateRecordQuery, + variables: { + fileSyncTestObjectId: recordId, + data: { + filesField: [ + { + fileId: textFile.id, + label: 'text-to-keep.txt', + }, + ], + }, + }, + }); + + expect(updateResponse.body.errors).toBeUndefined(); + + const updatedRecord = updateResponse.body.data.updateFileSyncTestObject; + + expect(updatedRecord.filesField).toHaveLength(1); + expect(updatedRecord.filesField[0].fileId).toBe(textFile.id); + + expect(await checkFileExistsInDB(imageFile.id)).toBe(false); + expect(await checkFileExistsInDB(textFile.id)).toBe(true); + expect(await checkFileIsInPermanentStorage(textFile.id)).toBe(true); + + await makeGraphqlAPIRequest({ + query: deleteRecordsQuery, + variables: { + filter: { id: { eq: recordId } }, + }, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/file-upload/upload-files-field-file.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/files-field/upload-files-field-file.integration-spec.ts similarity index 100% rename from packages/twenty-server/test/integration/graphql/suites/file-upload/upload-files-field-file.integration-spec.ts rename to packages/twenty-server/test/integration/graphql/suites/files-field/upload-files-field-file.integration-spec.ts diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap index 3e6d822611..ca85343cfa 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/__snapshots__/files-field-create-input-validation.integration-spec.ts.snap @@ -1,25 +1,21 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":"not-a-files-array"} 1`] = `"Expected type "FileInput" to be an object."`; +exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":"not-an-addFiles-property"} 1`] = `"Expected type "FileItemInput" to be an object."`; -exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","fileType":"application/pdf"}]} 1`] = `"Field "fileType" is not defined by type "FileInput". Did you mean "fileId"?"`; +exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]}]} 1`] = `"Field "fileId" of required type "UUID!" was not provided."`; -exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440001","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440002","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440003","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440004","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440005","label":"Document.pdf"}]} 1`] = `"Max number of files is 2"`; +exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","extension":"not-allowed-in-input"}]}} 1`] = `"Field "fileId" of required type "UUID!" was not provided."`; -exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]} 1`] = `"String cannot represent a non string value: 12345"`; +exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"not-a-uuid","label":"Document.pdf"}]}} 1`] = `"Field "fileId" of required type "UUID!" was not provided."`; -exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"fileId":"not-a-uuid","label":"Document.pdf"}]} 1`] = `"Invalid UUID"`; +exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"invalidField":"test"}]}} 1`] = `"Field "fileId" of required type "UUID!" was not provided."`; -exports[`Create input validation - FILES Gql create input - failure FILES - should fail with : {"filesField":[{"invalidField":"test"}]} 1`] = `"Field "fileId" of required type "UUID!" was not provided."`; +exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":"not-an-addFiles-property"} 1`] = `"["Invalid value \\"'not-an-addFiles-property'\\" for FILES field \\"filesField\\" - It should be an array of objects with \\"fileId\\" and \\"label\\" properties."]"`; -exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":"not-a-files-array"} 1`] = `"["Invalid value \\"'not-a-files-array'\\" for FILES field \\"filesField\\" - It should be an array of objects with \\"fileId\\" and \\"label\\" properties."]"`; +exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]}]} 1`] = `"["Invalid value \\"[ { addFiles: [ [Object] ] } ]\\" for FILES field \\"filesField\\" - 0.fileId: Invalid input: expected string, received undefined, 0.label: Invalid input: expected string, received undefined, 0: Unrecognized key: \\"addFiles\\""]"`; -exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","fileType":"application/pdf"}]} 1`] = `"["Invalid value \\"[\\n {\\n fileId: '550e8400-e29b-41d4-a716-446655440000',\\n label: 'Document.pdf',\\n fileType: 'application/pdf'\\n }\\n]\\" for FILES field \\"filesField\\" - 0: Unrecognized key: \\"fileType\\""]"`; +exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf","extension":"not-allowed-in-input"}]}} 1`] = `"["Invalid value \\"{\\n addFiles: [\\n {\\n fileId: '550e8400-e29b-41d4-a716-446655440000',\\n label: 'Document.pdf',\\n extension: 'not-allowed-in-input'\\n }\\n ]\\n}\\" for FILES field \\"filesField\\" - : Invalid input: expected array, received object"]"`; -exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440001","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440002","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440003","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440004","label":"Document.pdf"},{"fileId":"550e8400-e29b-41d4-a716-446655440005","label":"Document.pdf"}]} 1`] = `"["Max number of files is 2"]"`; +exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"fileId":"not-a-uuid","label":"Document.pdf"}]}} 1`] = `"["Invalid value \\"{ addFiles: [ { fileId: 'not-a-uuid', label: 'Document.pdf' } ] }\\" for FILES field \\"filesField\\" - : Invalid input: expected array, received object"]"`; -exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"550e8400-e29b-41d4-a716-446655440000","label":12345}]} 1`] = `"["Invalid value \\"[ { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 } ]\\" for FILES field \\"filesField\\" - 0.label: Invalid input: expected string, received number"]"`; - -exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"fileId":"not-a-uuid","label":"Document.pdf"}]} 1`] = `"["Invalid value \\"[ { fileId: 'not-a-uuid', label: 'Document.pdf' } ]\\" for FILES field \\"filesField\\" - 0.fileId: Invalid UUID"]"`; - -exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":[{"invalidField":"test"}]} 1`] = `"["Invalid value \\"[ { invalidField: 'test' } ]\\" for FILES field \\"filesField\\" - 0.fileId: Invalid input: expected string, received undefined, 0.label: Invalid input: expected string, received undefined, 0: Unrecognized key: \\"invalidField\\""]"`; +exports[`Create input validation - FILES Rest create input - failure FILES - should fail with : {"filesField":{"addFiles":[{"invalidField":"test"}]}} 1`] = `"["Invalid value \\"{ addFiles: [ { invalidField: 'test' } ] }\\" for FILES field \\"filesField\\" - : Invalid input: expected array, received object"]"`; diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts index 53873835f7..46498d696b 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant.ts @@ -389,66 +389,43 @@ export const failingCreateInputByFieldMetadataType: { [FieldMetadataType.FILES]: [ { input: { - filesField: 'not-a-files-array', + filesField: 'not-an-addFiles-property', }, }, { input: { - filesField: [{ invalidField: 'test' }], + filesField: { addFiles: [{ invalidField: 'test' }] }, }, }, { input: { - filesField: [{ fileId: 'not-a-uuid', label: 'Document.pdf' }], - }, - }, - { - input: { - filesField: [ - { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }, - ], + filesField: { + addFiles: [{ fileId: 'not-a-uuid', label: 'Document.pdf' }], + }, }, }, { input: { filesField: [ { - fileId: '550e8400-e29b-41d4-a716-446655440000', - label: 'Document.pdf', - fileType: 'application/pdf', + addFiles: [ + { fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }, + ], }, ], }, }, - //Should fail because max number of files is 2 { input: { - filesField: [ - { - fileId: '550e8400-e29b-41d4-a716-446655440000', - label: 'Document.pdf', - }, - { - fileId: '550e8400-e29b-41d4-a716-446655440001', - label: 'Document.pdf', - }, - { - fileId: '550e8400-e29b-41d4-a716-446655440002', - label: 'Document.pdf', - }, - { - fileId: '550e8400-e29b-41d4-a716-446655440003', - label: 'Document.pdf', - }, - { - fileId: '550e8400-e29b-41d4-a716-446655440004', - label: 'Document.pdf', - }, - { - fileId: '550e8400-e29b-41d4-a716-446655440005', - label: 'Document.pdf', - }, - ], + filesField: { + addFiles: [ + { + fileId: '550e8400-e29b-41d4-a716-446655440000', + label: 'Document.pdf', + extension: 'not-allowed-in-input', + }, + ], + }, }, }, ], diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts index 344a50e28d..d8e021c97e 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant.ts @@ -8,7 +8,7 @@ import { FieldMetadataType } from 'twenty-shared/types'; export const successfulCreateInputByFieldMetadataType: { [K in Exclude< FieldMetadataTypesToTestForCreateInputValidation, - FieldMetadataType.RICH_TEXT + FieldMetadataType.RICH_TEXT | FieldMetadataType.FILES // Done in files-field-sync.integration-spec.ts >]: { input: any; validateInput: (record: Record) => boolean; @@ -509,41 +509,4 @@ export const successfulCreateInputByFieldMetadataType: { }, }, ], - [FieldMetadataType.FILES]: [ - { - input: { - filesField: [ - { - fileId: '20202020-a21e-4ec2-873b-de4264d89025', - label: 'Document.pdf', - }, - ], - }, - validateInput: (record: Record) => { - return ( - Array.isArray(record.filesField) && - record.filesField.length === 1 && - record.filesField[0].fileId === - '20202020-a21e-4ec2-873b-de4264d89025' && - record.filesField[0].label === 'Document.pdf' - ); - }, - }, - { - input: { - filesField: [], - }, - validateInput: (record: Record) => { - return record.filesField === null; - }, - }, - { - input: { - filesField: null, - }, - validateInput: (record: Record) => { - return record.filesField === null; - }, - }, - ], }; diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts index fc1c26fcdb..129514396c 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/create-validation/files-field-create-input-validation.integration-spec.ts @@ -1,9 +1,6 @@ import { failingCreateInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/create-validation/constants/failing-create-input-by-field-metadata-type.constant'; -import { successfulCreateInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/create-validation/constants/successful-create-input-by-field-metadata-type.constant'; import { expectGqlCreateInputValidationError } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-error.util'; -import { expectGqlCreateInputValidationSuccess } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-gql-create-input-validation-success.util'; import { expectRestCreateInputValidationError } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-rest-create-input-validation-error.util'; -import { expectRestCreateInputValidationSuccess } from 'test/integration/graphql/suites/inputs-validation/create-validation/utils/expect-rest-create-input-validation-success.util'; import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata'; import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; @@ -17,8 +14,6 @@ const FIELD_METADATA_TYPE = FieldMetadataType.FILES; const failingTestCases = failingCreateInputByFieldMetadataType[FIELD_METADATA_TYPE]; -const successfulTestCases = - successfulCreateInputByFieldMetadataType[FIELD_METADATA_TYPE]; describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => { let objectMetadataId: string; @@ -94,42 +89,4 @@ describe(`Create input validation - ${FIELD_METADATA_TYPE}`, () => { }, ); }); - - describe('Gql create input - success', () => { - it.each( - successfulTestCases.map((testCase) => ({ - ...testCase, - stringifiedInput: JSON.stringify(testCase.input), - })), - )( - `${FIELD_METADATA_TYPE} - should succeed with : $stringifiedInput`, - async ({ input, validateInput }) => { - await expectGqlCreateInputValidationSuccess( - objectMetadataSingularName, - input, - validateInput, - true, - ); - }, - ); - }); - - describe('Rest create input - success', () => { - it.each( - successfulTestCases.map((testCase) => ({ - ...testCase, - stringifiedInput: JSON.stringify(testCase.input), - })), - )( - `${FIELD_METADATA_TYPE} - should succeed with : $stringifiedInput`, - async ({ input, validateInput }) => { - await expectRestCreateInputValidationSuccess( - objectMetadataPluralName, - objectMetadataSingularName, - input, - validateInput, - ); - }, - ); - }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts index a75d61fb22..726420ca6a 100644 --- a/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts +++ b/packages/twenty-server/test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util.ts @@ -1,5 +1,7 @@ +import gql from 'graphql-tag'; import { getFieldMetadataCreationInputs } from 'test/integration/graphql/suites/inputs-validation/utils/get-field-metadata-creation-inputs.util'; import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { makeGraphqlAPIRequestWithFileUpload } from 'test/integration/graphql/utils/make-graphql-api-request-with-file-upload.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; @@ -26,6 +28,17 @@ export const TEST_UUID_FIELD_VALUE = '20202020-b21e-4ec2-873b-de4264d89025'; export const TEST_TARGET_OBJECT_RECORD_ID_FIELD_VALUE = '20202020-b21e-4ec2-873b-de4264d89021'; +const uploadFilesFieldFileMutation = gql` + mutation UploadFilesFieldFile($file: Upload!) { + uploadFilesFieldFile(file: $file) { + id + path + size + createdAt + } + } +`; + export const joinColumnNameForManyToOneMorphRelationField1 = computeMorphRelationFieldName({ fieldName: 'manyToOneMorphRelationField', @@ -97,6 +110,33 @@ export const setupTestObjectsWithAllFieldTypes = async ( }), ); + let uploadedFileId: string | undefined; + + if (withFilesField) { + jest.useRealTimers(); + + const testFileContent = 'Test document content'; + const testFileName = 'Document.pdf'; + const testMimeType = 'application/pdf'; + + const uploadResponse = await makeGraphqlAPIRequestWithFileUpload( + { + query: uploadFilesFieldFileMutation, + variables: { file: null }, + }, + { + field: 'file', + buffer: Buffer.from(testFileContent), + filename: testFileName, + contentType: testMimeType, + }, + ); + + jest.useFakeTimers(); + + uploadedFileId = uploadResponse.body.data.uploadFilesFieldFile.id; + } + await makeGraphqlAPIRequest( createManyOperationFactory({ objectMetadataSingularName: TEST_OBJECT_METADATA_NAME_SINGULAR, @@ -165,11 +205,11 @@ export const setupTestObjectsWithAllFieldTypes = async ( test: 'test', }, arrayField: ['test'], - ...(withFilesField + ...(withFilesField && uploadedFileId ? { filesField: [ { - fileId: '20202020-a21e-4ec2-873b-de4264d89025', + fileId: uploadedFileId, label: 'Document.pdf', }, ], diff --git a/packages/twenty-shared/src/types/FileFolder.ts b/packages/twenty-shared/src/types/FileFolder.ts index 09bf3f0450..42f766f0be 100644 --- a/packages/twenty-shared/src/types/FileFolder.ts +++ b/packages/twenty-shared/src/types/FileFolder.ts @@ -11,6 +11,5 @@ export enum FileFolder { BuiltFrontComponent = 'built-front-component', PublicAsset = 'public-asset', Source = 'source', - TemporaryFilesField = 'temporary-files-field', FilesField = 'files-field', }