Files v2 - FILES field update/create in common API (#17400)

## FILES Field update interface

```
mutation {
  updateCompany(
    id: "company-uuid"
    data: {
      filesfield: [
          { fileId: "new-file-uuid", label: "new-document.pdf" }
        ]
    }
  ) {
    id
    filesfield {
      fileId
      label
      extension
    }
  }
}
```
This commit is contained in:
Etienne
2026-01-29 14:33:26 +01:00
committed by GitHub
parent 3dfb5ffc02
commit 09de762285
43 changed files with 1929 additions and 236 deletions
@@ -1562,7 +1562,6 @@ export enum FileFolder {
ProfilePicture = 'ProfilePicture',
PublicAsset = 'PublicAsset',
Source = 'Source',
TemporaryFilesField = 'TemporaryFilesField',
WorkspaceLogo = 'WorkspaceLogo'
}
@@ -1529,7 +1529,6 @@ export enum FileFolder {
ProfilePicture = 'ProfilePicture',
PublicAsset = 'PublicAsset',
Source = 'Source',
TemporaryFilesField = 'TemporaryFilesField',
WorkspaceLogo = 'WorkspaceLogo'
}
@@ -0,0 +1,15 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddFileSettingsColumnOnFileTable1769434782880
implements MigrationInterface
{
name = 'AddFileSettingsColumnOnFileTable1769434782880';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "core"."file" ADD "settings" jsonb`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "core"."file" DROP COLUMN "settings"`);
}
}
@@ -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);
@@ -0,0 +1,4 @@
export type FileItemInput = {
fileId: string;
label: string;
};
@@ -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);
});
});
@@ -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<typeof fileItemSchema>;
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}"`,
},
);
}
@@ -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';
@@ -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';
@@ -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);
@@ -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) },
},
});
@@ -110,7 +110,11 @@ export class TypeMapperService {
}: {
fieldMetadataType: FieldMetadataType;
typeOptions?: TypeOptions;
}): GraphQLScalarType | GraphQLList<GraphQLInputType> | undefined {
}):
| GraphQLScalarType
| GraphQLList<GraphQLInputType>
| GraphQLInputObjectType
| undefined {
if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
return GraphQLID;
}
@@ -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<FileEntity> {
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<void> {
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<void> {
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 };
@@ -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;
}
@@ -256,6 +256,10 @@ export class FileUploadService {
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId,
fileId,
settings: {
isTemporaryFile: true,
toDelete: false,
},
});
}
}
@@ -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 {}
@@ -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<FilesFieldExceptionCode> {
constructor(
message: string,
code: FilesFieldExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor },
) {
super(message, code, {
userFriendlyMessage,
});
}
}
@@ -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<void> {
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}`,
},
);
}
}
}
@@ -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<void> {
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}`);
}
}
}
}
@@ -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<ObjectRecordDestroyEvent>,
) {
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<string>();
for (const event of payload.events) {
const recordBefore = event.properties.before as Record<string, unknown>;
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<FilesFieldDeletionJobData>(
FilesFieldDeletionJob.name,
{
workspaceId,
fileIds: Array.from(fileIds),
},
);
}
}
}
@@ -51,9 +51,6 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
[FileFolder.FilesField]: {
ignoreExpirationToken: false,
},
[FileFolder.TemporaryFilesField]: {
ignoreExpirationToken: false,
},
};
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
@@ -0,0 +1,6 @@
export type FileFieldSettings = {
isTemporaryFile: boolean;
toDelete: boolean;
};
export type FileSettings = FileFieldSettings;
@@ -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);
});
});
@@ -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}/`, '');
};
@@ -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(),
@@ -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<string, ObjectLiteral>,
);
const filesFieldSync = new FilesFieldSync(this.internalContext);
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
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<Entity[]>(
@@ -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<FileEntity>;
constructor(internalContext: WorkspaceInternalContext) {
this.internalContext = internalContext;
this.fileRepository =
internalContext.coreDataSource.getRepository(FileEntity);
}
prepareFilesFieldSyncBeforeUpdate<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
existingRecords: ObjectLiteral[],
): FilesFieldDiffByEntityIndex | null {
return this.computeFilesFieldDiffBeforeUpdate(
entities,
target,
existingRecords,
);
}
computeFilesFieldDiffBeforeUpdate<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
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<string, unknown>,
filesField,
existingFilesValue,
);
if (diff) {
if (!filesFieldDiffByEntityIndex[index]) {
filesFieldDiffByEntityIndex[index] = {};
}
filesFieldDiffByEntityIndex[index][filesField.name] = diff;
}
}
});
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
computeFilesFieldDiffBeforeUpdateOne<Entity extends ObjectLiteral>(
updatePayload: QueryDeepPartialEntity<Entity>,
target: EntityTarget<Entity>,
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<string, unknown>,
filesField,
existingFilesValue,
);
if (diff) {
if (!filesFieldDiffByEntityIndex[0]) {
filesFieldDiffByEntityIndex[0] = {};
}
filesFieldDiffByEntityIndex[0][filesField.name] = diff;
}
}
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
computeFilesFieldDiffBeforeInsert<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
): 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<string, unknown>,
filesField,
[],
);
if (diff) {
if (!filesFieldDiffByEntityIndex[index]) {
filesFieldDiffByEntityIndex[index] = {};
}
filesFieldDiffByEntityIndex[index][filesField.name] = diff;
}
}
});
return Object.keys(filesFieldDiffByEntityIndex).length > 0
? filesFieldDiffByEntityIndex
: null;
}
computeFilesFieldDiffBeforeUpsert<Entity extends ObjectLiteral>(
entities: QueryDeepPartialEntity<Entity>[],
target: EntityTarget<Entity>,
existingRecordsMapById: Record<string, 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 = 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<string, unknown>,
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<string, unknown>,
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<Entity extends ObjectLiteral>({
entities,
filesFieldDiffByEntityIndex,
workspaceId,
target,
}: {
entities: QueryDeepPartialEntity<Entity>[];
filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex;
workspaceId: string;
target: EntityTarget<Entity>;
}): Promise<{
entities: QueryDeepPartialEntity<Entity>[];
fileIds: {
toAdd: Set<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
};
fileIdToApplicationId: Map<string, string>;
}> {
if (Object.keys(filesFieldDiffByEntityIndex).length === 0) {
return {
entities,
fileIds: {
toAdd: new Set<string>(),
toUpdate: new Set<string>(),
toRemove: new Set<string>(),
},
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<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
fileIdToApplicationId: Map<string, string>;
}> {
const allFileIds = {
toAdd: new Set<string>(),
toUpdate: new Set<string>(),
toRemove: new Set<string>(),
};
const fileIdToApplicationId = new Map<string, string>();
const allFileIdsToFetch = new Set<string>();
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<string>;
toUpdate: Set<string>;
toRemove: Set<string>;
},
fileIdToApplicationId: Map<string, string>,
): Promise<void> {
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<string, string[]>,
);
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<Entity>[],
filesFieldDiffByEntityIndex: FilesFieldDiffByEntityIndex,
): QueryDeepPartialEntity<Entity>[] {
return entities.map((entity, index) => {
const entityDiffs = filesFieldDiffByEntityIndex[index];
if (!entityDiffs) {
return entity;
}
const updatedEntity = { ...entity };
const updatedEntityAny = updatedEntity as Record<string, unknown>;
for (const [fieldName, diff] of Object.entries(entityDiffs)) {
const entityAny = entity as Record<string, unknown>;
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,
);
}
}
@@ -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';
@@ -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', () => {
@@ -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<void> {
@@ -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();
}
@@ -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', {
@@ -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<FeatureFlagKey, boolean>;
userWorkspaceRoleMap: Record<string, string>;
eventEmitterService: WorkspaceEventEmitter;
coreDataSource: DataSource;
}
@@ -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<T>,
@@ -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<string, string>();
const entities = Array.isArray(this.expressionMap.valuesSet)
? this.expressionMap.valuesSet
: [this.expressionMap.valuesSet];
const filesFieldDiffByEntityIndex =
this.filesFieldSync.computeFilesFieldDiffBeforeInsert(
entities as QueryDeepPartialEntityWithNestedRelationFields<T>[],
mainAliasTarget,
);
if (isDefined(filesFieldDiffByEntityIndex)) {
const result = await this.filesFieldSync.enrichFilesFields({
entities:
entities as QueryDeepPartialEntityWithNestedRelationFields<T>[],
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<T>,
@@ -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(
@@ -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<T>;
}[];
private filesFieldSync: FilesFieldSync;
constructor(
queryBuilder: UpdateQueryBuilder<T>,
@@ -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<T[]>(
before,
objectMetadata,
this.internalContext.flatObjectMetadataMaps,
this.internalContext.flatFieldMetadataMaps,
);
let filesFieldDiffByEntityIndex = null;
let filesFieldFileIds = null;
let fileIdToApplicationId = new Map<string, string>();
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<T[]>(
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<T[]>(
@@ -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<T[]>(
@@ -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<UploadedFile> => {
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<void> => {
await makeGraphqlAPIRequest({
query: deleteFileMutation,
variables: { fileId },
});
};
describe('fileFieldSync - FILES field <> files sync', () => {
let createdObjectMetadataId = '';
let uploadedFiles: UploadedFile[] = [];
const checkFileExistsInDB = async (fileId: string): Promise<boolean> => {
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<boolean> => {
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<boolean> => {
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 } },
},
});
});
});
@@ -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"]"`;
@@ -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',
},
],
},
},
},
],
@@ -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<string, any>) => boolean;
@@ -509,41 +509,4 @@ export const successfulCreateInputByFieldMetadataType: {
},
},
],
[FieldMetadataType.FILES]: [
{
input: {
filesField: [
{
fileId: '20202020-a21e-4ec2-873b-de4264d89025',
label: 'Document.pdf',
},
],
},
validateInput: (record: Record<string, any>) => {
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<string, any>) => {
return record.filesField === null;
},
},
{
input: {
filesField: null,
},
validateInput: (record: Record<string, any>) => {
return record.filesField === null;
},
},
],
};
@@ -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,
);
},
);
});
});
@@ -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',
},
],
@@ -11,6 +11,5 @@ export enum FileFolder {
BuiltFrontComponent = 'built-front-component',
PublicAsset = 'public-asset',
Source = 'source',
TemporaryFilesField = 'temporary-files-field',
FilesField = 'files-field',
}