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

## FILES Field update interface

```
mutation {
  updateCompany(
    id: "company-uuid"
    data: {
      filesfield: [
          { fileId: "new-file-uuid", label: "new-document.pdf" }
        ]
    }
  ) {
    id
    filesfield {
      fileId
      label
      extension
    }
  }
}
```
This commit is contained in:
Etienne
2026-01-29 14:33:26 +01:00
committed by GitHub
parent 3dfb5ffc02
commit 09de762285
43 changed files with 1929 additions and 236 deletions
@@ -251,7 +251,7 @@ export class DataArgProcessor {
const validatedValue = validateFilesFieldOrThrow(
value,
key,
fieldMetadata.settings as FieldMetadataSettingsMapping['FILES'],
fieldMetadata.settings as FieldMetadataSettingsMapping[FieldMetadataType.FILES],
);
return transformRawJsonField(validatedValue);
@@ -0,0 +1,4 @@
export type FileItemInput = {
fileId: string;
label: string;
};
@@ -2,43 +2,68 @@ import { validateFilesFieldOrThrow } from 'src/engine/api/common/common-args-pro
import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
describe('validateFilesFieldOrThrow', () => {
const mockSettings = { maxNumberOfValues: 10 };
describe('valid inputs', () => {
it('should return null when value is null', () => {
const result = validateFilesFieldOrThrow(null, 'testField', {
maxNumberOfValues: 10,
});
const result = validateFilesFieldOrThrow(null, 'testField', mockSettings);
expect(result).toBeNull();
});
it('should return the files array when all fields are valid', () => {
it('should return the files array when value is valid', () => {
const filesValue = [
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
{ fileId: '660e8400-e29b-41d4-a716-446655440001', label: 'Document 2' },
{
fileId: '550e8400-e29b-41d4-a716-446655440000',
label: 'Document 1',
},
{
fileId: '660e8400-e29b-41d4-a716-446655440001',
label: 'Document 2',
},
];
const result = validateFilesFieldOrThrow(filesValue, 'testField', {
maxNumberOfValues: 10,
});
const result = validateFilesFieldOrThrow(
filesValue,
'testField',
mockSettings,
);
expect(result).toEqual(filesValue);
});
it('should return an empty array when value is an empty array', () => {
const result = validateFilesFieldOrThrow([], 'testField', {
maxNumberOfValues: 10,
});
const result = validateFilesFieldOrThrow([], 'testField', mockSettings);
expect(result).toEqual([]);
});
it('should parse and return valid stringified JSON array', () => {
const filesValue = [
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'Document 1' },
{
fileId: '550e8400-e29b-41d4-a716-446655440000',
label: 'Document 1',
},
];
const stringifiedValue = JSON.stringify(filesValue);
const result = validateFilesFieldOrThrow(stringifiedValue, 'testField', {
maxNumberOfValues: 10,
});
const result = validateFilesFieldOrThrow(
stringifiedValue,
'testField',
mockSettings,
);
expect(result).toEqual(filesValue);
});
it('should accept files array up to max limit', () => {
const filesValue = Array.from({ length: 10 }, (_, index) => ({
fileId: `550e8400-e29b-41d4-a716-44665544000${index}`,
label: `Document ${index}`,
}));
const result = validateFilesFieldOrThrow(
filesValue,
'testField',
mockSettings,
);
expect(result).toEqual(filesValue);
});
@@ -47,51 +72,39 @@ describe('validateFilesFieldOrThrow', () => {
describe('invalid inputs', () => {
it('should throw when value is an invalid JSON string', () => {
expect(() =>
validateFilesFieldOrThrow('not valid json', 'testField', {
maxNumberOfValues: 10,
}),
validateFilesFieldOrThrow('not valid json', 'testField', mockSettings),
).toThrow(CommonQueryRunnerException);
});
it('should throw when value is not an array', () => {
it('should throw when value is an object instead of array', () => {
expect(() =>
validateFilesFieldOrThrow(
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
'testField',
{ maxNumberOfValues: 10 },
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
it('should throw when value is undefined', () => {
expect(() =>
validateFilesFieldOrThrow(undefined, 'testField', {
maxNumberOfValues: 10,
}),
).toThrow(CommonQueryRunnerException);
});
it('should throw when array item is not an object', () => {
expect(() =>
validateFilesFieldOrThrow(['not an object'], 'testField', {
maxNumberOfValues: 10,
}),
validateFilesFieldOrThrow(['not an object'], 'testField', mockSettings),
).toThrow(CommonQueryRunnerException);
});
it('should throw when array item is null', () => {
expect(() =>
validateFilesFieldOrThrow([null], 'testField', {
maxNumberOfValues: 10,
}),
validateFilesFieldOrThrow([null], 'testField', mockSettings),
).toThrow(CommonQueryRunnerException);
});
it('should throw when fileId key is missing', () => {
expect(() =>
validateFilesFieldOrThrow([{ label: 'test' }], 'testField', {
maxNumberOfValues: 10,
}),
validateFilesFieldOrThrow(
[{ label: 'test' }],
'testField',
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
@@ -100,12 +113,12 @@ describe('validateFilesFieldOrThrow', () => {
validateFilesFieldOrThrow(
[{ fileId: '550e8400-e29b-41d4-a716-446655440000' }],
'testField',
{ maxNumberOfValues: 10 },
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
it('should throw when extra keys are present', () => {
it('should throw when extra keys are present in file item', () => {
expect(() =>
validateFilesFieldOrThrow(
[
@@ -116,7 +129,7 @@ describe('validateFilesFieldOrThrow', () => {
},
],
'testField',
{ maxNumberOfValues: 10 },
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
@@ -126,7 +139,7 @@ describe('validateFilesFieldOrThrow', () => {
validateFilesFieldOrThrow(
[{ fileId: 'not-a-uuid', label: 'test' }],
'testField',
{ maxNumberOfValues: 10 },
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
@@ -136,7 +149,7 @@ describe('validateFilesFieldOrThrow', () => {
validateFilesFieldOrThrow(
[{ fileId: 12345, label: 'test' }],
'testField',
{ maxNumberOfValues: 10 },
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
@@ -146,20 +159,19 @@ describe('validateFilesFieldOrThrow', () => {
validateFilesFieldOrThrow(
[{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 12345 }],
'testField',
{ maxNumberOfValues: 10 },
mockSettings,
),
).toThrow(CommonQueryRunnerException);
});
it('should throw when max number of files is exceeded', () => {
it('should throw when number of files exceeds max limit', () => {
const filesValue = Array.from({ length: 11 }, (_, index) => ({
fileId: `550e8400-e29b-41d4-a716-44665544000${index}`,
label: `Document ${index}`,
}));
expect(() =>
validateFilesFieldOrThrow(
[
{ fileId: '550e8400-e29b-41d4-a716-446655440000', label: 'test' },
{ fileId: '550e8400-e29b-41d4-a716-446655440001', label: 'test' },
],
'testField',
{ maxNumberOfValues: 1 },
),
validateFilesFieldOrThrow(filesValue, 'testField', mockSettings),
).toThrow(CommonQueryRunnerException);
});
});
@@ -2,15 +2,19 @@ import { inspect } from 'util';
import { msg } from '@lingui/core/macro';
import { isNull } from '@sniptt/guards';
import {
type FieldMetadataSettingsMapping,
type FieldMetadataType,
} from 'twenty-shared/types';
import { z } from 'zod';
import { type FieldMetadataSettingsMapping } from 'twenty-shared/types';
import { type FileItemInput } from 'src/engine/api/common/common-args-processors/data-arg-processor/types/file-item.type';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
export const fileItemSchema = z
const fileItemSchema = z
.object({
fileId: z.string().uuidv4(),
label: z.string(),
@@ -19,13 +23,11 @@ export const fileItemSchema = z
export const filesFieldSchema = z.array(fileItemSchema);
export type FileItem = z.infer<typeof fileItemSchema>;
export const validateFilesFieldOrThrow = (
value: unknown,
fieldName: string,
settings: FieldMetadataSettingsMapping['FILES'],
): FileItem[] | null => {
settings: FieldMetadataSettingsMapping[FieldMetadataType.FILES],
): FileItemInput[] | null => {
if (isNull(value)) return null;
let parsedValue: unknown = value;
@@ -67,10 +69,10 @@ export const validateFilesFieldOrThrow = (
const maxNumberOfValues = settings.maxNumberOfValues;
throw new CommonQueryRunnerException(
`Max number of files is ${maxNumberOfValues}`,
`Max number of files is ${maxNumberOfValues} for field "${fieldName}"`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{
userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues}`,
userFriendlyMessage: msg`Max number of files is ${maxNumberOfValues} for field "${fieldName}"`,
},
);
}
@@ -6,7 +6,6 @@ import { ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FindOptionsRelations, In, InsertResult, ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import { PartialObjectRecordWithId } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/types/partial-object-record-with-id.type';
import { buildWhereConditions } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/utils/build-where-conditions.util';
@@ -31,6 +30,7 @@ import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runne
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
@@ -5,7 +5,6 @@ import { QUERY_MAX_RECORDS_FROM_RELATION } from 'twenty-shared/constants';
import { ObjectRecord } from 'twenty-shared/types';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import {
CommonQueryRunnerException,
@@ -22,6 +21,7 @@ import {
} from 'src/engine/api/common/types/common-query-args.type';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';