1630 extensibility twenty cli ability to create edit and delete fields (#15501)

As title

- adds decorators in twenty-sdk
- update twenty-cli load-manifest to it gets @FieldMetadata infos +
testing
- update twenty-server so it CRUD fields properly, using
universalIdentifier
- Fix UI so we can update managed objects records
- move FieldMetadata items from twenty-server to twenty-shared
This commit is contained in:
martmull
2025-11-05 17:50:06 +01:00
committed by GitHub
parent 9fef0752a1
commit abde3c04ac
123 changed files with 1235 additions and 289 deletions
@@ -13,6 +13,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
catch(exception: ApplicationException) {
switch (exception.code) {
case ApplicationExceptionCode.OBJECT_NOT_FOUND:
case ApplicationExceptionCode.FIELD_NOT_FOUND:
case ApplicationExceptionCode.ENTITY_NOT_FOUND:
case ApplicationExceptionCode.APPLICATION_NOT_FOUND:
case ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND:
@@ -13,12 +13,12 @@ import {
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import {
FieldManifest,
ObjectManifest,
ServerlessFunctionManifest,
ServerlessFunctionTriggerManifest,
} from 'src/engine/core-modules/application/types/application.types';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
import { CronTriggerV2Service } from 'src/engine/metadata-modules/cron-trigger/services/cron-trigger-v2.service';
import { FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -35,6 +35,11 @@ import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serv
import { ServerlessFunctionV2Service } from 'src/engine/metadata-modules/serverless-function/services/serverless-function-v2.service';
import { FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
import { Sources } from 'src/engine/core-modules/file-storage/types/source.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
@Injectable()
export class ApplicationSyncService {
@@ -45,6 +50,7 @@ export class ApplicationSyncService {
private readonly applicationVariableService: ApplicationVariableEntityService,
private readonly serverlessFunctionLayerService: ServerlessFunctionLayerService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly fieldMetadataServiceV2: FieldMetadataServiceV2,
private readonly serverlessFunctionV2Service: ServerlessFunctionV2Service,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly dataSourceService: DataSourceService,
@@ -155,6 +161,131 @@ export class ApplicationSyncService {
return application;
}
private async syncFields({
objectId,
fieldsToSync,
workspaceId,
applicationId,
}: {
objectId: string;
workspaceId: string;
applicationId: string;
fieldsToSync?: FieldManifest[];
}) {
if (!isDefined(fieldsToSync)) {
return;
}
const { flatFieldMetadataMaps: existingFlatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const existingFields = Object.values(
existingFlatFieldMetadataMaps.byId,
).filter(
(field) => isDefined(field) && field.objectMetadataId === objectId,
) as FlatFieldMetadata[];
const fieldsToSyncUniversalIds = fieldsToSync.map(
(field) => field.universalIdentifier,
);
const existingFieldsStandardIds = existingFields.map(
(field) => field.universalIdentifier,
);
const fieldsToDelete = existingFields.filter(
(field) =>
isDefined(field.universalIdentifier) &&
!fieldsToSyncUniversalIds.includes(field.universalIdentifier) &&
field.isCustom === true,
);
const fieldsToUpdate = existingFields.filter(
(field) =>
isDefined(field.universalIdentifier) &&
fieldsToSyncUniversalIds.includes(field.universalIdentifier),
);
const fieldsToCreate = fieldsToSync.filter(
(fieldToSync) =>
!existingFieldsStandardIds.includes(fieldToSync.universalIdentifier),
);
for (const fieldToDelete of fieldsToDelete) {
await this.fieldMetadataServiceV2.updateOne({
updateFieldInput: {
id: fieldToDelete.id,
isActive: false,
},
workspaceId,
});
await this.fieldMetadataServiceV2.deleteOneField({
deleteOneFieldInput: { id: fieldToDelete.id },
workspaceId,
});
}
for (const fieldToUpdate of fieldsToUpdate) {
const fieldToSync = fieldsToSync.find(
(field) =>
field.universalIdentifier === fieldToUpdate.universalIdentifier,
);
if (!fieldToSync) {
throw new ApplicationException(
`Failed to find field to sync with universalIdentifier ${fieldToUpdate.universalIdentifier}`,
ApplicationExceptionCode.FIELD_NOT_FOUND,
);
}
const updateFieldInput = {
id: fieldToUpdate.id,
label: fieldToSync.label,
description: fieldToSync.description ?? undefined,
icon: fieldToSync.icon ?? undefined,
defaultValue: fieldToSync.defaultValue ?? undefined,
options: fieldToSync.options ?? undefined,
settings: fieldToSync.settings ?? undefined,
isNullable: fieldToSync.isNullable ?? true,
};
await this.fieldMetadataServiceV2.updateOne({
updateFieldInput,
workspaceId,
});
}
for (const fieldToCreate of fieldsToCreate) {
const createFieldInput: CreateFieldInput = {
name: computeMetadataNameFromLabel(fieldToCreate.label),
type: fieldToCreate.type,
label: fieldToCreate.label,
description: fieldToCreate.description ?? undefined,
icon: fieldToCreate.icon ?? undefined,
defaultValue: fieldToCreate.defaultValue ?? undefined,
options: fieldToCreate.options ?? undefined,
settings: fieldToCreate.settings ?? undefined,
isNullable: fieldToCreate.isNullable ?? true,
objectMetadataId: objectId,
universalIdentifier: fieldToCreate.universalIdentifier,
standardId: fieldToCreate.universalIdentifier,
applicationId,
isCustom: true,
workspaceId,
};
await this.fieldMetadataServiceV2.createOne({
createFieldInput,
workspaceId,
});
}
}
private async syncObjects({
objectsToSync,
workspaceId,
@@ -243,6 +374,13 @@ export class ApplicationSyncService {
updateObjectInput,
workspaceId,
});
await this.syncFields({
fieldsToSync: objectToSync.fields,
objectId: objectToUpdate.id,
workspaceId,
applicationId,
});
}
const dataSourceMetadata =
@@ -264,10 +402,17 @@ export class ApplicationSyncService {
applicationId,
};
await this.objectMetadataServiceV2.createOne({
const createdObject = await this.objectMetadataServiceV2.createOne({
createObjectInput,
workspaceId,
});
await this.syncFields({
fieldsToSync: objectToCreate.fields,
objectId: createdObject.id,
workspaceId,
applicationId,
});
}
}
@@ -4,6 +4,7 @@ export class ApplicationException extends CustomException<ApplicationExceptionCo
export enum ApplicationExceptionCode {
OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND',
FIELD_NOT_FOUND = 'FIELD_NOT_FOUND',
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND',
APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
@@ -17,12 +17,14 @@ import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/ro
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationEntity, AgentEntity, WorkspaceEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ObjectMetadataModule,
FieldMetadataModule,
DataSourceModule,
ApplicationVariableEntityModule,
ServerlessFunctionLayerModule,
@@ -1,3 +1,5 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { type HTTPMethod } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
export type PackageJson = {
@@ -66,6 +68,22 @@ export type ServerlessFunctionTriggerManifest = (
universalIdentifier: string;
};
export type FieldManifest = {
universalIdentifier: string;
type: FieldMetadataType;
label: string;
description?: string;
icon?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defaultValue?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings?: any;
isNullable?: boolean;
isFieldUiReadOnly?: boolean;
};
export type ObjectManifest = {
universalIdentifier: string;
nameSingular: string;
@@ -74,6 +92,7 @@ export type ObjectManifest = {
labelPlural: string;
description?: string;
icon?: string;
fields?: FieldManifest[];
};
interface AgentResponseFormat {