diff --git a/packages/twenty-sdk/package.json b/packages/twenty-sdk/package.json index 52cf85a1c8..114f765496 100644 --- a/packages/twenty-sdk/package.json +++ b/packages/twenty-sdk/package.json @@ -31,6 +31,7 @@ "license": "AGPL-3.0", "dependencies": { "@genql/cli": "^3.0.3", + "@sniptt/guards": "^0.2.0", "axios": "^1.6.0", "chalk": "^5.3.0", "chokidar": "^4.0.0", diff --git a/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts b/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts index ccfe267990..044bdf3253 100644 --- a/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts +++ b/packages/twenty-sdk/src/application/__tests__/define-object.spec.ts @@ -114,38 +114,6 @@ describe('defineObject', () => { expect(result.fields).toBeUndefined(); }); - it('should throw error when field is missing universalIdentifier', () => { - const config = { - ...validConfig, - fields: [ - { - type: FieldMetadataType.TEXT, - label: 'Content', - }, - ], - }; - - expect(() => defineObject(config as any)).toThrow( - 'Field "Content" must have a universalIdentifier', - ); - }); - - it('should throw error when field is missing type', () => { - const config = { - ...validConfig, - fields: [ - { - universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b', - label: 'Content', - }, - ], - }; - - expect(() => defineObject(config as any)).toThrow( - 'Field "Content" must have a type', - ); - }); - it('should throw error when field is missing label', () => { const config = { ...validConfig, @@ -175,7 +143,26 @@ describe('defineObject', () => { ], }; - expect(() => defineObject(config as any)).toThrow('Field must have a name'); + expect(() => defineObject(config as any)).toThrow( + 'Field "Content" must have a name', + ); + }); + + it('should throw error when field is missing universalIdentifier', () => { + const config = { + ...validConfig, + fields: [ + { + type: FieldMetadataType.TEXT, + name: 'content', + label: 'Content', + }, + ], + }; + + expect(() => defineObject(config as any)).toThrow( + 'Field "Content" must have a universalIdentifier', + ); }); it('should throw error when SELECT field has no options', () => { diff --git a/packages/twenty-sdk/src/application/__tests__/extend-object.spec.ts b/packages/twenty-sdk/src/application/__tests__/extend-object.spec.ts new file mode 100644 index 0000000000..e20a92aaed --- /dev/null +++ b/packages/twenty-sdk/src/application/__tests__/extend-object.spec.ts @@ -0,0 +1,303 @@ +import { extendObject } from '@/application/objects/extend-object'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { type ObjectExtensionManifest } from 'twenty-shared/application'; + +describe('extendObject', () => { + const validConfigByName: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.NUMBER, + name: 'healthScore', + label: 'Health Score', + }, + ], + }; + + const validConfigByUniversalId: ObjectExtensionManifest = { + targetObject: { + universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440002', + type: FieldMetadataType.TEXT, + name: 'customNote', + label: 'Custom Note', + }, + ], + }; + + describe('valid configurations', () => { + it('should return the config when targeting by nameSingular', () => { + const result = extendObject(validConfigByName); + + expect(result).toEqual(validConfigByName); + }); + + it('should return the config when targeting by universalIdentifier', () => { + const result = extendObject(validConfigByUniversalId); + + expect(result).toEqual(validConfigByUniversalId); + }); + + it('should pass through optional field properties', () => { + const config: ObjectExtensionManifest = { + ...validConfigByName, + fields: [ + { + ...validConfigByName.fields[0], + description: 'A health score from 0-100', + icon: 'IconHeart', + }, + ], + }; + + const result = extendObject(config); + + expect(result.fields[0].description).toBe('A health score from 0-100'); + expect(result.fields[0].icon).toBe('IconHeart'); + }); + + it('should accept SELECT field with options', () => { + const config: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440003', + type: FieldMetadataType.SELECT, + name: 'churnRisk', + label: 'Churn Risk', + options: [ + { value: 'low', label: 'Low', color: 'green', position: 0 }, + { + value: 'medium', + label: 'Medium', + color: 'yellow', + position: 1, + }, + { value: 'high', label: 'High', color: 'red', position: 2 }, + ], + }, + ], + }; + + const result = extendObject(config); + + expect(result.fields[0].label).toBe('Churn Risk'); + }); + + it('should accept multiple fields', () => { + const config: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'person', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440004', + type: FieldMetadataType.NUMBER, + name: 'score', + label: 'Score', + }, + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440005', + type: FieldMetadataType.TEXT, + name: 'notes', + label: 'Notes', + }, + ], + }; + + const result = extendObject(config); + + expect(result.fields).toHaveLength(2); + }); + }); + + describe('targetObject validation', () => { + it('should throw error when targetObject is missing', () => { + const config = { + fields: validConfigByName.fields, + }; + + expect(() => extendObject(config as any)).toThrow( + 'Object extension must have a targetObject', + ); + }); + + it('should throw error when targetObject has neither nameSingular nor universalIdentifier', () => { + const config = { + targetObject: {}, + fields: validConfigByName.fields, + }; + + expect(() => extendObject(config as any)).toThrow( + 'targetObject must have either nameSingular or universalIdentifier', + ); + }); + + it('should throw error when targetObject has both nameSingular and universalIdentifier', () => { + const config = { + targetObject: { + nameSingular: 'company', + universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + }, + fields: validConfigByName.fields, + }; + + expect(() => extendObject(config as any)).toThrow( + 'targetObject cannot have both nameSingular and universalIdentifier', + ); + }); + }); + + describe('fields validation', () => { + it('should throw error when fields is missing', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + }; + + expect(() => extendObject(config as any)).toThrow( + 'Object extension must have at least one field', + ); + }); + + it('should throw error when fields is empty', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Object extension must have at least one field', + ); + }); + + it('should throw error when field is missing label', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.NUMBER, + name: 'healthScore', + }, + ], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Field must have a label', + ); + }); + + it('should throw error when field is missing name', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.NUMBER, + label: 'Health Score', + }, + ], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Field "Health Score" must have a name', + ); + }); + + it('should throw error when field is missing universalIdentifier', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + type: FieldMetadataType.NUMBER, + name: 'healthScore', + label: 'Health Score', + }, + ], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Field "Health Score" must have a universalIdentifier', + ); + }); + + it('should throw error when SELECT field has no options', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.SELECT, + name: 'status', + label: 'Status', + }, + ], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Field "Status" is a SELECT/MULTI_SELECT type and must have options', + ); + }); + + it('should throw error when MULTI_SELECT field has no options', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.MULTI_SELECT, + name: 'tags', + label: 'Tags', + }, + ], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Field "Tags" is a SELECT/MULTI_SELECT type and must have options', + ); + }); + + it('should throw error when SELECT field has empty options array', () => { + const config = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.SELECT, + name: 'status', + label: 'Status', + options: [], + }, + ], + }; + + expect(() => extendObject(config as any)).toThrow( + 'Field "Status" is a SELECT/MULTI_SELECT type and must have options', + ); + }); + }); +}); diff --git a/packages/twenty-sdk/src/application/index.ts b/packages/twenty-sdk/src/application/index.ts index 474e376329..0f93cd2dce 100644 --- a/packages/twenty-sdk/src/application/index.ts +++ b/packages/twenty-sdk/src/application/index.ts @@ -42,8 +42,10 @@ export type { ObjectRecordUpsertEvent, } from './functions/triggers/database-event-payload-type'; export { defineObject } from './objects/define-object'; +export { extendObject } from './objects/extend-object'; export { Object } from './objects/object.decorator'; export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids'; +export { validateFieldsOrThrow } from './objects/validate-fields'; export { PermissionFlag } from './permission-flag-type'; export type { RoleConfig } from './role-config'; export { defineRole } from './roles/define-role'; diff --git a/packages/twenty-sdk/src/application/objects/define-object.ts b/packages/twenty-sdk/src/application/objects/define-object.ts index 78f0678e63..0cb42070d8 100644 --- a/packages/twenty-sdk/src/application/objects/define-object.ts +++ b/packages/twenty-sdk/src/application/objects/define-object.ts @@ -1,5 +1,6 @@ -import { FieldMetadataType } from 'twenty-shared/types'; import { type ObjectManifest } from 'twenty-shared/application'; +import { isNonEmptyString } from '@sniptt/guards'; +import { validateFieldsOrThrow } from './validate-fields'; /** * Define an object configuration with validation. @@ -35,55 +36,27 @@ import { type ObjectManifest } from 'twenty-shared/application'; * ``` */ export const defineObject = (config: T): T => { - if (!config.universalIdentifier) { + if (!isNonEmptyString(config.universalIdentifier)) { throw new Error('Object must have a universalIdentifier'); } - if (!config.nameSingular) { + if (!isNonEmptyString(config.nameSingular)) { throw new Error('Object must have a nameSingular'); } - if (!config.namePlural) { + if (!isNonEmptyString(config.namePlural)) { throw new Error('Object must have a namePlural'); } - if (!config.labelSingular) { + if (!isNonEmptyString(config.labelSingular)) { throw new Error('Object must have a labelSingular'); } - if (!config.labelPlural) { + if (!isNonEmptyString(config.labelPlural)) { throw new Error('Object must have a labelPlural'); } - // Validate each field - for (const field of config.fields ?? []) { - if (!field.universalIdentifier) { - throw new Error(`Field "${field.label}" must have a universalIdentifier`); - } - - if (!field.type) { - throw new Error(`Field "${field.label}" must have a type`); - } - - if (!field.name) { - throw new Error('Field must have a name'); - } - - if (!field.label) { - throw new Error('Field must have a label'); - } - - // Validate SELECT fields have options - if ( - (field.type === FieldMetadataType.SELECT || - field.type === FieldMetadataType.MULTI_SELECT) && - (!field.options || field.options.length === 0) - ) { - throw new Error( - `Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`, - ); - } - } + validateFieldsOrThrow(config.fields); return config; }; diff --git a/packages/twenty-sdk/src/application/objects/extend-object.ts b/packages/twenty-sdk/src/application/objects/extend-object.ts new file mode 100644 index 0000000000..5908aee64d --- /dev/null +++ b/packages/twenty-sdk/src/application/objects/extend-object.ts @@ -0,0 +1,79 @@ +import { type ObjectExtensionManifest } from 'twenty-shared/application'; +import { isNonEmptyString } from '@sniptt/guards'; +import { validateFieldsOrThrow } from './validate-fields'; + +/** + * Extend an existing object with additional fields. + * + * @example + * ```typescript + * import { extendObject, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk'; + * + * // Extend by object name + * export const companyExtension = extendObject({ + * targetObject: { + * nameSingular: 'company', + * }, + * fields: [ + * { + * universalIdentifier: '...', + * name: 'healthScore', + * type: FieldType.NUMBER, + * label: 'Health Score', + * description: 'Calculated customer health metric', + * }, + * { + * universalIdentifier: '...', + * name: 'churnRisk', + * type: FieldType.SELECT, + * label: 'Churn Risk', + * options: [ + * { label: 'Low', value: 'low', color: 'green' }, + * { label: 'Medium', value: 'medium', color: 'yellow' }, + * { label: 'High', value: 'high', color: 'red' }, + * ], + * }, + * ], + * }); + * + * // Or extend by universal identifier + * export const companyExtensionById = extendObject({ + * targetObject: { + * universalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company, + * }, + * fields: [...], + * }); + * ``` + */ +export const extendObject = ( + config: T, +): T => { + if (!config.targetObject) { + throw new Error('Object extension must have a targetObject'); + } + + const { nameSingular, universalIdentifier } = config.targetObject; + + if ( + !isNonEmptyString(nameSingular) && + !isNonEmptyString(universalIdentifier) + ) { + throw new Error( + 'targetObject must have either nameSingular or universalIdentifier', + ); + } + + if (isNonEmptyString(nameSingular) && isNonEmptyString(universalIdentifier)) { + throw new Error( + 'targetObject cannot have both nameSingular and universalIdentifier - they are mutually exclusive', + ); + } + + if (!config.fields || config.fields.length === 0) { + throw new Error('Object extension must have at least one field'); + } + + validateFieldsOrThrow(config.fields); + + return config; +}; diff --git a/packages/twenty-sdk/src/application/objects/validate-fields.ts b/packages/twenty-sdk/src/application/objects/validate-fields.ts new file mode 100644 index 0000000000..4375ae9eb0 --- /dev/null +++ b/packages/twenty-sdk/src/application/objects/validate-fields.ts @@ -0,0 +1,42 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { + type FieldManifest, + type RelationFieldManifest, +} from 'twenty-shared/application'; +import { isNonEmptyString } from '@sniptt/guards'; + +/** + * Validates an array of fields and throws an error if any field is invalid. + * This validation is shared between defineObject and extendObject. + */ +export const validateFieldsOrThrow = ( + fields: (FieldManifest | RelationFieldManifest)[] | undefined, +): void => { + if (!fields) { + return; + } + + for (const field of fields) { + if (!isNonEmptyString(field.label)) { + throw new Error('Field must have a label'); + } + + if (!isNonEmptyString(field.name)) { + throw new Error(`Field "${field.label}" must have a name`); + } + + if (!isNonEmptyString(field.universalIdentifier)) { + throw new Error(`Field "${field.label}" must have a universalIdentifier`); + } + + if ( + (field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT) && + (!Array.isArray(field.options) || field.options.length === 0) + ) { + throw new Error( + `Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`, + ); + } + } +}; diff --git a/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts b/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts index 238e3fdd6c..fc81b99af0 100644 --- a/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts +++ b/packages/twenty-sdk/src/cli/__tests__/e2e/applications-install-delete-reinstall.e2e-spec.ts @@ -2,6 +2,9 @@ import { existsSync } from 'fs'; import { AppSyncCommand } from '@/cli/commands/app-sync.command'; import { AppUninstallCommand } from '@/cli/commands/app-uninstall.command'; import { getTestedApplicationPath } from '@/cli/__tests__/e2e/utils/get-tested-application-path.util'; +import { inspect } from 'util'; + +inspect.defaultOptions.depth = 10; describe('Application: install delete and reinstall test-app', () => { const applicationName = 'test-app'; diff --git a/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/postCard.object-extension.ts b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/postCard.object-extension.ts new file mode 100644 index 0000000000..e93f468c4e --- /dev/null +++ b/packages/twenty-sdk/src/cli/__tests__/test-app/src/app/postCard.object-extension.ts @@ -0,0 +1,40 @@ +import { extendObject } from '@/application/objects/extend-object'; +import { FieldType } from '@/application/fields/field-type'; + +export const POST_CARD_EXTENSION_PRIORITY_FIELD_ID = + '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d'; + +export const POST_CARD_EXTENSION_CATEGORY_FIELD_ID = + '8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e'; + +export default extendObject({ + targetObject: { + nameSingular: 'postCard', + }, + fields: [ + { + universalIdentifier: POST_CARD_EXTENSION_PRIORITY_FIELD_ID, + type: FieldType.NUMBER, + name: 'priority', + label: 'Priority', + description: 'Priority level for the post card (1-10)', + }, + { + universalIdentifier: POST_CARD_EXTENSION_CATEGORY_FIELD_ID, + type: FieldType.SELECT, + name: 'category', + label: 'Category', + description: 'Post card category', + options: [ + { value: 'PERSONAL', label: 'Personal', color: 'blue', position: 0 }, + { value: 'BUSINESS', label: 'Business', color: 'green', position: 1 }, + { + value: 'PROMOTIONAL', + label: 'Promotional', + color: 'orange', + position: 2, + }, + ], + }, + ], +}); diff --git a/packages/twenty-sdk/src/cli/cli.ts b/packages/twenty-sdk/src/cli/cli.ts index 0670fef298..7484e8dc3b 100644 --- a/packages/twenty-sdk/src/cli/cli.ts +++ b/packages/twenty-sdk/src/cli/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { inspect } from 'util'; import chalk from 'chalk'; import { Command, CommanderError } from 'commander'; import { AppCommand } from '@/cli/commands/app.command'; @@ -6,6 +7,8 @@ import { AuthCommand } from '@/cli/commands/auth.command'; import { ConfigService } from '@/cli/services/config.service'; import packageJson from '../../package.json'; +inspect.defaultOptions.depth = 10; + const program = new Command(); program diff --git a/packages/twenty-sdk/src/cli/commands/app.command.ts b/packages/twenty-sdk/src/cli/commands/app.command.ts index dc809eba4e..d357a10ff6 100644 --- a/packages/twenty-sdk/src/cli/commands/app.command.ts +++ b/packages/twenty-sdk/src/cli/commands/app.command.ts @@ -1,4 +1,3 @@ -import { inspect } from 'util'; import chalk from 'chalk'; import { Command } from 'commander'; import { @@ -13,8 +12,6 @@ import { formatPath } from '@/cli/utils/format-path'; import { AppGenerateCommand } from '@/cli/commands/app-generate.command'; import { AppLogsCommand } from '@/cli/commands/app-logs.command'; -inspect.defaultOptions.depth = 10; - export class AppCommand { private devCommand = new AppDevCommand(); private syncCommand = new AppSyncCommand(); diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts index 1a3f9f626e..a372f41d8f 100644 --- a/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts +++ b/packages/twenty-sdk/src/cli/utils/__tests__/load-manifest.spec.ts @@ -4,6 +4,10 @@ import { type LoadManifestResult, } from '@/cli/utils/load-manifest'; import { DEFAULT_FUNCTION_ROLE_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/test-app/src/app/default-function.role'; +import { + POST_CARD_EXTENSION_PRIORITY_FIELD_ID, + POST_CARD_EXTENSION_CATEGORY_FIELD_ID, +} from '@/cli/__tests__/test-app/src/app/postCard.object-extension'; const TEST_APP_PATH = join(__dirname, '../../__tests__/test-app'); @@ -22,7 +26,7 @@ describe('loadManifest with test-app', () => { yarnLock = result.yarnLock; warnings = result.warnings; shouldGenerate = result.shouldGenerate; - }); + }, 15_000); it('should load manifest from test-app directory', async () => { // Check package.json loaded correctly @@ -70,7 +74,6 @@ describe('loadManifest with test-app', () => { const statusField = postCard.fields?.find((f) => f.name === 'status'); expect(statusField).toBeDefined(); expect(statusField?.type).toBe('SELECT'); - expect(statusField?.options).toHaveLength(4); expect(manifest.serverlessFunctions).toHaveLength(2); @@ -153,6 +156,41 @@ describe('loadManifest with test-app', () => { expect(appSources['postCard.object.ts']).toContain('defineObject'); expect(appSources['test-function.function.ts']).toContain('defineFunction'); expect(appSources['default-function.role.ts']).toContain('defineRole'); + expect(appSources['postCard.object-extension.ts']).toContain( + 'extendObject', + ); + + // Check object extensions + expect(manifest.objectExtensions).toBeDefined(); + expect(manifest.objectExtensions).toHaveLength(1); + + const postCardExtension = manifest.objectExtensions![0]; + expect(postCardExtension.targetObject.nameSingular).toBe('postCard'); + expect(postCardExtension.fields).toHaveLength(2); + + const priorityField = postCardExtension.fields.find( + (f) => f.name === 'priority', + ); + expect(priorityField).toBeDefined(); + expect(priorityField?.universalIdentifier).toBe( + POST_CARD_EXTENSION_PRIORITY_FIELD_ID, + ); + expect(priorityField?.type).toBe('NUMBER'); + expect(priorityField?.label).toBe('Priority'); + expect(priorityField?.description).toBe( + 'Priority level for the post card (1-10)', + ); + + const categoryField = postCardExtension.fields.find( + (f) => f.name === 'category', + ); + expect(categoryField).toBeDefined(); + expect(categoryField?.universalIdentifier).toBe( + POST_CARD_EXTENSION_CATEGORY_FIELD_ID, + ); + expect(categoryField?.type).toBe('SELECT'); + expect(categoryField?.label).toBe('Category'); + expect((categoryField as any)?.options).toHaveLength(3); expect(shouldGenerate).toBe(true); diff --git a/packages/twenty-sdk/src/cli/utils/__tests__/validate-manifest.spec.ts b/packages/twenty-sdk/src/cli/utils/__tests__/validate-manifest.spec.ts new file mode 100644 index 0000000000..dde4411432 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utils/__tests__/validate-manifest.spec.ts @@ -0,0 +1,471 @@ +import { validateManifest } from '../validate-manifest'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { + type Application, + type ObjectExtensionManifest, +} from 'twenty-shared/application'; + +describe('validateManifest - objectExtensions', () => { + const validApplication: Application = { + universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7', + displayName: 'Test App', + }; + + const validObjectExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.NUMBER, + name: 'healthScore', + label: 'Health Score', + }, + ], + }; + + describe('valid object extensions', () => { + it('should pass validation with valid object extension by nameSingular', () => { + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [validObjectExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should pass validation with valid object extension by universalIdentifier', () => { + const extensionByUuid: ObjectExtensionManifest = { + targetObject: { + universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440002', + type: FieldMetadataType.TEXT, + name: 'customNote', + label: 'Custom Note', + }, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [extensionByUuid], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should pass validation with multiple object extensions', () => { + const anotherExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'person', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440003', + type: FieldMetadataType.TEXT, + name: 'nickname', + label: 'Nickname', + }, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [validObjectExtension, anotherExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should pass validation with SELECT field having options', () => { + const extensionWithSelect: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440004', + type: FieldMetadataType.SELECT, + name: 'status', + label: 'Status', + options: [ + { value: 'active', label: 'Active', color: 'green', position: 0 }, + { + value: 'inactive', + label: 'Inactive', + color: 'red', + position: 1, + }, + ], + }, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [extensionWithSelect], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('targetObject validation', () => { + it('should fail when targetObject is missing', () => { + const invalidExtension = { + fields: validObjectExtension.fields, + } as ObjectExtensionManifest; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'Object extension must have a targetObject', + }), + ); + }); + + it('should fail when targetObject has neither nameSingular nor universalIdentifier', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: {} as any, + fields: validObjectExtension.fields, + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: + 'Object extension targetObject must have either nameSingular or universalIdentifier', + }), + ); + }); + + it('should fail when targetObject has both nameSingular and universalIdentifier', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + universalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + } as any, + fields: validObjectExtension.fields, + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: + 'Object extension targetObject cannot have both nameSingular and universalIdentifier', + }), + ); + }); + }); + + describe('fields validation', () => { + it('should fail when fields array is empty', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'Object extension must have at least one field', + }), + ); + }); + + it('should fail when field is missing universalIdentifier', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + type: FieldMetadataType.NUMBER, + name: 'healthScore', + label: 'Health Score', + } as any, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'Field must have a universalIdentifier', + }), + ); + }); + + it('should fail when field is missing type', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + name: 'healthScore', + label: 'Health Score', + } as any, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'Field must have a type', + }), + ); + }); + + it('should fail when field is missing label', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.NUMBER, + name: 'healthScore', + } as any, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'Field must have a label', + }), + ); + }); + + it('should fail when SELECT field has no options', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.SELECT, + name: 'status', + label: 'Status', + } as any, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'SELECT/MULTI_SELECT field must have options', + }), + ); + }); + + it('should fail when MULTI_SELECT field has empty options', () => { + const invalidExtension: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.MULTI_SELECT, + name: 'tags', + label: 'Tags', + options: [], + } as any, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [invalidExtension], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: 'SELECT/MULTI_SELECT field must have options', + }), + ); + }); + }); + + describe('duplicate universalIdentifier detection', () => { + it('should fail when extension field has duplicate universalIdentifier', () => { + const duplicateId = '550e8400-e29b-41d4-a716-446655440001'; + + const extensionWithDuplicates: ObjectExtensionManifest = { + targetObject: { + nameSingular: 'company', + }, + fields: [ + { + universalIdentifier: duplicateId, + type: FieldMetadataType.NUMBER, + name: 'field1', + label: 'Field 1', + }, + { + universalIdentifier: duplicateId, // Same ID! + type: FieldMetadataType.TEXT, + name: 'field2', + label: 'Field 2', + }, + ], + }; + + const result = validateManifest({ + application: validApplication, + objects: [], + objectExtensions: [extensionWithDuplicates], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('Duplicate universalIdentifier'), + }), + ); + }); + + it('should fail when extension field ID conflicts with object field ID', () => { + const sharedId = '550e8400-e29b-41d4-a716-446655440001'; + + const result = validateManifest({ + application: validApplication, + objects: [ + { + universalIdentifier: 'obj-uuid', + nameSingular: 'myObject', + namePlural: 'myObjects', + labelSingular: 'My Object', + labelPlural: 'My Objects', + fields: [ + { + universalIdentifier: sharedId, + type: FieldMetadataType.TEXT, + name: 'existingField', + label: 'Existing Field', + }, + ], + }, + ], + objectExtensions: [ + { + targetObject: { nameSingular: 'company' }, + fields: [ + { + universalIdentifier: sharedId, // Same as object field! + type: FieldMetadataType.NUMBER, + name: 'extensionField', + label: 'Extension Field', + }, + ], + }, + ], + serverlessFunctions: [], + roles: [], + }); + + expect(result.isValid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + message: expect.stringContaining('Duplicate universalIdentifier'), + }), + ); + }); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utils/load-manifest.ts b/packages/twenty-sdk/src/cli/utils/load-manifest.ts index 683759de8a..ba986c9a2b 100644 --- a/packages/twenty-sdk/src/cli/utils/load-manifest.ts +++ b/packages/twenty-sdk/src/cli/utils/load-manifest.ts @@ -4,6 +4,7 @@ import path, { posix, relative, sep } from 'path'; import { type Application, type ApplicationManifest, + type ObjectExtensionManifest, type ObjectManifest, type PackageJson, type RoleManifest, @@ -83,6 +84,35 @@ const loadObjects = async (appPath: string): Promise => { return objects; }; +/** + * Load all object extension definitions from src/app/ (any *.object-extension.ts file). + */ +const loadObjectExtensions = async ( + appPath: string, +): Promise => { + const extensionFiles = await loadFiles( + ['src/app/**/*.object-extension.ts'], + appPath, + ); + + const extensions: ObjectExtensionManifest[] = []; + + for (const filepath of extensionFiles) { + try { + const manifest = await loadConfig(filepath); + + extensions.push(manifest); + } catch (error) { + const relPath = toPosixRelative(filepath, appPath); + throw new Error( + `Failed to load object extension from ${relPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return extensions; +}; + /** * Load all function definitions from src/app/ (any *.function.ts file). */ @@ -217,8 +247,11 @@ export type LoadManifestResult = { /** * Load an application manifest using the folder structure with jiti runtime evaluation. * - * Files are detected by their suffix (*.object.ts, *.function.ts, *.role.ts) - * and can be placed anywhere within src/app/. + * Files are detected by their suffix and can be placed anywhere within src/app/: + * - *.object.ts - Custom object definitions + * - *.object-extension.ts - Extensions to existing objects (adding fields) + * - *.function.ts - Serverless function definitions + * - *.role.ts - Role definitions * * Example structures: * ``` @@ -229,6 +262,8 @@ export type LoadManifestResult = { * │ ├── application.config.ts * │ ├── objects/ * │ │ └── postCard.object.ts + * │ ├── object-extensions/ + * │ │ └── company.object-extension.ts * │ ├── functions/ * │ │ └── createPostCard.function.ts * │ └── roles/ @@ -241,6 +276,7 @@ export type LoadManifestResult = { * │ ├── application.config.ts * │ └── post-card/ * │ ├── postCard.object.ts + * │ ├── company.object-extension.ts * │ ├── createPostCard.function.ts * │ └── postCardAdmin.role.ts * ``` @@ -270,19 +306,28 @@ export const loadManifest = async ( const application = await loadConfig(applicationConfigPath); // Load all entities in parallel - const [objects, serverlessFunctions, roles, sources, shouldGenerate] = - await Promise.all([ - loadObjects(appPath), - loadFunctions(appPath), - loadRoles(appPath), - loadSources(appPath), - checkShouldGenerate(appPath), - ]); + const [ + objects, + objectExtensions, + serverlessFunctions, + roles, + sources, + shouldGenerate, + ] = await Promise.all([ + loadObjects(appPath), + loadObjectExtensions(appPath), + loadFunctions(appPath), + loadRoles(appPath), + loadSources(appPath), + checkShouldGenerate(appPath), + ]); // Build manifest const manifest: ApplicationManifest = { application, objects, + objectExtensions: + objectExtensions.length > 0 ? objectExtensions : undefined, serverlessFunctions, roles, sources, @@ -292,6 +337,7 @@ export const loadManifest = async ( const validation = validateManifest({ application, objects, + objectExtensions, serverlessFunctions, roles, }); diff --git a/packages/twenty-sdk/src/cli/utils/validate-manifest.ts b/packages/twenty-sdk/src/cli/utils/validate-manifest.ts index 1668f0cdfe..68ad7c84c3 100644 --- a/packages/twenty-sdk/src/cli/utils/validate-manifest.ts +++ b/packages/twenty-sdk/src/cli/utils/validate-manifest.ts @@ -1,6 +1,7 @@ import { type ApplicationManifest, type ServerlessFunctionManifest, + type ObjectExtensionManifest, type ObjectManifest, type RoleManifest, type Application, @@ -25,7 +26,9 @@ export type ValidationResult = { export class ManifestValidationError extends Error { constructor(public readonly errors: ValidationError[]) { - const messages = errors.map((e) => ` • ${e.path}: ${e.message}`).join('\n'); + const messages = errors + .map((e) => ` • ${e.path}: ${e.message}`) + .join('\n'); super(`Manifest validation failed:\n${messages}`); this.name = 'ManifestValidationError'; } @@ -80,6 +83,22 @@ const collectAllIds = ( } } + for (const ext of manifest.objectExtensions ?? []) { + const targetName = + ext.targetObject?.nameSingular ?? + ext.targetObject?.universalIdentifier ?? + 'unknown'; + // Extension fields + for (const field of ext.fields ?? []) { + if (field.universalIdentifier) { + ids.push({ + id: field.universalIdentifier, + location: `object-extensions/${targetName}.fields.${field.label}`, + }); + } + } + } + // Functions for (const fn of manifest.serverlessFunctions ?? []) { if (fn.universalIdentifier) { @@ -214,7 +233,94 @@ const validateObjects = ( if ( (field.type === FieldMetadataType.SELECT || field.type === FieldMetadataType.MULTI_SELECT) && - (!field.options || (field.options as unknown[]).length === 0) + (!Array.isArray(field.options) || field.options.length === 0) + ) { + errors.push({ + path: fieldPath, + message: 'SELECT/MULTI_SELECT field must have options', + }); + } + } + } +}; + +/** + * Validate object extensions and their fields. + */ +const validateObjectExtensions = ( + extensions: ObjectExtensionManifest[], + errors: ValidationError[], +): void => { + for (const ext of extensions) { + const targetName = + ext.targetObject?.nameSingular ?? + ext.targetObject?.universalIdentifier ?? + 'unknown'; + const extPath = `object-extensions/${targetName}`; + + if (!ext.targetObject) { + errors.push({ + path: extPath, + message: 'Object extension must have a targetObject', + }); + continue; + } + + const { nameSingular, universalIdentifier } = ext.targetObject; + + if (!nameSingular && !universalIdentifier) { + errors.push({ + path: extPath, + message: + 'Object extension targetObject must have either nameSingular or universalIdentifier', + }); + } + + if (nameSingular && universalIdentifier) { + errors.push({ + path: extPath, + message: + 'Object extension targetObject cannot have both nameSingular and universalIdentifier', + }); + } + + if (!ext.fields || ext.fields.length === 0) { + errors.push({ + path: extPath, + message: 'Object extension must have at least one field', + }); + } + + // Validate fields + for (const field of ext.fields ?? []) { + const fieldPath = `${extPath}.fields.${field.label ?? 'unknown'}`; + + if (!field.universalIdentifier) { + errors.push({ + path: fieldPath, + message: 'Field must have a universalIdentifier', + }); + } + + if (!field.type) { + errors.push({ + path: fieldPath, + message: 'Field must have a type', + }); + } + + if (!field.label) { + errors.push({ + path: fieldPath, + message: 'Field must have a label', + }); + } + + // Check SELECT/MULTI_SELECT fields have options + if ( + (field.type === FieldMetadataType.SELECT || + field.type === FieldMetadataType.MULTI_SELECT) && + (!Array.isArray(field.options) || field.options.length === 0) ) { errors.push({ path: fieldPath, @@ -340,6 +446,9 @@ export const validateManifest = ( // Validate objects validateObjects(manifest.objects ?? [], errors); + // Validate object extensions + validateObjectExtensions(manifest.objectExtensions ?? [], errors); + // Validate functions validateFunctions(manifest.serverlessFunctions ?? [], errors); @@ -363,7 +472,10 @@ export const validateManifest = ( }); } - if (!manifest.serverlessFunctions || manifest.serverlessFunctions.length === 0) { + if ( + !manifest.serverlessFunctions || + manifest.serverlessFunctions.length === 0 + ) { warnings.push({ message: 'No functions defined in src/app/functions/', }); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts index 9c5c9fa8b7..fc59a7a6e7 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts @@ -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); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts index e1cf7696dd..acdb5b24e9 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-sync.service.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts index f3dcc4f58f..4a0aac8a1b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts @@ -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); } diff --git a/packages/twenty-shared/src/application/applicationManifestType.ts b/packages/twenty-shared/src/application/applicationManifestType.ts index 01acb474bd..eede5a8121 100644 --- a/packages/twenty-shared/src/application/applicationManifestType.ts +++ b/packages/twenty-shared/src/application/applicationManifestType.ts @@ -3,12 +3,14 @@ import { type ServerlessFunctionManifest, type Application, } from '@/application'; -import { type Sources } from '@/types'; +import { type ObjectExtensionManifest } from '@/application/objectExtensionManifestType'; import { type RoleManifest } from '@/application/roleManifestType'; +import { type Sources } from '@/types'; export type ApplicationManifest = { application: Application; objects: ObjectManifest[]; + objectExtensions?: ObjectExtensionManifest[]; serverlessFunctions: ServerlessFunctionManifest[]; roles?: RoleManifest[]; sources: Sources; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 622a2efe70..251c6b3c28 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -13,6 +13,7 @@ export type { ApplicationVariables } from './applicationVariablesType'; export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName'; export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName'; export type { FieldManifest } from './fieldManifestType'; +export type { ObjectExtensionManifest } from './objectExtensionManifestType'; export type { ObjectManifest } from './objectManifestType'; export type { PackageJson } from './packageJsonType'; export type { RelationFieldManifest } from './relationFieldManifestType'; diff --git a/packages/twenty-shared/src/application/objectExtensionManifestType.ts b/packages/twenty-shared/src/application/objectExtensionManifestType.ts new file mode 100644 index 0000000000..7ec350e7c0 --- /dev/null +++ b/packages/twenty-shared/src/application/objectExtensionManifestType.ts @@ -0,0 +1,17 @@ +import { type FieldManifest } from '@/application/fieldManifestType'; +import { type RelationFieldManifest } from '@/application/relationFieldManifestType'; + +type TargetObjectByNameSingular = { + nameSingular: string; + universalIdentifier?: never; +}; + +type TargetObjectByUniversalIdentifier = { + nameSingular?: never; + universalIdentifier: string; +}; + +export type ObjectExtensionManifest = { + targetObject: TargetObjectByNameSingular | TargetObjectByUniversalIdentifier; + fields: (FieldManifest | RelationFieldManifest)[]; +}; diff --git a/packages/twenty-shared/src/application/relationFieldManifestType.ts b/packages/twenty-shared/src/application/relationFieldManifestType.ts index e9bb5e8203..7ad0e610c2 100644 --- a/packages/twenty-shared/src/application/relationFieldManifestType.ts +++ b/packages/twenty-shared/src/application/relationFieldManifestType.ts @@ -13,5 +13,5 @@ export type RelationFieldManifest = SyncableEntityOptions & { targetFieldLabel: string; targetFieldIcon?: string; onDelete?: RelationOnDeleteAction; - type: FieldMetadataType; + type: FieldMetadataType.RELATION; }; diff --git a/yarn.lock b/yarn.lock index 5e96b7ad17..fffb8dd189 100644 --- a/yarn.lock +++ b/yarn.lock @@ -57532,6 +57532,7 @@ __metadata: resolution: "twenty-sdk@workspace:packages/twenty-sdk" dependencies: "@genql/cli": "npm:^3.0.3" + "@sniptt/guards": "npm:^0.2.0" "@types/fs-extra": "npm:^11.0.0" "@types/inquirer": "npm:^9.0.0" "@types/jest": "npm:^29.5.0"