Files v2 - Add new FILES field type (#17236)

In this PR : 
- New field type FieldMetadataType.FILES added (as jsonb in DB)
- Backend: GraphQL types, validation, REST API schema, data processor
handling
- Frontend: field configuration in settings
- Feature flag IS_FILES_FIELD_ENABLED to gate the feature
- Integration tests for create/filter validation
- Dev seed: Feature flag enabled + FILES field on survey result object


To do in next PRs : 
- Backend : 
-- new workspaceFile controller (for download) & new workspaceFile
upload resolver (for upload)
-- pre-hook/post-hook to ensure fileId existence + listener to ensure
cleaning
- Frontend : FILES field display/edit in table view, record, ...
This commit is contained in:
Etienne
2026-01-20 18:03:49 +01:00
committed by GitHub
parent 41329a0ea9
commit 30c13fc947
72 changed files with 2278 additions and 92 deletions
@@ -5,8 +5,8 @@ import {
GraphQLInputObjectType,
isObjectType,
} from 'graphql';
import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
@@ -72,7 +72,10 @@ export class CompositeFieldMetadataCreateGqlInputTypeGenerator {
const type = isEnumFieldMetadataType(property.type)
? this.gqlTypesStorage.getGqlTypeByKey(key)
: this.typeMapperService.mapToScalarType(property.type, typeOptions);
: this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: property.type,
typeOptions,
});
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`;
@@ -187,10 +187,10 @@ export class ObjectMetadataCreateGqlInputTypeGenerator {
fieldMetadata: FlatFieldMetadata,
typeOptions: TypeOptions,
) {
const type = this.typeMapperService.mapToScalarType(
fieldMetadata.type,
const type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: fieldMetadata.type,
typeOptions,
);
});
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
@@ -123,9 +123,10 @@ export class RelationConnectGqlInputTypeGenerator {
> = {};
uniqueProperties.forEach((property) => {
const scalarType = this.typeMapperService.mapToScalarType(
property.type,
);
const scalarType =
this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: property.type,
});
compositeFields[property.name] = {
type: scalarType || GraphQLString,
@@ -144,10 +145,14 @@ export class RelationConnectGqlInputTypeGenerator {
};
}
} else {
const scalarType = this.typeMapperService.mapToScalarType(
field.type,
{ settings: field.settings, isIdField: field.name === 'id' },
);
const scalarType =
this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: field.type,
typeOptions: {
settings: field.settings,
isIdField: field.name === 'id',
},
});
inputFields[field.name] = {
type: scalarType || GraphQLString,
@@ -45,10 +45,10 @@ export class RelationFieldMetadataGqlInputTypeGenerator {
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
const type = this.typeMapperService.mapToScalarType(
fieldMetadata.type,
const type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: fieldMetadata.type,
typeOptions,
);
});
if (!isDefined(type)) {
const message = `Could not find a GraphQL input type for ${type} field metadata`;
@@ -5,8 +5,8 @@ import {
GraphQLInputObjectType,
isObjectType,
} from 'graphql';
import { isDefined } from 'twenty-shared/utils';
import { CompositeType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
@@ -73,7 +73,10 @@ export class CompositeFieldMetadataUpdateGqlInputTypeGenerator {
const type = isEnumFieldMetadataType(property.type)
? this.gqlTypesStorage.getGqlTypeByKey(key)
: this.typeMapperService.mapToScalarType(property.type, typeOptions);
: this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: property.type,
typeOptions,
});
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${compositeType.type} ${property.name}`;
@@ -4,10 +4,12 @@ import {
GraphQLEnumType,
GraphQLInputFieldConfigMap,
GraphQLInputObjectType,
GraphQLList,
GraphQLScalarType,
isEnumType,
isInputObjectType,
isObjectType,
type GraphQLInputType,
} from 'graphql';
import { isDefined } from 'twenty-shared/utils';
@@ -97,6 +99,7 @@ export class ObjectMetadataUpdateGqlInputTypeGenerator {
| GraphQLInputObjectType
| GraphQLEnumType
| GraphQLScalarType
| GraphQLList<GraphQLInputType>
| undefined;
if (isEnumFieldMetadataType(fieldMetadata.type)) {
@@ -138,10 +141,10 @@ export class ObjectMetadataUpdateGqlInputTypeGenerator {
type = compositeType;
} else {
type = this.typeMapperService.mapToScalarType(
fieldMetadata.type,
type = this.typeMapperService.mapToPreBuiltGraphQLInputType({
fieldMetadataType: fieldMetadata.type,
typeOptions,
);
});
if (!isDefined(type) || isObjectType(type)) {
const message = `Could not find a GraphQL input type for ${fieldMetadata.type} field metadata`;
@@ -71,7 +71,10 @@ export class CompositeFieldMetadataGqlObjectTypeGenerator {
const type = isEnumFieldMetadataType(property.type)
? this.gqlTypesStorage.getGqlTypeByKey(key)
: this.typeMapperService.mapToScalarType(property.type, typeOptions);
: this.typeMapperService.mapToPreBuiltGraphQLOutputType({
fieldMetadataType: property.type,
typeOptions,
});
if (!isDefined(type) || isInputObjectType(type)) {
const message = `Could not find a GraphQL object type for ${compositeType.type} ${property.name}`;
@@ -134,10 +134,10 @@ export class ObjectMetadataGqlObjectTypeGenerator {
type = enumFieldEnumType;
} else {
type = this.typeMapperService.mapToScalarType(
field.type,
typeFactoryOptions,
);
type = this.typeMapperService.mapToPreBuiltGraphQLOutputType({
fieldMetadataType: field.type,
typeOptions: typeFactoryOptions,
});
if (!isDefined(type)) {
const message = `Could not find a GraphQL output type for ${field.name} scalar field metadata of object ${objectNameSingular}`;
@@ -32,10 +32,10 @@ export class RelationFieldMetadataGqlObjectTypeGenerator {
const { joinColumnName } = extractGraphQLRelationFieldNames(fieldMetadata);
const type = this.typeMapperService.mapToScalarType(
fieldMetadata.type,
const type = this.typeMapperService.mapToPreBuiltGraphQLOutputType({
fieldMetadataType: fieldMetadata.type,
typeOptions,
);
});
if (!isDefined(type)) {
const message = `Could not find a GraphQL output type for ${type} field metadata`;
@@ -0,0 +1,18 @@
import {
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
GraphQLString,
} from 'graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
const FileInputType = new GraphQLInputObjectType({
name: 'FileInput',
fields: {
fileId: { type: new GraphQLNonNull(UUIDScalarType) },
label: { type: new GraphQLNonNull(GraphQLString) },
},
});
export const FilesInputType = new GraphQLList(FileInputType);
@@ -0,0 +1,30 @@
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 },
//TODO: Will be made non-nullable in a future PR
// fileCategory: { type: new GraphQLNonNull(FileCategoryEnumType) },
},
});
export const FilesObjectType = new GraphQLList(FileObjectType);
@@ -9,17 +9,18 @@ import {
type GraphQLInputType,
GraphQLList,
GraphQLNonNull,
type GraphQLOutputType,
type GraphQLScalarType,
GraphQLString,
type GraphQLType,
} from 'graphql';
import GraphQLJSON from 'graphql-type-json';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataType,
type FieldMetadataSettings,
FieldMetadataType,
NumberDataType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
@@ -34,11 +35,13 @@ import {
RawJsonFilterType,
StringFilterType,
} from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input';
import { FilesInputType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/files.input-type';
import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type';
import { RichTextV2FilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/rich-text.input-type';
import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type';
import { TSVectorFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/ts-vector-filter.input-type';
import { UUIDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/uuid-filter.input-type';
import { FilesObjectType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/object/files.object-type';
import {
BigFloatScalarType,
DateScalarType,
@@ -63,43 +66,85 @@ const StringArrayScalarType = new GraphQLList(GraphQLString);
@Injectable()
export class TypeMapperService {
mapToScalarType(
fieldMetadataType: FieldMetadataType,
typeOptions?: TypeOptions,
): GraphQLScalarType | undefined {
if (
typeOptions?.isIdField ||
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
) {
private readonly baseTypeScalarMapping = new Map<
FieldMetadataType,
GraphQLScalarType | GraphQLList<GraphQLScalarType>
>([
[FieldMetadataType.UUID, UUIDScalarType],
[FieldMetadataType.TEXT, GraphQLString],
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
[FieldMetadataType.DATE, DateScalarType],
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
[FieldMetadataType.NUMERIC, BigFloatScalarType],
[FieldMetadataType.POSITION, PositionScalarType],
[FieldMetadataType.RAW_JSON, GraphQLJSON],
[FieldMetadataType.ARRAY, StringArrayScalarType],
[FieldMetadataType.RICH_TEXT, GraphQLString],
[FieldMetadataType.TS_VECTOR, TSVectorScalarType],
]);
mapToPreBuiltGraphQLOutputType({
fieldMetadataType,
typeOptions,
}: {
fieldMetadataType: FieldMetadataType;
typeOptions?: TypeOptions;
}): GraphQLScalarType | GraphQLList<GraphQLOutputType> | undefined {
if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
return GraphQLID;
}
const typeScalarMapping = new Map<FieldMetadataType, GraphQLScalarType>([
[FieldMetadataType.UUID, UUIDScalarType],
[FieldMetadataType.TEXT, GraphQLString],
[FieldMetadataType.DATE_TIME, GraphQLISODateTime],
[FieldMetadataType.DATE, DateScalarType],
[FieldMetadataType.BOOLEAN, GraphQLBoolean],
[
FieldMetadataType.NUMBER,
getNumberScalarType(
(
typeOptions?.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>
)?.dataType ?? NumberDataType.FLOAT,
),
],
[FieldMetadataType.NUMERIC, BigFloatScalarType],
[FieldMetadataType.POSITION, PositionScalarType],
[FieldMetadataType.RAW_JSON, GraphQLJSON],
[
FieldMetadataType.ARRAY,
StringArrayScalarType as unknown as GraphQLScalarType,
],
[FieldMetadataType.RICH_TEXT, GraphQLString],
[FieldMetadataType.TS_VECTOR, TSVectorScalarType],
]);
return typeScalarMapping.get(fieldMetadataType);
if (fieldMetadataType === FieldMetadataType.NUMBER) {
return this.getNumberScalarTypeFromOptions(typeOptions);
}
if (fieldMetadataType === FieldMetadataType.FILES) {
return FilesObjectType;
}
return this.baseTypeScalarMapping.get(fieldMetadataType);
}
mapToPreBuiltGraphQLInputType({
fieldMetadataType,
typeOptions,
}: {
fieldMetadataType: FieldMetadataType;
typeOptions?: TypeOptions;
}): GraphQLScalarType | GraphQLList<GraphQLInputType> | undefined {
if (this.isIdOrRelationType(fieldMetadataType, typeOptions)) {
return GraphQLID;
}
if (fieldMetadataType === FieldMetadataType.NUMBER) {
return this.getNumberScalarTypeFromOptions(typeOptions);
}
if (fieldMetadataType === FieldMetadataType.FILES) {
return FilesInputType;
}
return this.baseTypeScalarMapping.get(fieldMetadataType);
}
private isIdOrRelationType(
fieldMetadataType: FieldMetadataType,
typeOptions?: TypeOptions,
): boolean {
return (
typeOptions?.isIdField === true ||
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION
);
}
private getNumberScalarTypeFromOptions(
typeOptions?: TypeOptions,
): GraphQLScalarType {
return getNumberScalarType(
(typeOptions?.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>)
?.dataType ?? NumberDataType.FLOAT,
);
}
mapToFilterType(
@@ -133,6 +178,7 @@ export class TypeMapperService {
],
[FieldMetadataType.NUMERIC, BigFloatFilterType],
[FieldMetadataType.POSITION, FloatFilterType],
[FieldMetadataType.FILES, RawJsonFilterType],
[FieldMetadataType.RAW_JSON, RawJsonFilterType],
[FieldMetadataType.RICH_TEXT, StringFilterType],
[FieldMetadataType.RICH_TEXT_V2, RichTextV2FilterType],
@@ -162,6 +208,7 @@ export class TypeMapperService {
[FieldMetadataType.SELECT, OrderByDirectionType],
[FieldMetadataType.MULTI_SELECT, OrderByDirectionType],
[FieldMetadataType.POSITION, OrderByDirectionType],
[FieldMetadataType.FILES, OrderByDirectionType],
[FieldMetadataType.RAW_JSON, OrderByDirectionType],
[FieldMetadataType.RICH_TEXT, OrderByDirectionType],
[FieldMetadataType.ARRAY, OrderByDirectionType],