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:
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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"`);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type FileItemInput = {
|
||||
fileId: string;
|
||||
label: string;
|
||||
};
|
||||
+65
-53
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+10
-8
@@ -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}"`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
+2
-11
@@ -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) },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
+75
-1
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -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 {}
|
||||
|
||||
+19
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+40
@@ -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}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-3
@@ -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;
|
||||
+45
@@ -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);
|
||||
});
|
||||
});
|
||||
+12
@@ -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}/`, '');
|
||||
};
|
||||
+8
@@ -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(),
|
||||
|
||||
+41
-1
@@ -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[]>(
|
||||
|
||||
+616
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
-1
@@ -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', () => {
|
||||
+6
@@ -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();
|
||||
}
|
||||
|
||||
+3
@@ -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', {
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+43
-1
@@ -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(
|
||||
|
||||
+86
-8
@@ -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[]>(
|
||||
|
||||
+575
@@ -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 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+10
-14
@@ -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"]"`;
|
||||
|
||||
+17
-40
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+1
-38
@@ -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;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
-43
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+42
-2
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user