Update manifest structure (#17547)

Move all sync entities in an `entities` key. Rename functions to
logicFunctions

```json
{
  application: {
    ...
  },
  entities: {
    objects: [],
    logicFunctions: [],
    ...
  }
}
```
This commit is contained in:
martmull
2026-01-30 16:26:45 +01:00
committed by GitHub
parent bc022f82cb
commit f46da3eefd
162 changed files with 2555 additions and 3778 deletions
@@ -0,0 +1,72 @@
import { defineApplication } from '@/sdk';
describe('defineApplication', () => {
it('should return successful validation result when valid', () => {
const config = {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
icon: 'IconWorld',
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
const result = defineApplication(config);
expect(result.success).toBe(true);
expect(result.config).toEqual(config);
expect(result.errors).toEqual([]);
});
it('should pass through all optional fields', () => {
const config = {
universalIdentifier: 'a9faf5f8-cf7e-4f24-9d37-fd523c30febe',
displayName: 'My App',
description: 'My app description',
icon: 'IconWorld',
applicationVariables: {
API_KEY: {
universalIdentifier: '3a327392-3a0f-4605-9223-0633f063eaf6',
description: 'API Key',
isSecret: true,
},
},
defaultRoleUniversalIdentifier: '68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
};
const result = defineApplication(config);
expect(result.success).toBe(true);
expect(result.config).toEqual(config);
expect(result.config?.applicationVariables).toBeDefined();
expect(result.config?.defaultRoleUniversalIdentifier).toBe(
'68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
);
});
it('should return error when universalIdentifier is missing', () => {
const config = {
displayName: 'My App',
};
const result = defineApplication(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Application must have a universalIdentifier',
);
});
it('should return error when universalIdentifier is empty string', () => {
const config = {
universalIdentifier: '',
displayName: 'My App',
};
const result = defineApplication(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Application must have a universalIdentifier',
);
});
});
@@ -0,0 +1,22 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
export const defineApplication: DefineEntity<ApplicationManifest> = (
config,
) => {
const errors = [];
if (!config.universalIdentifier) {
errors.push('Application must have a universalIdentifier');
}
if (!config.defaultRoleUniversalIdentifier) {
errors.push('Application must have a defaultRoleUniversalIdentifier');
}
return createValidationResult({
config,
errors,
});
};
@@ -0,0 +1,28 @@
import {
type ApplicationManifest,
type FieldManifest,
type ObjectManifest,
type RoleManifest,
} from 'twenty-shared/application';
import { type FrontComponentConfig } from '@/sdk/front-components/front-component-config';
import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config';
export type ValidationResult<T> = {
success: boolean;
config: T;
errors: string[];
};
export type DefinableEntity =
| ApplicationManifest
| ObjectManifest
| FieldManifest
| FrontComponentConfig
| LogicFunctionConfig
| RoleManifest;
export type DefineEntity<C extends DefinableEntity = DefinableEntity> = <
T extends C,
>(
config: T,
) => ValidationResult<T>;
@@ -0,0 +1 @@
export type SyncableEntityOptions = { universalIdentifier: string };
@@ -0,0 +1,13 @@
import { type ValidationResult } from '@/sdk/common/types/define-entity.type';
export const createValidationResult = <T>({
config,
errors = [],
}: {
config: T;
errors: string[];
}): ValidationResult<T> => ({
success: errors.length === 0,
config,
errors,
});
@@ -0,0 +1,179 @@
import { defineField } from '@/sdk';
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldManifest } from 'twenty-shared/application';
const validConfig: FieldManifest = {
objectUniversalIdentifier: '45e8ae95-0ed8-4087-9f59-3ac85144f86d',
universalIdentifier: '73068741-1638-4d40-b30c-3431a8733094',
type: FieldMetadataType.TEXT,
name: 'customNote',
label: 'Custom Note',
};
describe('defineField', () => {
describe('valid configurations', () => {
it('should return successful validation result when targeting by nameSingular', () => {
const result = defineField(validConfig);
expect(result.success).toBe(true);
expect(result.config).toEqual(validConfig);
expect(result.errors).toEqual([]);
});
it('should pass through optional field properties', () => {
const config: FieldManifest = {
...validConfig,
description: 'A health score from 0-100',
icon: 'IconHeart',
};
const result = defineField(config);
expect(result.success).toBe(true);
expect(result.config?.description).toBe('A health score from 0-100');
expect(result.config?.icon).toBe('IconHeart');
});
it('should accept SELECT field with options', () => {
const config: FieldManifest = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
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 = defineField(config);
expect(result.success).toBe(true);
expect(result.config?.label).toBe('Churn Risk');
});
});
it('should return error when objectUniversalIdentifier is missing', () => {
const { objectUniversalIdentifier: _, ...validConfigWithoutTargetObject } =
validConfig;
const result = defineField(validConfigWithoutTargetObject as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field must have an objectUniversalIdentifier',
);
});
describe('fields validation', () => {
it('should return error when field is missing label', () => {
const config = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
};
const result = defineField(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Field must have a label');
});
it('should return error when field is missing name', () => {
const config = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.NUMBER,
label: 'Health Score',
};
const result = defineField(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Field "Health Score" must have a name');
});
it('should return error when field is missing universalIdentifier', () => {
const config = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
type: FieldMetadataType.NUMBER,
name: 'healthScore',
label: 'Health Score',
};
const result = defineField(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Health Score" must have a universalIdentifier',
);
});
it('should return error when SELECT field has no options', () => {
const config = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
};
const result = defineField(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
);
});
it('should return error when MULTI_SELECT field has no options', () => {
const config = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
label: 'Tags',
};
const result = defineField(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Tags" is a SELECT/MULTI_SELECT type and must have options',
);
});
it('should return error when SELECT field has empty options array', () => {
const config = {
objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
universalIdentifier: '550e8400-e29b-41d4-a716-446655440001',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
options: [],
};
const result = defineField(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
);
});
});
});
@@ -0,0 +1,10 @@
export type {
ActorMetadata as ActorField,
AddressMetadata as AddressField,
CurrencyMetadata as CurrencyField,
EmailsMetadata as EmailsField,
FullNameMetadata as FullNameField,
LinksMetadata as LinksField,
PhonesMetadata as PhonesField,
RichTextV2Metadata as RichTextField,
} from 'twenty-shared/types';
@@ -0,0 +1,22 @@
import { type FieldManifest } from 'twenty-shared/application';
import { validateFields } from '@/sdk/fields/validate-fields';
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
export const defineField: DefineEntity<FieldManifest> = (config) => {
const errors = [];
if (!config.objectUniversalIdentifier) {
errors.push('Field must have an objectUniversalIdentifier');
}
const fieldErrors = validateFields([config]);
errors.push(...fieldErrors);
return createValidationResult({
config,
errors,
});
};
@@ -0,0 +1 @@
export { FieldMetadataType as FieldType } from 'twenty-shared/types';
@@ -0,0 +1 @@
export { RelationOnDeleteAction as OnDeleteAction } from 'twenty-shared/types';
@@ -0,0 +1 @@
export { RelationType } from 'twenty-shared/types';
@@ -0,0 +1,41 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isNonEmptyString } from '@sniptt/guards';
import { type ObjectFieldManifest } from 'twenty-shared/application';
export const validateFields = (
fields: ObjectFieldManifest[] | undefined,
): string[] => {
if (!fields) {
return [];
}
const errors: string[] = [];
for (const field of fields) {
if (!isNonEmptyString(field.label)) {
errors.push('Field must have a label');
}
if (!isNonEmptyString(field.name)) {
errors.push(`Field "${field.label}" must have a name`);
}
if (!isNonEmptyString(field.universalIdentifier)) {
errors.push(`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)
) {
errors.push(
`Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`,
);
}
}
return errors;
};
@@ -0,0 +1,85 @@
import { defineFrontComponent } from '@/sdk';
// Mock component for testing
const MockComponent = () => null;
describe('defineFrontComponent', () => {
const validConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'My Component',
component: MockComponent,
};
it('should return successful validation result when valid', () => {
const result = defineFrontComponent(validConfig);
expect(result.success).toBe(true);
expect(result.config).toEqual(validConfig);
expect(result.errors).toEqual([]);
});
it('should pass through optional fields', () => {
const config = {
...validConfig,
description: 'A sample front component',
};
const result = defineFrontComponent(config);
expect(result.success).toBe(true);
expect(result.config?.description).toBe('A sample front component');
});
it('should return error when universalIdentifier is missing', () => {
const config = {
name: 'My Component',
component: MockComponent,
};
const result = defineFrontComponent(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Front component must have a universalIdentifier',
);
});
it('should return error when component is missing', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'My Component',
};
const result = defineFrontComponent(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Front component must have a component');
});
it('should return error when component is not a function', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'My Component',
component: 'not-a-function',
};
const result = defineFrontComponent(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Front component component must be a React component',
);
});
it('should accept config without name', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
component: MockComponent,
};
const result = defineFrontComponent(config as any);
expect(result.success).toBe(true);
expect(result.config?.name).toBeUndefined();
});
});
@@ -0,0 +1,26 @@
import { type FrontComponentConfig } from '@/sdk/front-components/front-component-config';
import type { DefineEntity } from '@/sdk/common/types/define-entity.type';
import { createValidationResult } from '@/sdk';
export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
config,
) => {
const errors = [];
if (!config.universalIdentifier) {
errors.push('Front component must have a universalIdentifier');
}
if (!config.component) {
errors.push('Front component must have a component');
}
if (typeof config.component !== 'function') {
errors.push('Front component component must be a React component');
}
return createValidationResult({
config,
errors,
});
};
@@ -0,0 +1,13 @@
import { type FrontComponentManifest } from 'twenty-shared/application';
export type FrontComponentType = React.ComponentType<any>;
export type FrontComponentConfig = Omit<
FrontComponentManifest,
| 'sourceComponentPath'
| 'builtComponentPath'
| 'builtComponentChecksum'
| 'componentName'
> & {
component: FrontComponentType;
};
+59
View File
@@ -0,0 +1,59 @@
/*
* _____ _
*|_ _|_ _____ _ __ | |_ _ _
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
* | | \ V V / __/ | | | |_| |_| | Any edits to this will be overridden
* |_| \_/\_/ \___|_| |_|\__|\__, |
* |___/
*/
export { defineApplication } from './application/define-application';
export type {
ValidationResult,
DefinableEntity,
DefineEntity,
} from './common/types/define-entity.type';
export type { SyncableEntityOptions } from './common/types/syncable-entity-options.type';
export { createValidationResult } from './common/utils/create-validation-result';
export type {
ActorField,
AddressField,
CurrencyField,
EmailsField,
FullNameField,
LinksField,
PhonesField,
RichTextField,
} from './fields/composite-fields';
export { defineField } from './fields/define-field';
export { FieldType } from './fields/field-type';
export { OnDeleteAction } from './fields/on-delete-action';
export { RelationType } from './fields/relation-type';
export { validateFields } from './fields/validate-fields';
export { defineFrontComponent } from './front-components/define-front-component';
export type {
FrontComponentType,
FrontComponentConfig,
} from './front-components/front-component-config';
export { defineLogicFunction } from './logic-functions/define-logic-function';
export type {
LogicFunctionHandler,
LogicFunctionConfig,
} from './logic-functions/logic-function-config';
export type { CronPayload } from './logic-functions/triggers/cron-payload-type';
export type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordUpdateEvent,
ObjectRecordEvent,
ObjectRecordDeleteEvent,
ObjectRecordDestroyEvent,
ObjectRecordBaseEvent,
ObjectRecordRestoreEvent,
ObjectRecordUpsertEvent,
} from './logic-functions/triggers/database-event-payload-type';
export type { RoutePayload } from './logic-functions/triggers/route-payload-type';
export { defineObject } from './objects/define-object';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { defineRole } from './roles/define-role';
export { PermissionFlag } from './roles/permission-flag-type';
@@ -0,0 +1,314 @@
import { defineLogicFunction } from '@/sdk';
const mockHandler = async () => ({ success: true });
describe('defineLogicFunction', () => {
const validRouteConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
path: '/postcards/send',
httpMethod: 'POST' as const,
isAuthRequired: true,
},
],
};
it('should return the config when valid with route trigger', () => {
const result = defineLogicFunction(validRouteConfig);
expect(result.config).toEqual(validRouteConfig);
});
it('should accept cron trigger', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Daily Report',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron' as const,
pattern: '0 9 * * *',
},
],
};
const result = defineLogicFunction(config);
expect(result.config.triggers[0].type).toBe('cron');
});
it('should accept databaseEvent trigger', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'On Contact Created',
handler: mockHandler,
triggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent' as const,
eventName: 'contact.created',
},
],
};
const result = defineLogicFunction(config);
expect(result.config.triggers[0].type).toBe('databaseEvent');
});
it('should accept multiple triggers', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Multi-trigger Function',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
path: '/sync',
httpMethod: 'POST' as const,
isAuthRequired: true,
},
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron' as const,
pattern: '0 * * * *',
},
],
};
const result = defineLogicFunction(config);
expect(result.config.triggers).toHaveLength(2);
});
it('should pass through optional fields', () => {
const config = {
...validRouteConfig,
description: 'Send a postcard to a contact',
timeoutSeconds: 30,
};
const result = defineLogicFunction(config);
expect(result.config.description).toBe('Send a postcard to a contact');
expect(result.config.timeoutSeconds).toBe(30);
});
it('should return error when universalIdentifier is missing', () => {
const config = {
name: 'Send Postcard',
handler: mockHandler,
triggers: validRouteConfig.triggers,
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Logic function must have a universalIdentifier',
);
});
it('should return error when handler is missing', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
triggers: validRouteConfig.triggers,
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Logic function must have a handler');
});
it('should return error when handler is not a function', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: 'not-a-function',
triggers: validRouteConfig.triggers,
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Logic function handler must be a function',
);
});
it('should accept empty triggers array', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [],
};
const result = defineLogicFunction(config as any);
expect(result.config.triggers).toEqual([]);
});
it('should accept missing triggers', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
};
const result = defineLogicFunction(config as any);
expect(result.config.triggers).toBeUndefined();
});
it('should return error when trigger is missing universalIdentifier', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
type: 'route' as const,
path: '/postcards/send',
httpMethod: 'POST' as const,
isAuthRequired: true,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Each trigger must have a universalIdentifier',
);
});
it('should return error when trigger is missing type', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/postcards/send',
httpMethod: 'POST' as const,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Each trigger must have a type');
});
it('should return error when route trigger is missing path', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
httpMethod: 'POST' as const,
isAuthRequired: true,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Route trigger must have a path');
});
it('should return error when route trigger is missing httpMethod', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Send Postcard',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'route' as const,
path: '/postcards/send',
isAuthRequired: true,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Route trigger must have an httpMethod');
});
it('should return error when cron trigger is missing pattern', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Daily Report',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
type: 'cron' as const,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Cron trigger must have a pattern');
});
it('should return error when databaseEvent trigger is missing eventName', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'On Contact Created',
handler: mockHandler,
triggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
type: 'databaseEvent' as const,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Database event trigger must have an eventName',
);
});
it('should return error for unknown trigger type', () => {
const config = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Unknown Trigger',
handler: mockHandler,
triggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
type: 'unknown' as any,
},
],
};
const result = defineLogicFunction(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Unknown trigger type: unknown');
});
});
@@ -0,0 +1,64 @@
import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config';
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
import type { DefineEntity } from '@/sdk/common/types/define-entity.type';
export const defineLogicFunction: DefineEntity<LogicFunctionConfig> = (
config,
) => {
const errors = [];
if (!config.universalIdentifier) {
errors.push('Logic function must have a universalIdentifier');
}
if (!config.handler) {
errors.push('Logic function must have a handler');
}
if (typeof config.handler !== 'function') {
errors.push('Logic function handler must be a function');
}
for (const trigger of config.triggers ?? []) {
if (!trigger.universalIdentifier) {
errors.push('Each trigger must have a universalIdentifier');
}
if (!trigger.type) {
errors.push('Each trigger must have a type');
}
switch (trigger.type) {
case 'route':
if (!trigger.path) {
errors.push('Route trigger must have a path');
}
if (!trigger.httpMethod) {
errors.push('Route trigger must have an httpMethod');
}
break;
case 'cron':
if (!trigger.pattern) {
errors.push('Cron trigger must have a pattern');
}
break;
case 'databaseEvent':
if (!trigger.eventName) {
errors.push('Database event trigger must have an eventName');
}
break;
default:
errors.push(
`Unknown trigger type: ${(trigger as { type: string }).type}`,
);
}
}
return createValidationResult({
config,
errors,
});
};
@@ -0,0 +1,17 @@
import {
type LogicFunctionManifest,
type LogicFunctionTriggerManifest,
} from 'twenty-shared/application';
export type LogicFunctionHandler = (...args: any[]) => any | Promise<any>;
export type LogicFunctionConfig = Omit<
LogicFunctionManifest,
| 'sourceHandlerPath'
| 'builtHandlerPath'
| 'builtHandlerChecksum'
| 'handlerName'
> & {
handler: LogicFunctionHandler;
triggers?: LogicFunctionTriggerManifest[];
};
@@ -0,0 +1 @@
export type CronPayload = Record<string, never>;
@@ -0,0 +1,11 @@
export type {
DatabaseEventPayload,
ObjectRecordCreateEvent,
ObjectRecordUpdateEvent,
ObjectRecordEvent,
ObjectRecordDeleteEvent,
ObjectRecordDestroyEvent,
ObjectRecordBaseEvent,
ObjectRecordRestoreEvent,
ObjectRecordUpsertEvent,
} from 'twenty-shared/database-events';
@@ -0,0 +1 @@
export type { LogicFunctionEvent as RoutePayload } from 'twenty-shared/types';
@@ -0,0 +1,242 @@
import { defineObject } from '@/sdk';
import { FieldMetadataType } from 'twenty-shared/types';
import { type ObjectManifest } from 'twenty-shared/application';
describe('defineObject', () => {
const validConfig: ObjectManifest = {
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.TEXT,
name: 'content',
label: 'Content',
},
],
};
it('should return successful validation result when valid', () => {
const result = defineObject(validConfig);
expect(result.success).toBe(true);
expect(result.config).toEqual(validConfig);
expect(result.errors).toEqual([]);
});
it('should pass through all optional fields', () => {
const config: ObjectManifest = {
...validConfig,
description: 'A post card object',
};
const result = defineObject(config);
expect(result.success).toBe(true);
expect(result.config?.description).toBe('A post card object');
});
it('should return error when universalIdentifier is missing', () => {
const config = {
...validConfig,
universalIdentifier: undefined,
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Object must have a universalIdentifier');
});
it('should return error when nameSingular is missing', () => {
const config = {
...validConfig,
nameSingular: undefined,
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Object must have a nameSingular');
});
it('should return error when namePlural is missing', () => {
const config = {
...validConfig,
namePlural: undefined,
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Object must have a namePlural');
});
it('should return error when labelSingular is missing', () => {
const config = {
...validConfig,
labelSingular: undefined,
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Object must have a labelSingular');
});
it('should return error when labelPlural is missing', () => {
const config = {
...validConfig,
labelPlural: undefined,
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Object must have a labelPlural');
});
it('should accept empty fields array', () => {
const config = {
...validConfig,
fields: [],
};
const result = defineObject(config as any);
expect(result.config.fields).toEqual([]);
});
it('should accept missing fields', () => {
const config = {
...validConfig,
fields: undefined,
};
const result = defineObject(config as any);
expect(result.config.fields).toBeUndefined();
});
it('should return error when field is missing label', () => {
const config = {
...validConfig,
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.TEXT,
name: 'content',
},
],
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Field must have a label');
});
it('should return error when field is missing name', () => {
const config = {
...validConfig,
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.TEXT,
label: 'Content',
},
],
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Field "Content" must have a name');
});
it('should return error when field is missing universalIdentifier', () => {
const config = {
...validConfig,
fields: [
{
type: FieldMetadataType.TEXT,
name: 'content',
label: 'Content',
},
],
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Content" must have a universalIdentifier',
);
});
it('should return error when SELECT field has no options', () => {
const config = {
...validConfig,
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.SELECT,
label: 'Status',
name: 'status',
},
],
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Status" is a SELECT/MULTI_SELECT type and must have options',
);
});
it('should return error when MULTI_SELECT field has no options', () => {
const config = {
...validConfig,
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.MULTI_SELECT,
label: 'Tags',
name: 'tag',
},
],
};
const result = defineObject(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field "Tags" is a SELECT/MULTI_SELECT type and must have options',
);
});
it('should accept SELECT field with options', () => {
const config = {
...validConfig,
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
type: FieldMetadataType.SELECT,
name: 'status',
label: 'Status',
options: [
{ value: 'draft', label: 'Draft', color: 'gray', position: 0 },
{ value: 'sent', label: 'Sent', color: 'green', position: 1 },
],
},
],
};
const result = defineObject(config as any);
expect(result.config.fields[0].options).toHaveLength(2);
});
});
@@ -0,0 +1,38 @@
import { type ObjectManifest } from 'twenty-shared/application';
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
import { validateFields } from '@/sdk/fields/validate-fields';
export const defineObject: DefineEntity<ObjectManifest> = (config) => {
const errors = [];
if (!config.universalIdentifier) {
errors.push('Object must have a universalIdentifier');
}
if (!config.nameSingular) {
errors.push('Object must have a nameSingular');
}
if (!config.namePlural) {
errors.push('Object must have a namePlural');
}
if (!config.labelSingular) {
errors.push('Object must have a labelSingular');
}
if (!config.labelPlural) {
errors.push('Object must have a labelPlural');
}
const fieldErrors = validateFields(config.fields);
errors.push(...fieldErrors);
return createValidationResult({
config,
errors,
});
};
@@ -0,0 +1 @@
export { STANDARD_OBJECT_IDS as STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
@@ -0,0 +1,124 @@
import { defineRole } from '@/sdk';
describe('defineRole', () => {
const validConfig = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'App User',
description: 'Standard user role',
};
it('should return successful validation result when valid', () => {
const result = defineRole(validConfig);
expect(result.success).toBe(true);
expect(result.config).toEqual(validConfig);
expect(result.errors).toEqual([]);
});
it('should pass through all optional fields', () => {
const config = {
...validConfig,
icon: 'IconUser',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
};
const result = defineRole(config);
expect(result.success).toBe(true);
expect(result.config?.icon).toBe('IconUser');
expect(result.config?.canReadAllObjectRecords).toBe(true);
});
it('should accept permissionFlags', () => {
const config = {
...validConfig,
permissionFlags: ['UPLOAD_FILE', 'DOWNLOAD_FILE'],
};
const result = defineRole(config as any);
expect(result.success).toBe(true);
expect(result.config?.permissionFlags).toHaveLength(2);
});
it('should return error when universalIdentifier is missing', () => {
const config = {
label: 'App User',
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Role must have a universalIdentifier');
});
it('should return error when label is missing', () => {
const config = {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain('Role must have a label');
});
it('should return error when objectPermission has no objectUniversalIdentifier', () => {
const config = {
...validConfig,
objectPermissions: [
{
canReadObjectRecords: true,
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Object permission must have an objectUniversalIdentifier',
);
});
it('should return error when fieldPermission has no objectUniversalIdentifier', () => {
const config = {
...validConfig,
fieldPermissions: [
{
fieldUniversalIdentifier: 'dd14cab4-0829-4475-a794-d0d4959161e6',
canReadFieldValue: true,
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field permission must have an objectUniversalIdentifier',
);
});
it('should return error when fieldPermission has no fieldUniversalIdentifier', () => {
const config = {
...validConfig,
fieldPermissions: [
{
objectUniversalIdentifier: '38339ab2-f00b-416c-8ee0-806b48caca18',
canReadFieldValue: true,
},
],
};
const result = defineRole(config as any);
expect(result.success).toBe(false);
expect(result.errors).toContain(
'Field permission must have a fieldUniversalIdentifier',
);
});
});
@@ -0,0 +1,37 @@
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
import { type RoleManifest } from 'twenty-shared/application';
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
export const defineRole: DefineEntity<RoleManifest> = (config) => {
const errors = [];
if (!config.universalIdentifier) {
errors.push('Role must have a universalIdentifier');
}
if (!config.label) {
errors.push('Role must have a label');
}
if (config.objectPermissions) {
for (const permission of config.objectPermissions) {
if (!permission.objectUniversalIdentifier) {
errors.push('Object permission must have an objectUniversalIdentifier');
}
}
}
if (config.fieldPermissions) {
for (const permission of config.fieldPermissions) {
if (!permission.objectUniversalIdentifier) {
errors.push('Field permission must have an objectUniversalIdentifier');
}
if (!permission.fieldUniversalIdentifier) {
errors.push('Field permission must have a fieldUniversalIdentifier');
}
}
}
return createValidationResult({ config, errors });
};
@@ -0,0 +1 @@
export { PermissionFlagType as PermissionFlag } from 'twenty-shared/constants';