Allow app to extend object (#17116)

- exports `extendObject` utils from `twenty-sdk`
- define `*.object-extension.ts` filename pattern to extend and object
- handle object-extension configs in manifest
This commit is contained in:
martmull
2026-01-13 16:07:14 +01:00
committed by GitHub
parent f1be7129cb
commit d55b83375d
23 changed files with 1300 additions and 88 deletions
@@ -22,6 +22,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND:
throw new NotFoundError(exception);
case ApplicationExceptionCode.FORBIDDEN:
case ApplicationExceptionCode.INVALID_INPUT:
throw new UserInputError(exception);
default: {
assertUnreachable(exception.code);
@@ -5,6 +5,7 @@ import { parse } from 'path';
import {
ApplicationManifest,
FieldManifest,
ObjectExtensionManifest,
ObjectManifest,
RelationFieldManifest,
RoleManifest,
@@ -96,6 +97,14 @@ export class ApplicationSyncService {
applicationId: application.id,
});
if (manifest.objectExtensions && manifest.objectExtensions.length > 0) {
await this.syncObjectExtensionsOrThrow({
objectExtensionsToSync: manifest.objectExtensions,
workspaceId,
applicationId: application.id,
});
}
if (manifest.serverlessFunctions.length > 0) {
if (!isDefined(application.serverlessFunctionLayerId)) {
throw new ApplicationException(
@@ -772,6 +781,87 @@ export class ApplicationSyncService {
}
}
private async syncObjectExtensionsOrThrow({
objectExtensionsToSync,
workspaceId,
applicationId,
}: {
objectExtensionsToSync: ObjectExtensionManifest[];
workspaceId: string;
applicationId: string;
}) {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const { idByNameSingular: objectIdByNameSingular } =
buildObjectIdByNameMaps(flatObjectMetadataMaps);
for (const objectExtension of objectExtensionsToSync) {
const { targetObject, fields } = objectExtension;
let targetObjectId: string | undefined;
if (isDefined(targetObject.nameSingular)) {
targetObjectId = objectIdByNameSingular[targetObject.nameSingular];
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
`Failed to find target object with nameSingular "${targetObject.nameSingular}" for object extension`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
} else if (isDefined(targetObject.universalIdentifier)) {
targetObjectId =
flatObjectMetadataMaps.idByUniversalIdentifier[
targetObject.universalIdentifier
];
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
`Failed to find target object with universalIdentifier "${targetObject.universalIdentifier}" for object extension`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
}
if (!isDefined(targetObjectId)) {
throw new ApplicationException(
'Object extension must specify either nameSingular or universalIdentifier in targetObject',
ApplicationExceptionCode.INVALID_INPUT,
);
}
// Sync regular fields for this extension
await this.syncFields({
objectId: targetObjectId,
fieldsToSync: fields,
workspaceId,
applicationId,
});
// Sync relation fields for this extension
const relationFields = fields.filter(
(field): field is RelationFieldManifest =>
this.isRelationFieldManifest(field),
);
if (relationFields.length > 0) {
await this.syncRelationFields({
objectId: targetObjectId,
relationsToSync: relationFields,
workspaceId,
applicationId,
flatObjectMetadataMaps,
});
}
}
}
private async syncServerlessFunctions({
serverlessFunctionsToSync,
code,
@@ -11,6 +11,7 @@ export enum ApplicationExceptionCode {
ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND',
APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
FORBIDDEN = 'FORBIDDEN',
INVALID_INPUT = 'INVALID_INPUT',
}
const getApplicationExceptionUserFriendlyMessage = (
@@ -29,6 +30,8 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`Application not found.`;
case ApplicationExceptionCode.FORBIDDEN:
return msg`You do not have permission to perform this action.`;
case ApplicationExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
default:
assertUnreachable(code);
}