[Extensibility] Support relation fields (#17056)

Closes https://github.com/twentyhq/core-team-issues/issues/2043
This commit is contained in:
Marie
2026-01-12 13:54:30 +01:00
committed by GitHub
parent 91b788ee09
commit 3ada8e5168
4 changed files with 187 additions and 3 deletions
@@ -6,11 +6,12 @@ import {
ApplicationManifest,
FieldManifest,
ObjectManifest,
RelationFieldManifest,
RoleManifest,
ServerlessFunctionManifest,
ServerlessFunctionTriggerManifest,
} from 'twenty-shared/application';
import { HTTPMethod, Sources } from 'twenty-shared/types';
import { FieldMetadataType, HTTPMethod, Sources } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -89,6 +90,12 @@ export class ApplicationSyncService {
applicationId: application.id,
});
await this.syncRelations({
objectsToSync: manifest.objects,
workspaceId,
applicationId: application.id,
});
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(application.serverlessFunctionLayerId)) {
throw new ApplicationException(
@@ -350,6 +357,12 @@ export class ApplicationSyncService {
}
}
private isRelationFieldManifest(
field: FieldManifest | RelationFieldManifest,
): field is RelationFieldManifest {
return field.type === FieldMetadataType.RELATION;
}
private async syncFields({
objectId,
fieldsToSync,
@@ -359,12 +372,39 @@ export class ApplicationSyncService {
objectId: string;
workspaceId: string;
applicationId: string;
fieldsToSync?: FieldManifest[];
fieldsToSync?: (FieldManifest | RelationFieldManifest)[];
}) {
if (!isDefined(fieldsToSync)) {
return;
}
const regularFields = fieldsToSync.filter(
(field): field is FieldManifest => !this.isRelationFieldManifest(field),
);
await this.syncRegularFields({
objectId,
fieldsToSync: regularFields,
workspaceId,
applicationId,
});
}
private async syncRegularFields({
objectId,
fieldsToSync,
workspaceId,
applicationId,
}: {
objectId: string;
workspaceId: string;
applicationId: string;
fieldsToSync: FieldManifest[];
}) {
if (fieldsToSync.length === 0) {
return;
}
const { flatFieldMetadataMaps: existingFlatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -476,6 +516,82 @@ export class ApplicationSyncService {
}
}
private async syncRelationFields({
objectId,
relationsToSync,
workspaceId,
applicationId,
flatObjectMetadataMaps,
}: {
objectId: string;
relationsToSync: RelationFieldManifest[];
workspaceId: string;
applicationId: string;
flatObjectMetadataMaps: {
idByUniversalIdentifier: Partial<Record<string, string>>;
};
}) {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
for (const relation of relationsToSync) {
const existingRelationField = Object.values(
flatFieldMetadataMaps.byId,
).find(
(field) =>
isDefined(field) &&
field.universalIdentifier === relation.universalIdentifier,
);
if (isDefined(existingRelationField)) {
continue;
}
const targetObjectId =
flatObjectMetadataMaps.idByUniversalIdentifier[
relation.targetObjectUniversalIdentifier
];
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
`Failed to find target object with universalIdentifier ${relation.targetObjectUniversalIdentifier}`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
const createFieldInput: CreateFieldInput = {
name: computeMetadataNameFromLabelOrThrow(relation.label),
type: FieldMetadataType.RELATION,
label: relation.label,
description: relation.description ?? undefined,
icon: relation.icon ?? undefined,
objectMetadataId: objectId,
universalIdentifier: relation.universalIdentifier,
standardId: relation.universalIdentifier,
applicationId,
isCustom: true,
workspaceId,
relationCreationPayload: {
type: relation.relationType,
targetObjectMetadataId: targetObjectId,
targetFieldLabel: relation.targetFieldLabel,
targetFieldIcon: relation.targetFieldIcon ?? 'IconRelationOneToMany',
},
};
await this.fieldMetadataService.createOneField({
createFieldInput,
workspaceId,
applicationId,
});
}
}
private async syncObjects({
objectsToSync,
workspaceId,
@@ -607,6 +723,55 @@ export class ApplicationSyncService {
}
}
private async syncRelations({
objectsToSync,
workspaceId,
applicationId,
}: {
objectsToSync: ObjectManifest[];
workspaceId: string;
applicationId: string;
}) {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
for (const objectToSync of objectsToSync) {
const relationFields = objectToSync.fields.filter(
(field): field is RelationFieldManifest =>
this.isRelationFieldManifest(field),
);
if (relationFields.length === 0) {
continue;
}
const sourceObjectId =
flatObjectMetadataMaps.idByUniversalIdentifier[
objectToSync.universalIdentifier
];
if (!isDefined(sourceObjectId)) {
throw new ApplicationException(
`Failed to find source object with universalIdentifier ${objectToSync.universalIdentifier}`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
await this.syncRelationFields({
objectId: sourceObjectId,
relationsToSync: relationFields,
workspaceId,
applicationId,
flatObjectMetadataMaps,
});
}
}
private async syncServerlessFunctions({
serverlessFunctionsToSync,
code,
@@ -15,6 +15,7 @@ export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName';
export type { FieldManifest } from './fieldManifestType';
export type { ObjectManifest } from './objectManifestType';
export type { PackageJson } from './packageJsonType';
export type { RelationFieldManifest } from './relationFieldManifestType';
export type { RoleManifest } from './roleManifestType';
export type {
InputJsonSchema,
@@ -1,4 +1,5 @@
import { type FieldManifest } from '@/application';
import { type RelationFieldManifest } from '@/application/relationFieldManifestType';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type ObjectManifest = SyncableEntityOptions & {
@@ -8,5 +9,5 @@ export type ObjectManifest = SyncableEntityOptions & {
labelPlural: string;
description?: string;
icon?: string;
fields: FieldManifest[];
fields: (FieldManifest | RelationFieldManifest)[];
};
@@ -0,0 +1,17 @@
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import { type FieldMetadataType } from '@/types';
import { type RelationOnDeleteAction } from '@/types/RelationOnDeleteAction.type';
import { type RelationType } from '@/types/RelationType';
export type RelationFieldManifest = SyncableEntityOptions & {
name: string;
label: string;
description?: string;
icon?: string;
relationType: RelationType;
targetObjectUniversalIdentifier: string;
targetFieldLabel: string;
targetFieldIcon?: string;
onDelete?: RelationOnDeleteAction;
type: FieldMetadataType;
};