Migrate role and role target to v2 (#16009)

# Introduction
close https://github.com/twentyhq/core-team-issues/issues/1930
close https://github.com/twentyhq/core-team-issues/issues/1929
Migrating role and roleTarget entities to the v2 core engine, allowing
v2 caching leverage and allow migrating agent to v2 that needs role
target in prior
After agent we should be able to pass twenty standard app totally though
workspace migration

## Role target assignation
Please note that role target have 3 creation entrypoints:
- Agent
- User workspace
- ApiKey

Refactored all 3 of them to pass through a new role-target.service.ts
that consumes the v2 under the hood.

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Paul Rastoin
2025-11-28 18:06:11 +01:00
committed by GitHub
parent 23a7611aac
commit ea3c5d2d45
131 changed files with 6082 additions and 1319 deletions
@@ -9,6 +9,8 @@ import {
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -19,6 +21,7 @@ describe('AiAgentRoleService', () => {
let agentRepository: Repository<AgentEntity>;
let roleRepository: Repository<RoleEntity>;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
let roleTargetService: RoleTargetService;
const testWorkspaceId = 'test-workspace-id';
let testAgent: AgentEntity;
@@ -52,6 +55,13 @@ describe('AiAgentRoleService', () => {
find: jest.fn(),
},
},
{
provide: RoleTargetService,
useValue: {
create: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
@@ -65,6 +75,7 @@ describe('AiAgentRoleService', () => {
roleTargetsRepository = module.get<Repository<RoleTargetsEntity>>(
getRepositoryToken(RoleTargetsEntity),
);
roleTargetService = module.get<RoleTargetService>(RoleTargetService);
// Setup test data
testAgent = {
@@ -122,24 +133,12 @@ describe('AiAgentRoleService', () => {
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);
.spyOn(roleTargetService, 'create')
.mockResolvedValue({} as FlatRoleTarget);
// Act
await service.assignRoleToAgent({
@@ -162,38 +161,24 @@ describe('AiAgentRoleService', () => {
workspaceId: testWorkspaceId,
},
});
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
roleId: testRole.id,
agentId: testAgent.id,
expect(roleTargetService.create).toHaveBeenCalledWith({
createRoleTargetInput: {
roleId: testRole.id,
targetId: testAgent.id,
targetMetadataForeignKey: 'agentId',
},
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);
.spyOn(roleTargetService, 'create')
.mockResolvedValue({} as FlatRoleTarget);
// Act
await service.assignRoleToAgent({
@@ -203,16 +188,14 @@ describe('AiAgentRoleService', () => {
});
// Assert
expect(roleTargetsRepository.save).toHaveBeenCalledWith({
roleId: testRole2.id,
agentId: testAgent.id,
expect(roleTargetService.create).toHaveBeenCalledWith({
createRoleTargetInput: {
roleId: testRole2.id,
targetId: testAgent.id,
targetMetadataForeignKey: 'agentId',
},
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 () => {
@@ -240,8 +223,7 @@ describe('AiAgentRoleService', () => {
});
// Assert
expect(roleTargetsRepository.save).not.toHaveBeenCalled();
expect(roleTargetsRepository.delete).not.toHaveBeenCalled();
expect(roleTargetService.create).not.toHaveBeenCalled();
});
it('should throw AgentException when agent does not exist', async () => {
@@ -330,9 +312,19 @@ describe('AiAgentRoleService', () => {
describe('removeRoleFromAgent', () => {
it('should successfully remove role from agent', 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(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 1 } as any);
.spyOn(roleTargetsRepository, 'findOne')
.mockResolvedValue(existingRoleTarget);
jest.spyOn(roleTargetService, 'delete').mockResolvedValue(undefined);
// Act
await service.removeRoleFromAgent({
@@ -341,29 +333,38 @@ describe('AiAgentRoleService', () => {
});
// Assert
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
expect(roleTargetsRepository.findOne).toHaveBeenCalledWith({
where: {
agentId: testAgent.id,
workspaceId: testWorkspaceId,
},
});
expect(roleTargetService.delete).toHaveBeenCalledWith({
id: existingRoleTarget.id,
workspaceId: testWorkspaceId,
});
});
it('should not throw error when removing role from agent that has no role', async () => {
it('should throw error when removing role from agent that has no role', async () => {
// Arrange
jest
.spyOn(roleTargetsRepository, 'delete')
.mockResolvedValue({ affected: 0 } as any);
jest.spyOn(roleTargetsRepository, 'findOne').mockResolvedValue(null);
// Act & Assert - Should not throw
// Act & Assert
await expect(
service.removeRoleFromAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
}),
).resolves.not.toThrow();
).rejects.toThrow(AgentException);
expect(roleTargetsRepository.delete).toHaveBeenCalledWith({
agentId: testAgent.id,
workspaceId: testWorkspaceId,
await expect(
service.removeRoleFromAgent({
workspaceId: testWorkspaceId,
agentId: testAgent.id,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.ROLE_NOT_FOUND,
message: `Role target not found for agent ${testAgent.id}`,
});
});
});
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -10,6 +11,7 @@ import { AiAgentRoleService } from './ai-agent-role.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
RoleTargetModule,
],
providers: [AiAgentRoleService],
exports: [AiAgentRoleService],
@@ -9,6 +9,7 @@ import {
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -21,6 +22,7 @@ export class AiAgentRoleService {
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly roleTargetService: RoleTargetService,
) {}
public async assignRoleToAgent({
@@ -42,17 +44,14 @@ export class AiAgentRoleService {
return;
}
const newRoleTarget = await this.roleTargetsRepository.save({
roleId,
agentId,
await this.roleTargetService.create({
createRoleTargetInput: {
roleId,
targetId: agentId,
targetMetadataForeignKey: 'agentId',
},
workspaceId,
});
await this.roleTargetsRepository.delete({
agentId,
workspaceId,
id: Not(newRoleTarget.id),
});
}
public async assignStandardRoleToAgent({
@@ -68,7 +67,7 @@ export class AiAgentRoleService {
where: { standardId: standardRoleId, workspaceId },
});
if (!role) {
if (!isDefined(role)) {
throw new AgentException(
`Standard role with standard ID ${standardRoleId} not found in workspace`,
AgentExceptionCode.ROLE_NOT_FOUND,
@@ -89,8 +88,22 @@ export class AiAgentRoleService {
workspaceId: string;
agentId: string;
}): Promise<void> {
await this.roleTargetsRepository.delete({
agentId,
const existingRoleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId,
workspaceId,
},
});
if (!isDefined(existingRoleTarget)) {
throw new AgentException(
`Role target not found for agent ${agentId}`,
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
await this.roleTargetService.delete({
id: existingRoleTarget.id,
workspaceId,
});
}
@@ -1,4 +1,4 @@
import { Module, forwardRef } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
@@ -9,7 +9,6 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiChatRouterModule } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module';
@@ -54,7 +53,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
TokenModule,
WorkspaceDomainsModule,
WorkflowToolsModule,
forwardRef(() => UserModule),
UserWorkspaceModule,
UserRoleModule,
],