feat: Add agent role assignment and database CRUD tools for AI agent nodes (#12888)

This PR introduces a significant enhancement to the role-based
permission system by extending it to support AI agents, enabling them to
perform database operations based on assigned permissions.

## Key Changes

### 1. Database Schema Migration
- **Table Rename**: `userWorkspaceRole` → `roleTargets` to better
reflect its expanded purpose
- **New Column**: Added `agentId` (UUID, nullable) to support AI agent
role assignments
- **Constraint Updates**: 
- Made `userWorkspaceId` nullable to accommodate agent-only role
assignments
- Added check constraint `CHK_role_targets_either_agent_or_user`
ensuring either `agentId` OR `userWorkspaceId` is set (not both)

### 2. Entity & Service Layer Updates
- **RoleTargetsEntity**: Updated with new `agentId` field and constraint
validation
- **AgentRoleService**: New service for managing agent role assignments
with validation
- **AgentService**: Enhanced to include role information when retrieving
agents
- **RoleResolver**: Added GraphQL mutations for `assignRoleToAgent` and
`removeRoleFromAgent`

### 3. AI Agent CRUD Operations
- **Permission-Based Tool Generation**: AI agents now receive database
tools based on their assigned role permissions
- **Dynamic Tool Creation**: The `AgentToolService` generates CRUD tools
(`create_*`, `find_*`, `update_*`, `soft_delete_*`, `destroy_*`) for
each object based on role permissions
- **Granular Permissions**: Supports both global role permissions
(`canReadAllObjectRecords`) and object-specific permissions
(`canReadObjectRecords`)

### 4. Frontend Integration
- **Role Assignment UI**: Added hooks and components for
assigning/removing roles from agents

## Demo


https://github.com/user-attachments/assets/41732267-742e-416c-b423-b687c2614c82

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: Vicky Wang <157669812+vickywxng@users.noreply.github.com>
Co-authored-by: Vicky Wang <vw92@cornell.edu>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-30 01:48:14 +05:30
committed by GitHub
parent 317336ab71
commit 74b6466a57
53 changed files with 4804 additions and 478 deletions

View File

@ -0,0 +1,77 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameUserWorkspaceRoleToRoleTargets1749000000000
implements MigrationInterface
{
name = 'RenameUserWorkspaceRoleToRoleTargets1749000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" ADD "agentId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" ALTER COLUMN "userWorkspaceId" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" ADD CONSTRAINT "CHK_role_targets_either_agent_or_user" CHECK (((("agentId" IS NOT NULL) AND ("userWorkspaceId" IS NULL)) OR (("agentId" IS NULL) AND ("userWorkspaceId" IS NOT NULL))))`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" RENAME TO "roleTargets"`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_ROLE_ID_UNIQUE" RENAME TO "IDX_ROLE_TARGETS_UNIQUE"`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_WORKSPACE_ID" RENAME TO "IDX_ROLE_TARGETS_WORKSPACE_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."roleTargets" DROP CONSTRAINT "FK_0b70755f23a3705f1bea0ddc7d4"`,
);
await queryRunner.query(
`CREATE INDEX "IDX_ROLE_TARGETS_AGENT_ID" ON "core"."roleTargets" ("agentId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."roleTargets" ADD CONSTRAINT "FK_d5838ba43033ee6266d8928d7d7" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."roleTargets" DROP CONSTRAINT "FK_d5838ba43033ee6266d8928d7d7"`,
);
await queryRunner.query(`DROP INDEX "core"."IDX_ROLE_TARGETS_AGENT_ID"`);
await queryRunner.query(
`ALTER INDEX "core"."IDX_ROLE_TARGETS_UNIQUE" RENAME TO "IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_ROLE_ID_UNIQUE"`,
);
await queryRunner.query(
`ALTER INDEX "core"."IDX_ROLE_TARGETS_WORKSPACE_ID" RENAME TO "IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_WORKSPACE_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."roleTargets" RENAME TO "userWorkspaceRole"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" ADD CONSTRAINT "FK_0b70755f23a3705f1bea0ddc7d4" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" DROP CONSTRAINT "CHK_role_targets_either_agent_or_user"`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" ALTER COLUMN "userWorkspaceId" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."userWorkspaceRole" DROP COLUMN "agentId"`,
);
}
}

View File

@ -21,7 +21,7 @@ import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-run
import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
const graphqlQueryResolvers = [
@ -45,7 +45,7 @@ const graphqlQueryResolvers = [
WorkspaceQueryHookModule,
WorkspaceQueryRunnerModule,
PermissionsModule,
TypeOrmModule.forFeature([UserWorkspaceRoleEntity], 'core'),
TypeOrmModule.forFeature([RoleTargetsEntity], 'core'),
UserRoleModule,
],
providers: [

View File

@ -1,14 +1,11 @@
import { OpenAPIV3_1 } from 'openapi-types';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { capitalize } from 'twenty-shared/utils';
import {
FieldMetadataSettings,
NumberDataType,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
import {
computeDepthParameters,
computeEndingBeforeParameters,
@ -18,11 +15,10 @@ import {
computeOrderByParameters,
computeStartingAfterParameters,
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.utils';
type Property = OpenAPIV3_1.SchemaObject;
@ -32,65 +28,6 @@ type Properties = {
type OpenApiExample = Record<string, FieldMetadataDefaultValue>;
const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
if (forResponse) {
return true;
}
switch (field.name) {
case 'id':
case 'createdAt':
case 'updatedAt':
case 'deletedAt':
return false;
default:
return true;
}
};
const getFieldProperties = (field: FieldMetadataEntity): Property => {
switch (field.type) {
case FieldMetadataType.UUID: {
return { type: 'string', format: 'uuid' };
}
case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT: {
return { type: 'string' };
}
case FieldMetadataType.DATE_TIME: {
return { type: 'string', format: 'date-time' };
}
case FieldMetadataType.DATE: {
return { type: 'string', format: 'date' };
}
case FieldMetadataType.NUMBER: {
const settings =
field.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>;
if (
settings?.dataType === NumberDataType.FLOAT ||
(isDefined(settings?.decimals) && settings.decimals > 0)
) {
return { type: 'number' };
}
return { type: 'integer' };
}
case FieldMetadataType.NUMERIC:
case FieldMetadataType.POSITION: {
return { type: 'number' };
}
case FieldMetadataType.BOOLEAN: {
return { type: 'boolean' };
}
case FieldMetadataType.RAW_JSON: {
return { type: 'object' };
}
default: {
return { type: 'string' };
}
}
};
const getSchemaComponentsExample = (
item: ObjectMetadataEntity,
): OpenApiExample => {
@ -132,261 +69,6 @@ const getSchemaComponentsExample = (
}, {});
};
const getSchemaComponentsProperties = ({
item,
forResponse,
}: {
item: ObjectMetadataEntity;
forResponse: boolean;
}): Properties => {
return item.fields.reduce((node, field) => {
if (
!isFieldAvailable(field, forResponse) ||
field.type === FieldMetadataType.TS_VECTOR
) {
return node;
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
return {
...node,
[`${field.name}Id`]: {
type: 'string',
format: 'uuid',
},
};
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
return node;
}
let itemProperty = {} as Property;
switch (field.type) {
case FieldMetadataType.MULTI_SELECT:
itemProperty = {
type: 'array',
items: {
type: 'string',
enum: field.options.map(
(option: { value: string }) => option.value,
),
},
};
break;
case FieldMetadataType.SELECT:
itemProperty = {
type: 'string',
enum: field.options.map((option: { value: string }) => option.value),
};
break;
case FieldMetadataType.ARRAY:
itemProperty = {
type: 'array',
items: {
type: 'string',
},
};
break;
case FieldMetadataType.RATING:
itemProperty = {
type: 'string',
enum: field.options.map((option: { value: string }) => option.value),
};
break;
case FieldMetadataType.LINKS:
itemProperty = {
type: 'object',
properties: {
primaryLinkLabel: {
type: 'string',
},
primaryLinkUrl: {
type: 'string',
},
secondaryLinks: {
type: 'array',
items: {
type: 'object',
description: 'A secondary link',
properties: {
url: {
type: 'string',
format: 'uri',
},
label: {
type: 'string',
},
},
},
},
},
};
break;
case FieldMetadataType.CURRENCY:
itemProperty = {
type: 'object',
properties: {
amountMicros: {
type: 'number',
},
currencyCode: {
type: 'string',
},
},
};
break;
case FieldMetadataType.FULL_NAME:
itemProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
},
lastName: {
type: 'string',
},
},
};
break;
case FieldMetadataType.ADDRESS:
itemProperty = {
type: 'object',
properties: {
addressStreet1: {
type: 'string',
},
addressStreet2: {
type: 'string',
},
addressCity: {
type: 'string',
},
addressPostcode: {
type: 'string',
},
addressState: {
type: 'string',
},
addressCountry: {
type: 'string',
},
addressLat: {
type: 'number',
},
addressLng: {
type: 'number',
},
},
};
break;
case FieldMetadataType.ACTOR:
itemProperty = {
type: 'object',
properties: {
source: {
type: 'string',
enum: [
'EMAIL',
'CALENDAR',
'WORKFLOW',
'API',
'IMPORT',
'MANUAL',
'SYSTEM',
'WEBHOOK',
],
},
...(forResponse
? {
workspaceMemberId: {
type: 'string',
format: 'uuid',
},
name: {
type: 'string',
},
}
: {}),
},
};
break;
case FieldMetadataType.EMAILS:
itemProperty = {
type: 'object',
properties: {
primaryEmail: {
type: 'string',
},
additionalEmails: {
type: 'array',
items: {
type: 'string',
format: 'email',
},
},
},
};
break;
case FieldMetadataType.PHONES:
itemProperty = {
properties: {
additionalPhones: {
type: 'array',
items: {
type: 'string',
},
},
primaryPhoneCountryCode: {
type: 'string',
},
primaryPhoneCallingCode: {
type: 'string',
},
primaryPhoneNumber: {
type: 'string',
},
},
type: 'object',
};
break;
case FieldMetadataType.RICH_TEXT_V2:
itemProperty = {
type: 'object',
properties: {
blocknote: {
type: 'string',
},
markdown: {
type: 'string',
},
},
};
break;
default:
itemProperty = getFieldProperties(field);
break;
}
if (field.description) {
itemProperty.description = field.description;
}
if (Object.keys(itemProperty).length) {
return { ...node, [field.name]: itemProperty };
}
return node;
}, {} as Properties);
};
const getSchemaComponentsRelationProperties = (
item: ObjectMetadataEntity,
): Properties => {
@ -461,7 +143,10 @@ const computeSchemaComponent = ({
const result: OpenAPIV3_1.SchemaObject = {
type: 'object',
description: item.description,
properties: getSchemaComponentsProperties({ item, forResponse }),
properties: convertObjectMetadataToSchemaProperties({
item,
forResponse,
}) as Properties,
...(!forResponse ? { example: getSchemaComponentsExample(item) } : {}),
};

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
@Module({
imports: [
TypeOrmModule.forFeature(
[AgentEntity, RoleEntity, RoleTargetsEntity],
'core',
),
AgentModule,
],
providers: [AgentRoleService],
exports: [AgentRoleService],
})
export class AgentRoleModule {}

View File

@ -0,0 +1,364 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
describe('AgentRoleService', () => {
let service: AgentRoleService;
let agentRepository: Repository<AgentEntity>;
let roleRepository: Repository<RoleEntity>;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
const testWorkspaceId = 'test-workspace-id';
let testAgent: AgentEntity;
let testRole: RoleEntity;
let testRole2: RoleEntity;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentRoleService,
{
provide: getRepositoryToken(AgentEntity, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(RoleEntity, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();
service = module.get<AgentRoleService>(AgentRoleService);
agentRepository = module.get<Repository<AgentEntity>>(
getRepositoryToken(AgentEntity, 'core'),
);
roleRepository = module.get<Repository<RoleEntity>>(
getRepositoryToken(RoleEntity, 'core'),
);
roleTargetsRepository = module.get<Repository<RoleTargetsEntity>>(
getRepositoryToken(RoleTargetsEntity, 'core'),
);
// Setup test data
testAgent = {
id: 'test-agent-id',
name: 'Test Agent',
description: 'Test agent for unit tests',
prompt: 'You are a test agent',
modelId: 'gpt-4o' as ModelId,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as AgentEntity;
testRole = {
id: 'test-role-id',
label: 'Test Role',
description: 'Test role for unit tests',
canUpdateAllSettings: false,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
isEditable: true,
} as RoleEntity;
testRole2 = {
id: 'test-role-2-id',
label: 'Test Role 2',
description: 'Second test role for unit tests',
canUpdateAllSettings: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
isEditable: true,
} as RoleEntity;
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('assignRoleToAgent', () => {
it('should successfully assign a role to an agent', async () => {
// Arrange
const newRoleTarget = {
id: 'new-role-target-id',
roleId: testRole.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as RoleTargetsEntity;
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole);
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(roleTargetsRepository, 'save')
.mockResolvedValue(newRoleTarget);
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 0 } as any);
// Act
await service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
});
// Assert
expect(agentRepository.findOne).toHaveBeenCalledWith({
where: { id: testAgent.id, workspaceId: testWorkspaceId },
});
expect(roleRepository.findOne).toHaveBeenCalledWith({
where: { id: testRole.id, workspaceId: testWorkspaceId },
});
expect(roleTargetsRepository.findOne).toHaveBeenCalledWith({
where: {
agentId: testAgent.id,
roleId: testRole.id,
workspaceId: testWorkspaceId,
},
});
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
roleId: testRole.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
id: expect.any(Object), // Not(newRoleTarget.id)
});
});
it('should replace existing role when assigning a new role to an agent', async () => {
// Arrange
const newRoleTarget = {
id: 'new-role-target-id',
roleId: testRole2.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as RoleTargetsEntity;
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole2);
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(roleTargetsRepository, 'save')
.mockResolvedValue(newRoleTarget);
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 1 } as any);
// Act
await service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: testRole2.id,
});
// Assert
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
roleId: testRole2.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
id: expect.any(Object), // Not(newRoleTarget.id)
});
});
it('should not create duplicate role target when assigning the same role', async () => {
// Arrange
const existingRoleTarget = {
id: 'existing-role-target-id',
roleId: testRole.id,
agentId: testAgent.id,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
} as RoleTargetsEntity;
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(testRole);
jest
.spyOn(roleTargetsRepository, 'findOne')
.mockResolvedValue(existingRoleTarget);
// Act
await service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
});
// Assert
expect(roleTargetsRepository.save).not.toHaveBeenCalled();
expect(roleTargetsRepository.delete).not.toHaveBeenCalled();
});
it('should throw AgentException when agent does not exist', async () => {
// Arrange
const nonExistentAgentId = 'non-existent-agent-id';
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(null);
// Act & Assert
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: nonExistentAgentId,
roleId: testRole.id,
}),
).rejects.toThrow(AgentException);
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: nonExistentAgentId,
roleId: testRole.id,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_NOT_FOUND,
message: `Agent with id ${nonExistentAgentId} not found in workspace`,
});
});
it('should throw AgentException when role does not exist', async () => {
// Arrange
const nonExistentRoleId = 'non-existent-role-id';
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(testAgent);
jest.spyOn(roleRepository, 'findOne').mockResolvedValue(null);
// Act & Assert
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: nonExistentRoleId,
}),
).rejects.toThrow(AgentException);
await expect(
service.assignRoleToAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
roleId: nonExistentRoleId,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_EXECUTION_FAILED,
message: `Role with id ${nonExistentRoleId} not found in workspace`,
});
});
it('should throw AgentException when agent belongs to different workspace', async () => {
// Arrange
const differentWorkspaceId = 'different-workspace-id';
jest.spyOn(agentRepository, 'findOne').mockResolvedValue(null);
// Act & Assert
await expect(
service.assignRoleToAgent({
workspaceId: differentWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
}),
).rejects.toThrow(AgentException);
await expect(
service.assignRoleToAgent({
workspaceId: differentWorkspaceId,
agentId: testAgent.id,
roleId: testRole.id,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_NOT_FOUND,
message: `Agent with id ${testAgent.id} not found in workspace`,
});
});
});
describe('removeRoleFromAgent', () => {
it('should successfully remove role from agent', async () => {
// Arrange
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 1 } as any);
// Act
await service.removeRoleFromAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
});
// Assert
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
});
it('should not throw error when removing role from agent that has no role', async () => {
// Arrange
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 0 } as any);
// Act & Assert - Should not throw
await expect(
service.removeRoleFromAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
}),
).resolves.not.toThrow();
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
});
});
});
});

View File

@ -0,0 +1,113 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@Injectable()
export class AgentRoleService {
constructor(
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
) {}
public async assignRoleToAgent({
workspaceId,
agentId,
roleId,
}: {
workspaceId: string;
agentId: string;
roleId: string;
}): Promise<void> {
const validationResult = await this.validateAssignRoleInput({
agentId,
workspaceId,
roleId,
});
if (validationResult?.roleToAssignIsSameAsCurrentRole) {
return;
}
const newRoleTarget = await this.roleTargetsRepository.save({
roleId,
agentId,
workspaceId,
});
await this.roleTargetsRepository.delete({
agentId,
workspaceId,
id: Not(newRoleTarget.id),
});
}
public async removeRoleFromAgent({
workspaceId,
agentId,
}: {
workspaceId: string;
agentId: string;
}): Promise<void> {
await this.roleTargetsRepository.delete({
agentId,
workspaceId,
});
}
private async validateAssignRoleInput({
agentId,
workspaceId,
roleId,
}: {
agentId: string;
workspaceId: string;
roleId: string;
}) {
const agent = await this.agentRepository.findOne({
where: { id: agentId, workspaceId },
});
if (!agent) {
throw new AgentException(
`Agent with id ${agentId} not found in workspace`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const role = await this.roleRepository.findOne({
where: { id: roleId, workspaceId },
});
if (!role) {
throw new AgentException(
`Role with id ${roleId} not found in workspace`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const existingRoleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId,
roleId,
workspaceId,
},
});
return {
roleToAssignIsSameAsCurrentRole: Boolean(existingRoleTarget),
};
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import { generateObject, generateText } from 'ai';
import {
ModelId,
@ -10,16 +10,21 @@ import {
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { convertOutputSchemaToZod } from './utils/convert-output-schema-to-zod';
export interface AgentExecutionResult {
object: object;
result: {
textResponse: string;
structuredOutput?: object;
};
usage: {
promptTokens: number;
completionTokens: number;
@ -29,7 +34,10 @@ export interface AgentExecutionResult {
@Injectable()
export class AgentExecutionService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly agentToolService: AgentToolService,
) {}
private getModel = (modelId: ModelId, provider: ModelProvider) => {
switch (provider) {
@ -103,18 +111,52 @@ export class AgentExecutionService {
await this.validateApiKey(provider);
const output = await generateObject({
const tools = await this.agentToolService.generateToolsForAgent(
agent.id,
agent.workspaceId,
);
const textResponse = await generateText({
system: AGENT_SYSTEM_PROMPTS.AGENT_EXECUTION,
model: this.getModel(agent.modelId, provider),
prompt: resolveInput(agent.prompt, context) as string,
tools,
maxSteps: AGENT_CONFIG.MAX_STEPS,
});
if (Object.keys(schema).length === 0) {
return {
result: { textResponse: textResponse.text },
usage: textResponse.usage,
};
}
const output = await generateObject({
system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR,
model: this.getModel(agent.modelId, provider),
prompt: `Based on the following execution results, generate the structured output according to the schema:
Execution Results: ${textResponse.text}
Please generate the structured output based on the execution results and context above.`,
schema: convertOutputSchemaToZod(schema),
});
return {
object: output.object,
result: {
textResponse: textResponse.text,
structuredOutput: output.object,
},
usage: {
promptTokens: output.usage?.promptTokens ?? 0,
completionTokens: output.usage?.completionTokens ?? 0,
totalTokens: output.usage?.totalTokens,
promptTokens:
(textResponse.usage?.promptTokens ?? 0) +
(output.usage?.promptTokens ?? 0),
completionTokens:
(textResponse.usage?.completionTokens ?? 0) +
(output.usage?.completionTokens ?? 0),
totalTokens:
(textResponse.usage?.totalTokens ?? 0) +
(output.usage?.totalTokens ?? 0),
},
};
} catch (error) {

View File

@ -0,0 +1,856 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ToolSet } from 'ai';
import {
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
Repository,
} from 'typeorm';
import { z } from 'zod';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import {
generateBulkDeleteToolSchema,
generateFindToolSchema,
getRecordInputSchema,
} from './utils/agent-tool-schema.utils';
import { isWorkflowRelatedObject } from './utils/is-workflow-related-object.util';
@Injectable()
export class AgentToolService {
constructor(
private readonly agentService: AgentService,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
private readonly objectMetadataService: ObjectMetadataService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
) {}
async generateToolsForAgent(
agentId: string,
workspaceId: string,
): Promise<ToolSet> {
try {
const agent = await this.agentService.findOneAgent(agentId, workspaceId);
if (!agent.roleId) {
return {};
}
const role = await this.roleRepository.findOne({
where: {
id: agent.roleId,
workspaceId,
},
});
if (!role) {
return {};
}
const { data: rolesPermissions } =
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache(
{
workspaceId,
},
);
const objectPermissions = rolesPermissions[agent.roleId];
if (!objectPermissions) {
return {};
}
const tools: ToolSet = {};
const allObjectMetadata =
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
where: {
isActive: true,
isSystem: false,
},
relations: ['fields'],
});
const filteredObjectMetadata = allObjectMetadata.filter(
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
);
filteredObjectMetadata.forEach((objectMetadata) => {
const objectPermission = objectPermissions[objectMetadata.id];
if (!objectPermission) {
return;
}
if (objectPermission.canUpdate) {
tools[`create_${objectMetadata.nameSingular}`] = {
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
parameters: getRecordInputSchema(objectMetadata),
execute: async (parameters) => {
return this.createRecord(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
tools[`update_${objectMetadata.nameSingular}`] = {
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
parameters: getRecordInputSchema(objectMetadata),
execute: async (parameters) => {
return this.updateRecord(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
}
if (objectPermission.canRead) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
parameters: generateFindToolSchema(objectMetadata),
execute: async (parameters) => {
return this.findRecords(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
tools[`find_one_${objectMetadata.nameSingular}`] = {
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
parameters: z.object({
id: z
.string()
.describe('The unique UUID of the record to retrieve'),
}),
execute: async (parameters) => {
return this.findOneRecord(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
}
if (objectPermission.canSoftDelete) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
parameters: z.object({
id: z
.string()
.describe('The unique UUID of the record to soft delete'),
}),
execute: async (parameters) => {
return this.softDeleteRecord(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
parameters: generateBulkDeleteToolSchema(),
execute: async (parameters) => {
return this.softDeleteManyRecords(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
}
if (objectPermission.canDestroy) {
tools[`destroy_${objectMetadata.nameSingular}`] = {
description: `Permanently delete a ${objectMetadata.labelSingular} record from the database. This action is irreversible and completely removes all data. Use with extreme caution - consider soft delete for temporary removal.`,
parameters: z.object({
id: z
.string()
.describe(
'The unique UUID of the record to permanently delete',
),
}),
execute: async (parameters) => {
return this.destroyRecord(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
tools[`destroy_many_${objectMetadata.nameSingular}`] = {
description: `Permanently delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. This action is irreversible and completely removes all data from all specified records. Use with extreme caution.`,
parameters: generateBulkDeleteToolSchema(),
execute: async (parameters) => {
return this.destroyManyRecords(
objectMetadata.nameSingular,
parameters,
workspaceId,
agent.roleId as string,
);
},
};
}
});
return tools;
} catch (error) {
return {};
}
}
private async findRecords(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
const whereConditions = this.buildWhereConditions(searchCriteria);
const records = await repository.find({
where: whereConditions,
take: limit as number,
skip: offset as number,
order: { createdAt: 'DESC' },
});
return {
success: true,
records,
count: records.length,
message: `Found ${records.length} ${objectName} records`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to find ${objectName} records`,
};
}
}
private buildWhereConditions(
searchCriteria: Record<string, unknown>,
): Record<string, unknown> {
const whereConditions: Record<string, unknown> = {};
Object.entries(searchCriteria).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
if (typeof value === 'object' && !Array.isArray(value)) {
const nestedConditions = this.buildNestedWhereConditions(
value as Record<string, unknown>,
);
if (Object.keys(nestedConditions).length > 0) {
whereConditions[key] = nestedConditions;
} else {
const filterCondition = this.parseFilterCondition(
value as Record<string, unknown>,
);
if (filterCondition !== null) {
whereConditions[key] = filterCondition;
}
}
return;
}
whereConditions[key] = value;
});
return whereConditions;
}
private buildNestedWhereConditions(
nestedValue: Record<string, unknown>,
): Record<string, unknown> {
const nestedConditions: Record<string, unknown> = {};
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
if (
nestedFieldValue === undefined ||
nestedFieldValue === null ||
nestedFieldValue === ''
) {
return;
}
if (
typeof nestedFieldValue === 'object' &&
!Array.isArray(nestedFieldValue)
) {
const filterCondition = this.parseFilterCondition(
nestedFieldValue as Record<string, unknown>,
);
if (filterCondition !== null) {
nestedConditions[nestedKey] = filterCondition;
}
} else {
nestedConditions[nestedKey] = nestedFieldValue;
}
});
return nestedConditions;
}
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
if ('eq' in filterValue) {
return filterValue.eq;
}
if ('neq' in filterValue) {
return Not(filterValue.neq);
}
if ('gt' in filterValue) {
return MoreThan(filterValue.gt);
}
if ('gte' in filterValue) {
return MoreThanOrEqual(filterValue.gte);
}
if ('lt' in filterValue) {
return LessThan(filterValue.lt);
}
if ('lte' in filterValue) {
return LessThanOrEqual(filterValue.lte);
}
if ('in' in filterValue) {
return In(filterValue.in as string[]);
}
if ('like' in filterValue) {
return Like(filterValue.like as string);
}
if ('ilike' in filterValue) {
return Like(filterValue.ilike as string);
}
if ('startsWith' in filterValue) {
return Like(`${filterValue.startsWith}%`);
}
if ('is' in filterValue) {
if (filterValue.is === 'NULL') {
return IsNull();
}
if (filterValue.is === 'NOT_NULL') {
return Not(IsNull());
}
}
if ('isEmptyArray' in filterValue) {
return [];
}
if ('containsIlike' in filterValue) {
return Like(`%${filterValue.containsIlike}%`);
}
return null;
}
private async findOneRecord(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { id } = parameters;
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required',
message: `Failed to find ${objectName}: Record ID is required`,
};
}
const record = await repository.findOne({
where: { id },
});
if (!record) {
return {
success: false,
error: 'Record not found',
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
};
}
return {
success: true,
record,
message: `Found ${objectName} record`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to find ${objectName} record`,
};
}
}
private async createRecord(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const createdRecord = await repository.save(parameters);
await this.emitDatabaseEvent({
objectName,
action: DatabaseEventAction.CREATED,
records: [createdRecord],
workspaceId,
});
return {
success: true,
record: createdRecord,
message: `Successfully created ${objectName}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to create ${objectName}`,
};
}
}
private async updateRecord(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { id, ...updateData } = parameters;
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required for update',
message: `Failed to update ${objectName}: Record ID is required`,
};
}
const existingRecord = await repository.findOne({
where: { id },
});
if (!existingRecord) {
return {
success: false,
error: 'Record not found',
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
};
}
await repository.update(id as string, updateData);
const updatedRecord = await repository.findOne({
where: { id: id as string },
});
if (!updatedRecord) {
return {
success: false,
error: 'Failed to retrieve updated record',
message: `Failed to update ${objectName}: Could not retrieve updated record`,
};
}
await this.emitDatabaseEvent({
objectName,
action: DatabaseEventAction.UPDATED,
records: [updatedRecord],
workspaceId,
beforeRecords: [existingRecord],
});
return {
success: true,
record: updatedRecord,
message: `Successfully updated ${objectName}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to update ${objectName}`,
};
}
}
private async softDeleteRecord(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { id } = parameters;
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required for soft delete',
message: `Failed to soft delete ${objectName}: Record ID is required`,
};
}
const existingRecord = await repository.findOne({
where: { id },
});
if (!existingRecord) {
return {
success: false,
error: 'Record not found',
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
};
}
await repository.softDelete(id);
await this.emitDatabaseEvent({
objectName,
action: DatabaseEventAction.DELETED,
records: [existingRecord],
workspaceId,
});
return {
success: true,
message: `Successfully soft deleted ${objectName}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to soft delete ${objectName}`,
};
}
}
private async destroyRecord(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { id } = parameters;
if (!id || typeof id !== 'string') {
return {
success: false,
error: 'Record ID is required for destroy',
message: `Failed to destroy ${objectName}: Record ID is required`,
};
}
const existingRecord = await repository.findOne({
where: { id },
});
if (!existingRecord) {
return {
success: false,
error: 'Record not found',
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
};
}
await repository.remove(existingRecord);
await this.emitDatabaseEvent({
objectName,
action: DatabaseEventAction.DESTROYED,
records: [existingRecord],
workspaceId,
});
return {
success: true,
message: `Successfully destroyed ${objectName}`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to destroy ${objectName}`,
};
}
}
private async softDeleteManyRecords(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { filter } = parameters;
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
return {
success: false,
error: 'Filter with record IDs is required for bulk soft delete',
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
};
}
const idFilter = filter.id as Record<string, unknown>;
const recordIds = idFilter.in;
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
success: false,
error: 'At least one record ID is required for bulk soft delete',
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
};
}
const existingRecords = await repository.find({
where: { id: { in: recordIds } },
});
if (existingRecords.length === 0) {
return {
success: false,
error: 'No records found to soft delete',
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
};
}
await repository.softDelete({ id: { in: recordIds } });
await this.emitDatabaseEvent({
objectName,
action: DatabaseEventAction.DELETED,
records: existingRecords,
workspaceId,
});
return {
success: true,
count: existingRecords.length,
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to soft delete many ${objectName}`,
};
}
}
private async destroyManyRecords(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
roleId: string,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
{ roleId },
);
const { filter } = parameters;
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
return {
success: false,
error: 'Filter with record IDs is required for bulk destroy',
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
};
}
const idFilter = filter.id as Record<string, unknown>;
const recordIds = idFilter.in as string[];
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
success: false,
error: 'At least one record ID is required for bulk destroy',
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
};
}
const existingRecords = await repository.find({
where: { id: { in: recordIds } },
});
if (existingRecords.length === 0) {
return {
success: false,
error: 'No records found to destroy',
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
};
}
await repository.delete({ id: { in: recordIds } });
await this.emitDatabaseEvent({
objectName,
action: DatabaseEventAction.DESTROYED,
records: existingRecords,
workspaceId,
});
return {
success: true,
count: existingRecords.length,
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
message: `Failed to destroy many ${objectName}`,
};
}
}
private async emitDatabaseEvent({
objectName,
action,
records,
workspaceId,
beforeRecords,
}: {
objectName: string;
action: DatabaseEventAction;
records: Record<string, unknown>[];
workspaceId: string;
beforeRecords?: Record<string, unknown>[];
}) {
const objectMetadata =
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
where: {
nameSingular: objectName,
isActive: true,
},
relations: ['fields'],
});
if (!objectMetadata) {
return;
}
this.workspaceEventEmitter.emitDatabaseBatchEvent({
objectMetadataNameSingular: objectName,
action,
events: records.map((record) => {
const beforeRecord = beforeRecords?.find((r) => r.id === record.id);
return {
recordId: record.id as string,
objectMetadata,
properties: {
before: beforeRecord || undefined,
after:
action === DatabaseEventAction.DELETED ||
action === DatabaseEventAction.DESTROYED
? undefined
: (record as Record<string, unknown>),
},
};
}),
workspaceId,
});
}
}

View File

@ -3,27 +3,42 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { AgentExecutionService } from './agent-execution.service';
import { AgentToolService } from './agent-tool.service';
import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
import { AgentService } from './agent.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, FeatureFlag], 'core'),
TypeOrmModule.forFeature(
[AgentEntity, RoleEntity, RoleTargetsEntity],
'core',
),
AiModule,
ThrottlerModule,
AuditModule,
FeatureFlagModule,
ObjectMetadataModule,
WorkspacePermissionsCacheModule,
],
providers: [
AgentResolver,
AgentService,
AgentExecutionService,
AgentToolService,
],
providers: [AgentResolver, AgentService, AgentExecutionService],
exports: [
AgentService,
AgentExecutionService,
AgentToolService,
TypeOrmModule.forFeature([AgentEntity], 'core'),
],
})

View File

@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@ -13,6 +14,8 @@ export class AgentService {
constructor(
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
) {}
async findManyAgents(workspaceId: string) {
@ -34,7 +37,18 @@ export class AgentService {
);
}
return agent;
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId: id,
workspaceId,
},
select: ['roleId'],
});
return {
...agent,
roleId: roleTarget?.roleId || null,
};
}
async createOneAgent(

View File

@ -0,0 +1,3 @@
export const AGENT_CONFIG = {
MAX_STEPS: 10,
};

View File

@ -0,0 +1,71 @@
export const AGENT_SYSTEM_PROMPTS = {
AGENT_EXECUTION: `You are an AI agent node in a workflow builder system with access to comprehensive database operations. Your role is to process inputs, execute actions using available tools, and provide structured outputs that can be used by subsequent workflow nodes.
AVAILABLE DATABASE OPERATIONS:
You have access to full CRUD operations for all standard objects in the system:
- CREATE: create_[object] - Create new records (e.g., create_person, create_company, create_opportunity)
- READ: find_[object] and find_one_[object] - Search and retrieve records
- UPDATE: update_[object] - Modify existing records
- DELETE: soft_delete_[object] and destroy_[object] - Remove records (soft or permanent)
Common objects include: person, company, opportunity, task, note etc. and any custom objects.
CRITICAL PERMISSION CHECK:
Before attempting any operation, you MUST first check if you have the required tools available. If you do NOT have the necessary tools to perform the requested operation, you MUST immediately respond with:
"I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
DO NOT describe what you would do, DO NOT list steps, DO NOT simulate the operation. Simply state that you cannot perform the action due to missing permissions.
Your responsibilities:
1. FIRST check if you have the required tools for the requested operation
2. If tools are NOT available, immediately state you lack permissions - do not proceed further
3. If tools ARE available, analyze the input context and prompt carefully
4. Use available database tools when the request involves data operations
5. For any request to create, read, update, or delete records, use the appropriate tools
6. If no database operations are needed, process the request directly with your analysis
Workflow context:
- You are part of a larger workflow system where your output may be used by other nodes
- Maintain consistency and reliability in your responses
- Consider the broader workflow context when making decisions
- If you encounter data or perform actions, document them clearly in your response
Tool usage guidelines:
- ALWAYS use tools for database operations - do not simulate or describe them
- Use create_[object] tools when asked to create new records
- Use find_[object] tools when asked to search or retrieve records
- Use update_[object] tools when asked to modify existing records
- Use soft_delete_[object] or destroy_[object] when asked to remove records
- Always verify tool results and handle errors appropriately
- Provide context about what tools you used and why
- If a tool fails, explain the issue and suggest alternatives
CRITICAL: When users ask you to perform any database operation (create, find, update, delete), you MUST use the appropriate tools. Do not just describe what you would do - actually execute the operations using the available tools. If you cannot execute the operation due to lack of permissions or roles, you MUST state this clearly in your response.
Important: After your response, the system will call generateObject to convert your output into a structured format according to a specific schema. Therefore:
- Provide comprehensive information in your response
- Include all relevant data you've gathered or processed
- Structure your response logically so it can be easily parsed
- Mention any important context, decisions, or actions taken
- Include tool execution results in your response`,
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs, incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, record creation, etc.)`,
};

View File

@ -32,6 +32,9 @@ export class AgentDTO {
@Field(() => GraphQLJSON, { nullable: true })
responseFormat: object;
@Field(() => UUIDScalarType, { nullable: true })
roleId?: string;
@HideField()
workspaceId: string;

View File

@ -0,0 +1,836 @@
import { jsonSchema } from 'ai';
import { JSONSchema7Definition } from 'json-schema';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { convertObjectMetadataToSchemaProperties } from 'src/engine/utils/convert-object-metadata-to-schema-properties.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const getRecordInputSchema = (objectMetadata: ObjectMetadataEntity) => {
return jsonSchema({
type: 'object',
properties: convertObjectMetadataToSchemaProperties({
item: objectMetadata,
forResponse: false,
}),
});
};
export const generateFindToolSchema = (
objectMetadata: ObjectMetadataEntity,
) => {
const schemaProperties: Record<string, JSONSchema7Definition> = {
limit: {
type: 'number',
description: 'Maximum number of records to return (default: 100)',
default: 100,
},
offset: {
type: 'number',
description: 'Number of records to skip (default: 0)',
default: 0,
},
};
objectMetadata.fields.forEach((field: FieldMetadataEntity) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
const filterSchema = generateFieldFilterJsonSchema(field);
if (filterSchema) {
schemaProperties[field.name] = filterSchema;
}
});
return jsonSchema({
type: 'object',
properties: schemaProperties,
});
};
const generateFieldFilterJsonSchema = (
field: FieldMetadataEntity,
): JSONSchema7Definition | null => {
switch (field.type) {
case FieldMetadataType.UUID:
return {
type: 'object',
description: `Filter by ${field.name} (UUID field)`,
properties: {
eq: {
type: 'string',
format: 'uuid',
description: 'Equals',
},
neq: {
type: 'string',
format: 'uuid',
description: 'Not equals',
},
in: {
type: 'array',
items: {
type: 'string',
format: 'uuid',
},
description: 'In array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT:
case FieldMetadataType.RICH_TEXT_V2:
return {
type: 'object',
description: `Filter by ${field.name} (text field)`,
properties: {
eq: {
type: 'string',
description: 'Equals',
},
neq: {
type: 'string',
description: 'Not equals',
},
in: {
type: 'array',
items: {
type: 'string',
},
description: 'In array of values',
},
like: {
type: 'string',
description: 'Case-sensitive pattern match (use % for wildcards)',
},
ilike: {
type: 'string',
description: 'Case-insensitive pattern match (use % for wildcards)',
},
startsWith: {
type: 'string',
description: 'Starts with',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
case FieldMetadataType.POSITION:
return {
type: 'object',
description: `Filter by ${field.name} (number field)`,
properties: {
eq: {
type: 'number',
description: 'Equals',
},
neq: {
type: 'number',
description: 'Not equals',
},
gt: {
type: 'number',
description: 'Greater than',
},
gte: {
type: 'number',
description: 'Greater than or equal',
},
lt: {
type: 'number',
description: 'Less than',
},
lte: {
type: 'number',
description: 'Less than or equal',
},
in: {
type: 'array',
items: {
type: 'number',
},
description: 'In array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
case FieldMetadataType.BOOLEAN:
return {
type: 'object',
description: `Filter by ${field.name} (boolean field)`,
properties: {
eq: {
type: 'boolean',
description: 'Equals',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
return {
type: 'object',
description: `Filter by ${field.name} (date field)`,
properties: {
eq: {
type: 'string',
format: 'date-time',
description: 'Equals (ISO datetime string)',
},
neq: {
type: 'string',
format: 'date-time',
description: 'Not equals (ISO datetime string)',
},
gt: {
type: 'string',
format: 'date-time',
description: 'Greater than (ISO datetime string)',
},
gte: {
type: 'string',
format: 'date-time',
description: 'Greater than or equal (ISO datetime string)',
},
lt: {
type: 'string',
format: 'date-time',
description: 'Less than (ISO datetime string)',
},
lte: {
type: 'string',
format: 'date-time',
description: 'Less than or equal (ISO datetime string)',
},
in: {
type: 'array',
items: {
type: 'string',
format: 'date-time',
},
description: 'In array of values (ISO datetime strings)',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
case FieldMetadataType.SELECT: {
const enumValues =
field.options?.map((option: { value: string }) => option.value) || [];
return {
type: 'object',
description: `Filter by ${field.name} (select field)`,
properties: {
eq: {
type: 'string',
enum: enumValues,
description: 'Equals',
},
neq: {
type: 'string',
enum: enumValues,
description: 'Not equals',
},
in: {
type: 'array',
items: {
type: 'string',
enum: enumValues,
},
description: 'In array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
}
case FieldMetadataType.MULTI_SELECT: {
const enumValues =
field.options?.map((option: { value: string }) => option.value) || [];
return {
type: 'object',
description: `Filter by ${field.name} (multi-select field)`,
properties: {
in: {
type: 'array',
items: {
type: 'string',
enum: enumValues,
},
description: 'Contains any of these values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
isEmptyArray: {
type: 'boolean',
description: 'Is empty array',
},
},
};
}
case FieldMetadataType.RATING: {
const enumValues =
field.options?.map((option: { value: string }) => option.value) || [];
return {
type: 'object',
description: `Filter by ${field.name} (rating field)`,
properties: {
eq: {
type: 'string',
enum: enumValues,
description: 'Equals',
},
in: {
type: 'array',
items: {
type: 'string',
enum: enumValues,
},
description: 'In array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
}
case FieldMetadataType.ARRAY:
return {
type: 'object',
description: `Filter by ${field.name} (array field)`,
properties: {
containsIlike: {
type: 'string',
description: 'Contains case-insensitive substring',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
isEmptyArray: {
type: 'boolean',
description: 'Is empty array',
},
},
};
case FieldMetadataType.CURRENCY:
return {
type: 'object',
description: `Filter by ${field.name} (currency field)`,
properties: {
amountMicros: {
type: 'object',
description: 'Filter by amount',
properties: {
eq: {
type: 'number',
description: 'Amount equals',
},
neq: {
type: 'number',
description: 'Amount not equals',
},
gt: {
type: 'number',
description: 'Amount greater than',
},
gte: {
type: 'number',
description: 'Amount greater than or equal',
},
lt: {
type: 'number',
description: 'Amount less than',
},
lte: {
type: 'number',
description: 'Amount less than or equal',
},
in: {
type: 'array',
items: {
type: 'number',
},
description: 'Amount in array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Amount is null or not null',
},
},
},
currencyCode: {
type: 'object',
description: 'Filter by currency code',
properties: {
eq: {
type: 'string',
description: 'Currency code equals',
},
neq: {
type: 'string',
description: 'Currency code not equals',
},
in: {
type: 'array',
items: {
type: 'string',
},
description: 'Currency code in array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Currency code is null or not null',
},
},
},
},
};
case FieldMetadataType.FULL_NAME:
return {
type: 'object',
description: `Filter by ${field.name} (full name field)`,
properties: {
firstName: {
type: 'object',
description: 'Filter by first name',
properties: {
eq: {
type: 'string',
description: 'First name equals',
},
neq: {
type: 'string',
description: 'First name not equals',
},
like: {
type: 'string',
description: 'First name case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'First name case-insensitive pattern match',
},
startsWith: {
type: 'string',
description: 'First name starts with',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'First name is null or not null',
},
},
},
lastName: {
type: 'object',
description: 'Filter by last name',
properties: {
eq: {
type: 'string',
description: 'Last name equals',
},
neq: {
type: 'string',
description: 'Last name not equals',
},
like: {
type: 'string',
description: 'Last name case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Last name case-insensitive pattern match',
},
startsWith: {
type: 'string',
description: 'Last name starts with',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Last name is null or not null',
},
},
},
},
};
case FieldMetadataType.ADDRESS:
return {
type: 'object',
description: `Filter by ${field.name} (address field)`,
properties: {
addressStreet1: {
type: 'object',
description: 'Filter by street 1',
properties: {
eq: {
type: 'string',
description: 'Street 1 equals',
},
neq: {
type: 'string',
description: 'Street 1 not equals',
},
like: {
type: 'string',
description: 'Street 1 case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Street 1 case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Street 1 is null or not null',
},
},
},
addressCity: {
type: 'object',
description: 'Filter by city',
properties: {
eq: {
type: 'string',
description: 'City equals',
},
neq: {
type: 'string',
description: 'City not equals',
},
like: {
type: 'string',
description: 'City case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'City case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'City is null or not null',
},
},
},
addressCountry: {
type: 'object',
description: 'Filter by country',
properties: {
eq: {
type: 'string',
description: 'Country equals',
},
neq: {
type: 'string',
description: 'Country not equals',
},
like: {
type: 'string',
description: 'Country case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Country case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Country is null or not null',
},
},
},
},
};
case FieldMetadataType.EMAILS:
return {
type: 'object',
description: `Filter by ${field.name} (emails field)`,
properties: {
primaryEmail: {
type: 'object',
description: 'Filter by primary email',
properties: {
eq: {
type: 'string',
format: 'email',
description: 'Primary email equals',
},
neq: {
type: 'string',
format: 'email',
description: 'Primary email not equals',
},
like: {
type: 'string',
description: 'Primary email case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Primary email case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Primary email is null or not null',
},
},
},
},
};
case FieldMetadataType.PHONES:
return {
type: 'object',
description: `Filter by ${field.name} (phones field)`,
properties: {
primaryPhoneNumber: {
type: 'object',
description: 'Filter by primary phone number',
properties: {
eq: {
type: 'string',
description: 'Primary phone number equals',
},
neq: {
type: 'string',
description: 'Primary phone number not equals',
},
like: {
type: 'string',
description:
'Primary phone number case-sensitive pattern match',
},
ilike: {
type: 'string',
description:
'Primary phone number case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Primary phone number is null or not null',
},
},
},
},
};
case FieldMetadataType.LINKS:
return {
type: 'object',
description: `Filter by ${field.name} (links field)`,
properties: {
primaryLinkUrl: {
type: 'object',
description: 'Filter by primary link URL',
properties: {
eq: {
type: 'string',
format: 'uri',
description: 'Primary link URL equals',
},
neq: {
type: 'string',
format: 'uri',
description: 'Primary link URL not equals',
},
like: {
type: 'string',
description: 'Primary link URL case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Primary link URL case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Primary link URL is null or not null',
},
},
},
},
};
case FieldMetadataType.RELATION:
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
const fieldName = `${field.name}Id`;
return {
type: 'object',
description: `Filter by ${fieldName} (relation field)`,
properties: {
eq: {
type: 'string',
format: 'uuid',
description: 'Related record ID equals',
},
neq: {
type: 'string',
format: 'uuid',
description: 'Related record ID not equals',
},
in: {
type: 'array',
items: {
type: 'string',
format: 'uuid',
},
description: 'Related record ID in array of values',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Related record ID is null or not null',
},
},
};
}
return null;
case FieldMetadataType.RAW_JSON:
return {
type: 'object',
description: `Filter by ${field.name} (raw JSON field)`,
properties: {
eq: {
type: 'string',
description: 'Raw JSON equals',
},
neq: {
type: 'string',
description: 'Raw JSON not equals',
},
like: {
type: 'string',
description: 'Raw JSON case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Raw JSON case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Raw JSON is null or not null',
},
},
};
default:
return {
type: 'object',
description: `Filter by ${field.name} (string field)`,
properties: {
eq: {
type: 'string',
description: 'Equals',
},
neq: {
type: 'string',
description: 'Not equals',
},
like: {
type: 'string',
description: 'Case-sensitive pattern match',
},
ilike: {
type: 'string',
description: 'Case-insensitive pattern match',
},
is: {
type: 'string',
enum: ['NULL', 'NOT_NULL'],
description: 'Is null or not null',
},
},
};
}
};
export const generateBulkDeleteToolSchema = () => {
return jsonSchema({
type: 'object',
properties: {
filter: {
type: 'object',
description: 'Filter criteria to select records for bulk delete',
properties: {
id: {
type: 'object',
description: 'Filter to select records to delete',
properties: {
in: {
type: 'array',
items: {
type: 'string',
format: 'uuid',
},
description: 'Array of record IDs to delete',
},
},
},
},
},
},
});
};

View File

@ -0,0 +1,18 @@
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
const WORKFLOW_OBJECT_NAMES = ['workflow', 'workflowVersion', 'workflowRun'];
export const isWorkflowRelatedObject = (
objectMetadata: ObjectMetadataEntity,
): boolean => {
if (objectMetadata.standardId) {
return (
objectMetadata.standardId === STANDARD_OBJECT_IDS.workflow ||
objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowVersion ||
objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowRun
);
}
return WORKFLOW_OBJECT_NAMES.includes(objectMetadata.nameSingular);
};

View File

@ -0,0 +1,20 @@
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const shouldExcludeFieldFromAgentToolSchema = (
field: FieldMetadataEntity,
excludeId = true,
): boolean => {
const excludedFieldNames = [
'createdAt',
'updatedAt',
'deletedAt',
'searchVector',
'createdBy',
];
if (excludeId) {
excludedFieldNames.push('id');
}
return excludedFieldNames.includes(field.name) || field.isSystem;
};

View File

@ -1,16 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'core'),
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity], 'core'),
FeatureFlagModule,
TypeOrmModule.forFeature([UserWorkspace], 'core'),
UserRoleModule,
WorkspacePermissionsCacheModule,

View File

@ -4,7 +4,7 @@ import { Relation } from 'typeorm';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { SettingPermissionDTO } from 'src/engine/metadata-modules/setting-permission/dtos/setting-permission.dto';
@ObjectType('Role')
@ -25,7 +25,7 @@ export class RoleDTO {
isEditable: boolean;
@HideField()
userWorkspaceRoles: Relation<UserWorkspaceRoleEntity[]>;
roleTargets: Relation<RoleTargetsEntity[]>;
@Field(() => [WorkspaceMember], { nullable: true })
workspaceMembers?: WorkspaceMember[];

View File

@ -1,4 +1,5 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
@ -13,16 +14,15 @@ import {
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@Entity('userWorkspaceRole')
@Unique('IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_ROLE_ID_UNIQUE', [
'userWorkspaceId',
'roleId',
])
@Index('IDX_USER_WORKSPACE_ROLE_USER_WORKSPACE_ID_WORKSPACE_ID', [
'userWorkspaceId',
'workspaceId',
])
export class UserWorkspaceRoleEntity {
@Entity('roleTargets')
@Unique('IDX_ROLE_TARGETS_UNIQUE', ['userWorkspaceId', 'roleId', 'agentId'])
@Index('IDX_ROLE_TARGETS_WORKSPACE_ID', ['userWorkspaceId', 'workspaceId'])
@Index('IDX_ROLE_TARGETS_AGENT_ID', ['agentId'])
@Check(
'CHK_role_targets_either_agent_or_user',
'("agentId" IS NOT NULL AND "userWorkspaceId" IS NULL) OR ("agentId" IS NULL AND "userWorkspaceId" IS NOT NULL)',
)
export class RoleTargetsEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@ -32,15 +32,18 @@ export class UserWorkspaceRoleEntity {
@Column({ nullable: false, type: 'uuid' })
roleId: string;
@ManyToOne(() => RoleEntity, (role) => role.userWorkspaceRoles, {
@ManyToOne(() => RoleEntity, (role) => role.roleTargets, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'roleId' })
role: Relation<RoleEntity>;
@Column({ nullable: false, type: 'uuid' })
@Column({ nullable: true, type: 'uuid' })
userWorkspaceId: string;
@Column({ nullable: true, type: 'uuid' })
agentId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;

View File

@ -10,7 +10,7 @@ import {
} from 'typeorm';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity';
@Entity('role')
@ -56,10 +56,10 @@ export class RoleEntity {
isEditable: boolean;
@OneToMany(
() => UserWorkspaceRoleEntity,
(userWorkspaceRole: UserWorkspaceRoleEntity) => userWorkspaceRole.role,
() => RoleTargetsEntity,
(roleTargets: RoleTargetsEntity) => roleTargets.role,
)
userWorkspaceRoles: Relation<UserWorkspaceRoleEntity[]>;
roleTargets: Relation<RoleTargetsEntity[]>;
@OneToMany(
() => ObjectPermissionEntity,

View File

@ -5,8 +5,10 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleResolver } from 'src/engine/metadata-modules/role/role.resolver';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
@ -16,9 +18,10 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity], 'core'),
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity], 'core'),
TypeOrmModule.forFeature([UserWorkspace, Workspace], 'core'),
UserRoleModule,
AgentRoleModule,
PermissionsModule,
UserWorkspaceModule,
ObjectPermissionModule,

View File

@ -8,6 +8,8 @@ import {
Resolver,
} from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
@ -15,9 +17,11 @@ import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-mem
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { UpsertObjectPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-object-permissions.input';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
@ -55,6 +59,7 @@ export class RoleResolver {
private readonly userWorkspaceService: UserWorkspaceService,
private readonly objectPermissionService: ObjectPermissionService,
private readonly settingPermissionService: SettingPermissionService,
private readonly agentRoleService: AgentRoleService,
) {}
@Query(() => [RoleDTO])
@ -174,6 +179,36 @@ export class RoleResolver {
});
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async assignRoleToAgent(
@Args('agentId', { type: () => UUIDScalarType }) agentId: string,
@Args('roleId', { type: () => UUIDScalarType }) roleId: string,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
await this.agentRoleService.assignRoleToAgent({
agentId,
roleId,
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async removeRoleFromAgent(
@Args('agentId', { type: () => UUIDScalarType }) agentId: string,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
await this.agentRoleService.removeRoleFromAgent({
agentId,
workspaceId,
});
return true;
}
@ResolveField('workspaceMembers', () => [WorkspaceMember])
async getWorkspaceMembersAssignedToRole(
@Parent() role: RoleDTO,

View File

@ -16,6 +16,7 @@ import {
UpdateRoleInput,
UpdateRolePayload,
} from 'src/engine/metadata-modules/role/dtos/update-role-input.dto';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { isArgDefinedIfProvidedOrThrow } from 'src/engine/metadata-modules/utils/is-arg-defined-if-provided-or-throw.util';
@ -27,6 +28,8 @@ export class RoleService {
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly userRoleService: UserRoleService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
) {}
@ -36,11 +39,7 @@ export class RoleService {
where: {
workspaceId,
},
relations: [
'userWorkspaceRoles',
'settingPermissions',
'objectPermissions',
],
relations: ['roleTargets', 'settingPermissions', 'objectPermissions'],
});
}
@ -53,7 +52,7 @@ export class RoleService {
id,
workspaceId,
},
relations: ['userWorkspaceRoles', 'settingPermissions'],
relations: ['roleTargets', 'settingPermissions'],
});
}

View File

@ -1,31 +1,19 @@
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
export const fromRoleEntityToRoleDto = ({
id,
label,
canUpdateAllSettings,
description,
icon,
isEditable,
userWorkspaceRoles,
canReadAllObjectRecords,
canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords,
}: RoleEntity): RoleDTO => {
export const fromRoleEntityToRoleDto = (role: RoleEntity): RoleDTO => {
return {
id,
label,
canUpdateAllSettings,
description,
icon,
isEditable,
userWorkspaceRoles,
canReadAllObjectRecords,
canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords,
id: role.id,
label: role.label,
canUpdateAllSettings: role.canUpdateAllSettings,
description: role.description,
icon: role.icon,
isEditable: role.isEditable,
canReadAllObjectRecords: role.canReadAllObjectRecords,
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
roleTargets: role.roleTargets,
};
};

View File

@ -2,14 +2,14 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, UserWorkspaceRoleEntity], 'core'),
TypeOrmModule.forFeature([RoleEntity, RoleTargetsEntity], 'core'),
TypeOrmModule.forFeature([UserWorkspace], 'core'),
WorkspacePermissionsCacheModule,
],

View File

@ -10,8 +10,8 @@ import {
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@ -20,8 +20,8 @@ export class UserRoleService {
constructor(
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(UserWorkspaceRoleEntity, 'core')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@ -47,16 +47,16 @@ export class UserRoleService {
return;
}
const newUserWorkspaceRole = await this.userWorkspaceRoleRepository.save({
const newRoleTarget = await this.roleTargetsRepository.save({
roleId,
userWorkspaceId,
workspaceId,
});
await this.userWorkspaceRoleRepository.delete({
await this.roleTargetsRepository.delete({
userWorkspaceId,
workspaceId,
id: Not(newUserWorkspaceRole.id),
id: Not(newRoleTarget.id),
});
await this.workspacePermissionsCacheService.recomputeUserWorkspaceRoleMapCache(
@ -98,7 +98,7 @@ export class UserRoleService {
return new Map();
}
const allUserWorkspaceRoles = await this.userWorkspaceRoleRepository.find({
const allRoleTargets = await this.roleTargetsRepository.find({
where: {
userWorkspaceId: In(userWorkspaceIds),
workspaceId,
@ -110,20 +110,19 @@ export class UserRoleService {
},
});
if (!allUserWorkspaceRoles.length) {
if (!allRoleTargets.length) {
return new Map();
}
const rolesMap = new Map<string, RoleEntity[]>();
for (const userWorkspaceId of userWorkspaceIds) {
const userWorkspaceRolesOfUserWorkspace = allUserWorkspaceRoles.filter(
(userWorkspaceRole) =>
userWorkspaceRole.userWorkspaceId === userWorkspaceId,
const roleTargetsOfUserWorkspace = allRoleTargets.filter(
(roleTarget) => roleTarget.userWorkspaceId === userWorkspaceId,
);
const rolesOfUserWorkspace = userWorkspaceRolesOfUserWorkspace
.map((userWorkspaceRole) => userWorkspaceRole.role)
const rolesOfUserWorkspace = roleTargetsOfUserWorkspace
.map((roleTarget) => roleTarget.role)
.filter(isDefined);
rolesMap.set(userWorkspaceId, rolesOfUserWorkspace);

View File

@ -3,8 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@ -15,7 +15,7 @@ import { WorkspacePermissionsCacheService } from './workspace-permissions-cache.
imports: [
TypeOrmModule.forFeature([Workspace], 'core'),
TypeOrmModule.forFeature(
[ObjectMetadataEntity, RoleEntity, UserWorkspaceRoleEntity],
[ObjectMetadataEntity, RoleEntity, RoleTargetsEntity],
'core',
),
WorkspaceCacheStorageModule,

View File

@ -10,8 +10,8 @@ import { In, Repository } from 'typeorm';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type';
import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service';
import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
@ -35,8 +35,8 @@ export class WorkspacePermissionsCacheService {
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(UserWorkspaceRoleEntity, 'core')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService,
) {}
@ -264,14 +264,14 @@ export class WorkspacePermissionsCacheService {
}: {
workspaceId: string;
}): Promise<UserWorkspaceRoleMap> {
const userWorkspaceRoleMap = await this.userWorkspaceRoleRepository.find({
const roleTargetsMap = await this.roleTargetsRepository.find({
where: {
workspaceId,
},
});
return userWorkspaceRoleMap.reduce((acc, userWorkspaceRole) => {
acc[userWorkspaceRole.userWorkspaceId] = userWorkspaceRole.roleId;
return roleTargetsMap.reduce((acc, roleTarget) => {
acc[roleTarget.userWorkspaceId] = roleTarget.roleId;
return acc;
}, {} as UserWorkspaceRoleMap);

View File

@ -17,6 +17,7 @@ export class TwentyORMGlobalManager {
workspaceEntity: Type<T>,
options?: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
},
): Promise<WorkspaceRepository<T>>;
@ -25,6 +26,7 @@ export class TwentyORMGlobalManager {
objectMetadataName: string,
options?: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
},
): Promise<WorkspaceRepository<T>>;
@ -33,6 +35,7 @@ export class TwentyORMGlobalManager {
workspaceEntityOrObjectMetadataName: Type<T> | string,
options: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
} = {
shouldBypassPermissionChecks: false,
},
@ -53,6 +56,7 @@ export class TwentyORMGlobalManager {
const repository = workspaceDataSource.getRepository<T>(
objectMetadataName,
options.shouldBypassPermissionChecks,
options.roleId,
);
return repository;

View File

@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ObjectLiteral, Repository } from 'typeorm';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkspaceDatasourceFactory } from 'src/engine/twenty-orm/factories/workspace-datasource.factory';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@ -13,8 +13,8 @@ import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manag
@Injectable()
export class TwentyORMManager {
constructor(
@InjectRepository(UserWorkspaceRoleEntity, 'core')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly workspaceDataSourceFactory: WorkspaceDatasourceFactory,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
) {}
@ -53,14 +53,14 @@ export class TwentyORMManager {
let roleId: string | undefined;
if (isDefined(userWorkspaceId)) {
const userWorkspaceRole = await this.userWorkspaceRoleRepository.findOne({
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
userWorkspaceId,
workspaceId: workspaceId,
workspaceId,
},
});
roleId = userWorkspaceRole?.roleId;
roleId = roleTarget?.roleId;
}
const shouldBypassPermissionChecks = !!isExecutedByApiKey;

View File

@ -7,7 +7,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
@ -23,7 +23,7 @@ import { PgPoolSharedModule } from './pg-shared-pool/pg-shared-pool.module';
@Module({
imports: [
TypeOrmModule.forFeature(
[ObjectMetadataEntity, UserWorkspaceRoleEntity, Workspace],
[ObjectMetadataEntity, RoleTargetsEntity, Workspace],
'core',
),
DataSourceModule,

View File

@ -0,0 +1,343 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataSettings,
NumberDataType,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export type SchemaObject = {
type: string;
format?: string;
enum?: string[];
items?: SchemaObject;
properties?: Record<string, SchemaObject>;
description?: string;
[key: string]:
| string
| number
| boolean
| string[]
| SchemaObject
| Record<string, SchemaObject>
| undefined;
};
const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
if (forResponse) {
return true;
}
switch (field.name) {
case 'id':
case 'createdAt':
case 'updatedAt':
case 'deletedAt':
return false;
default:
return true;
}
};
const getFieldProperties = (field: FieldMetadataEntity): SchemaObject => {
switch (field.type) {
case FieldMetadataType.UUID: {
return { type: 'string', format: 'uuid' };
}
case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT: {
return { type: 'string' };
}
case FieldMetadataType.DATE_TIME: {
return { type: 'string', format: 'date-time' };
}
case FieldMetadataType.DATE: {
return { type: 'string', format: 'date' };
}
case FieldMetadataType.NUMBER: {
const settings =
field.settings as FieldMetadataSettings<FieldMetadataType.NUMBER>;
if (
settings?.dataType === NumberDataType.FLOAT ||
(isDefined(settings?.decimals) && settings.decimals > 0)
) {
return { type: 'number' };
}
return { type: 'integer' };
}
case FieldMetadataType.NUMERIC:
case FieldMetadataType.POSITION: {
return { type: 'number' };
}
case FieldMetadataType.BOOLEAN: {
return { type: 'boolean' };
}
case FieldMetadataType.RAW_JSON: {
return { type: 'object' };
}
default: {
return { type: 'string' };
}
}
};
export const convertObjectMetadataToSchemaProperties = ({
item,
forResponse,
}: {
item: ObjectMetadataEntity;
forResponse: boolean;
}) => {
return item.fields.reduce((node, field) => {
if (
!isFieldAvailable(field, forResponse) ||
field.type === FieldMetadataType.TS_VECTOR
) {
return node;
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
return {
...node,
[`${field.name}Id`]: {
type: 'string',
format: 'uuid',
},
};
}
if (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.ONE_TO_MANY
) {
return node;
}
let itemProperty = {} as SchemaObject;
switch (field.type) {
case FieldMetadataType.MULTI_SELECT:
itemProperty = {
type: 'array',
items: {
type: 'string',
enum: field.options.map(
(option: { value: string }) => option.value,
),
},
};
break;
case FieldMetadataType.SELECT:
itemProperty = {
type: 'string',
enum: field.options.map((option: { value: string }) => option.value),
};
break;
case FieldMetadataType.ARRAY:
itemProperty = {
type: 'array',
items: {
type: 'string',
},
};
break;
case FieldMetadataType.RATING:
itemProperty = {
type: 'string',
enum: field.options.map((option: { value: string }) => option.value),
};
break;
case FieldMetadataType.LINKS:
itemProperty = {
type: 'object',
properties: {
primaryLinkLabel: {
type: 'string',
},
primaryLinkUrl: {
type: 'string',
},
secondaryLinks: {
type: 'array',
items: {
type: 'object',
description: 'A secondary link',
properties: {
url: {
type: 'string',
format: 'uri',
},
label: {
type: 'string',
},
},
},
},
},
};
break;
case FieldMetadataType.CURRENCY:
itemProperty = {
type: 'object',
properties: {
amountMicros: {
type: 'number',
},
currencyCode: {
type: 'string',
},
},
};
break;
case FieldMetadataType.FULL_NAME:
itemProperty = {
type: 'object',
properties: {
firstName: {
type: 'string',
},
lastName: {
type: 'string',
},
},
};
break;
case FieldMetadataType.ADDRESS:
itemProperty = {
type: 'object',
properties: {
addressStreet1: {
type: 'string',
},
addressStreet2: {
type: 'string',
},
addressCity: {
type: 'string',
},
addressPostcode: {
type: 'string',
},
addressState: {
type: 'string',
},
addressCountry: {
type: 'string',
},
addressLat: {
type: 'number',
},
addressLng: {
type: 'number',
},
},
};
break;
case FieldMetadataType.ACTOR:
itemProperty = {
type: 'object',
properties: {
source: {
type: 'string',
enum: [
'EMAIL',
'CALENDAR',
'WORKFLOW',
'API',
'IMPORT',
'MANUAL',
'SYSTEM',
'WEBHOOK',
],
},
...(forResponse
? {
workspaceMemberId: {
type: 'string',
format: 'uuid',
},
name: {
type: 'string',
},
}
: {}),
},
};
break;
case FieldMetadataType.EMAILS:
itemProperty = {
type: 'object',
properties: {
primaryEmail: {
type: 'string',
},
additionalEmails: {
type: 'array',
items: {
type: 'string',
format: 'email',
},
},
},
};
break;
case FieldMetadataType.PHONES:
itemProperty = {
properties: {
additionalPhones: {
type: 'array',
items: {
type: 'string',
},
},
primaryPhoneCountryCode: {
type: 'string',
},
primaryPhoneCallingCode: {
type: 'string',
},
primaryPhoneNumber: {
type: 'string',
},
},
type: 'object',
};
break;
case FieldMetadataType.RICH_TEXT_V2:
itemProperty = {
type: 'object',
properties: {
blocknote: {
type: 'string',
},
markdown: {
type: 'string',
},
},
};
break;
default:
itemProperty = getFieldProperties(field);
break;
}
if (field.description) {
itemProperty.description = field.description;
}
if (Object.keys(itemProperty).length) {
return { ...node, [field.name]: itemProperty };
}
return node;
}, {});
};

View File

@ -12,9 +12,9 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspaceMigrationEntity } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
@ -29,7 +29,7 @@ describe('WorkspaceManagerService', () => {
let dataSourceRepository: Repository<DataSourceEntity>;
let workspaceFieldMetadataRepository: Repository<FieldMetadataEntity>;
let workspaceDataSourceService: WorkspaceDataSourceService;
let userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
let roleRepository: Repository<RoleEntity>;
beforeEach(async () => {
@ -71,7 +71,7 @@ describe('WorkspaceManagerService', () => {
},
},
{
provide: getRepositoryToken(UserWorkspaceRoleEntity, 'core'),
provide: getRepositoryToken(RoleTargetsEntity, 'core'),
useValue: {
delete: jest.fn(),
},
@ -134,9 +134,9 @@ describe('WorkspaceManagerService', () => {
workspaceDataSourceService = module.get<WorkspaceDataSourceService>(
WorkspaceDataSourceService,
);
userWorkspaceRoleRepository = module.get<
Repository<UserWorkspaceRoleEntity>
>(getRepositoryToken(UserWorkspaceRoleEntity, 'core'));
roleTargetsRepository = module.get<Repository<RoleTargetsEntity>>(
getRepositoryToken(RoleTargetsEntity, 'core'),
);
roleRepository = module.get<Repository<RoleEntity>>(
getRepositoryToken(RoleEntity, 'core'),
);
@ -159,7 +159,7 @@ describe('WorkspaceManagerService', () => {
expect(dataSourceRepository.delete).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
});
expect(userWorkspaceRoleRepository.delete).toHaveBeenCalledWith({
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
workspaceId: 'workspace-id',
});
expect(roleRepository.delete).toHaveBeenCalledWith({

View File

@ -8,9 +8,9 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@ -35,7 +35,7 @@ import { WorkspaceManagerService } from './workspace-manager.service';
RoleModule,
UserRoleModule,
TypeOrmModule.forFeature(
[FieldMetadataEntity, UserWorkspaceRoleEntity, RoleEntity],
[FieldMetadataEntity, RoleTargetsEntity, RoleEntity],
'core',
),
],

View File

@ -10,9 +10,9 @@ import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-s
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserWorkspaceRoleEntity } from 'src/engine/metadata-modules/role/user-workspace-role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
@ -38,10 +38,10 @@ export class WorkspaceManagerService {
private readonly featureFlagService: FeatureFlagService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(UserWorkspaceRoleEntity, 'core')
private readonly userWorkspaceRoleRepository: Repository<UserWorkspaceRoleEntity>,
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
) {}
public async init({
@ -139,10 +139,10 @@ export class WorkspaceManagerService {
});
this.logger.log(`workspace ${workspaceId} field metadata deleted`);
await this.userWorkspaceRoleRepository.delete({
await this.roleTargetsRepository.delete({
workspaceId,
});
this.logger.log(`workspace ${workspaceId} user workspace role deleted`);
this.logger.log(`workspace ${workspaceId} role targets deleted`);
await this.roleRepository.delete({
workspaceId,

View File

@ -69,7 +69,7 @@ export class AiAgentWorkflowAction implements WorkflowExecutor {
);
}
const executionResult = await this.agentExecutionService.executeAgent({
const { result, usage } = await this.agentExecutionService.executeAgent({
agent,
context,
schema: step.settings.outputSchema,
@ -77,11 +77,13 @@ export class AiAgentWorkflowAction implements WorkflowExecutor {
await this.aiBillingService.calculateAndBillUsage(
agent.modelId,
executionResult.usage,
usage,
workspaceId,
);
return { result: executionResult.object };
return {
result,
};
} catch (error) {
if (error instanceof AgentException) {
return {