diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 51c0e1f01..0a7548946 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -170,9 +170,7 @@ export class AuthResolver { (await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace( origin, )) ?? - (await this.userWorkspaceService.findFirstRandomWorkspaceByUserId( - user.id, - )); + (await this.userWorkspaceService.findFirstWorkspaceByUserId(user.id)); await this.userService.markEmailAsVerified(user.id); diff --git a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts index f27598af2..58864662b 100644 --- a/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain-manager/services/domain-manager.service.ts @@ -47,9 +47,7 @@ export class DomainManagerService { searchParams: Record, ) { Object.entries(searchParams).forEach(([key, value]) => { - if (isDefined(value)) { - url.searchParams.set(key, value.toString()); - } + url.searchParams.set(key, value.toString()); }); } diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts new file mode 100644 index 000000000..c8ba4be29 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -0,0 +1,680 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { TypeORMService } from 'src/database/typeorm/typeorm.service'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; +import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { User } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; + +describe('UserWorkspaceService', () => { + let service: UserWorkspaceService; + let userWorkspaceRepository: Repository; + let userRepository: Repository; + let objectMetadataRepository: Repository; + let dataSourceService: DataSourceService; + let typeORMService: TypeORMService; + let workspaceInvitationService: WorkspaceInvitationService; + let workspaceEventEmitter: WorkspaceEventEmitter; + let domainManagerService: DomainManagerService; + let twentyORMGlobalManager: TwentyORMGlobalManager; + let userRoleService: UserRoleService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserWorkspaceService, + { + provide: getRepositoryToken(UserWorkspace, 'core'), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOneBy: jest.fn(), + countBy: jest.fn(), + exists: jest.fn(), + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User, 'core'), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(ObjectMetadataEntity, 'metadata'), + useValue: { + findOneOrFail: jest.fn(), + }, + }, + { + provide: DataSourceService, + useValue: { + getLastDataSourceMetadataFromWorkspaceIdOrFail: jest.fn(), + }, + }, + { + provide: TypeORMService, + useValue: { + connectToDataSource: jest.fn(), + }, + }, + { + provide: WorkspaceInvitationService, + useValue: { + invalidateWorkspaceInvitation: jest.fn(), + }, + }, + { + provide: WorkspaceEventEmitter, + useValue: { + emitCustomBatchEvent: jest.fn(), + emitDatabaseBatchEvent: jest.fn(), + }, + }, + { + provide: DomainManagerService, + useValue: { + getWorkspaceUrls: jest.fn(), + }, + }, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest.fn(), + }, + }, + { + provide: UserRoleService, + useValue: { + assignRoleToUserWorkspace: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UserWorkspaceService); + userWorkspaceRepository = module.get( + getRepositoryToken(UserWorkspace, 'core'), + ); + userRepository = module.get(getRepositoryToken(User, 'core')); + objectMetadataRepository = module.get( + getRepositoryToken(ObjectMetadataEntity, 'metadata'), + ); + dataSourceService = module.get(DataSourceService); + typeORMService = module.get(TypeORMService); + workspaceInvitationService = module.get( + WorkspaceInvitationService, + ); + workspaceEventEmitter = module.get( + WorkspaceEventEmitter, + ); + domainManagerService = + module.get(DomainManagerService); + twentyORMGlobalManager = module.get( + TwentyORMGlobalManager, + ); + userRoleService = module.get(UserRoleService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a user workspace and emit an event', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const userWorkspace = { userId, workspaceId } as UserWorkspace; + + jest + .spyOn(userWorkspaceRepository, 'create') + .mockReturnValue(userWorkspace); + jest + .spyOn(userWorkspaceRepository, 'save') + .mockResolvedValue(userWorkspace); + jest + .spyOn(workspaceEventEmitter, 'emitCustomBatchEvent') + .mockImplementation(); + + const result = await service.create(userId, workspaceId); + + expect(userWorkspaceRepository.create).toHaveBeenCalledWith({ + userId, + workspaceId, + }); + expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace); + expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith( + USER_SIGNUP_EVENT_NAME, + [{ userId }], + workspaceId, + ); + expect(result).toEqual(userWorkspace); + }); + }); + + describe('createWorkspaceMember', () => { + it('should create a workspace member', async () => { + const workspaceId = 'workspace-id'; + const user = { + id: 'user-id', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + defaultAvatarUrl: 'avatar-url', + locale: 'en', + } as User; + const dataSourceMetadata = { + schema: 'public', + } as DataSourceEntity; + const workspaceDataSource = { + query: jest.fn(), + }; + const workspaceMember = [ + { + id: 'workspace-member-id', + nameFirstName: 'John', + nameLastName: 'Doe', + userId: 'user-id', + userEmail: 'test@example.com', + }, + ]; + const objectMetadata = { + nameSingular: 'workspaceMember', + } as ObjectMetadataEntity; + + jest + .spyOn( + dataSourceService, + 'getLastDataSourceMetadataFromWorkspaceIdOrFail', + ) + .mockResolvedValue(dataSourceMetadata); + jest + .spyOn(typeORMService, 'connectToDataSource') + .mockResolvedValue(workspaceDataSource as any); + workspaceDataSource.query + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(workspaceMember); + jest + .spyOn(objectMetadataRepository, 'findOneOrFail') + .mockResolvedValue(objectMetadata); + jest + .spyOn(workspaceEventEmitter, 'emitDatabaseBatchEvent') + .mockImplementation(); + + await service.createWorkspaceMember(workspaceId, user); + + expect( + dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail, + ).toHaveBeenCalledWith(workspaceId); + expect(typeORMService.connectToDataSource).toHaveBeenCalledWith( + dataSourceMetadata, + ); + expect(workspaceDataSource.query).toHaveBeenCalledTimes(2); + expect(objectMetadataRepository.findOneOrFail).toHaveBeenCalledWith({ + where: { + nameSingular: 'workspaceMember', + workspaceId, + }, + }); + expect(workspaceEventEmitter.emitDatabaseBatchEvent).toHaveBeenCalledWith( + { + objectMetadataNameSingular: 'workspaceMember', + action: DatabaseEventAction.CREATED, + events: [ + { + recordId: workspaceMember[0].id, + objectMetadata, + properties: { + after: workspaceMember[0], + }, + }, + ], + workspaceId, + }, + ); + }); + }); + + describe('addUserToWorkspaceIfUserNotInWorkspace', () => { + it('should add user to workspace if not already in workspace', async () => { + const user = { + id: 'user-id', + email: 'test@example.com', + } as User; + const workspace = { + id: 'workspace-id', + defaultRoleId: 'default-role-id', + } as Workspace; + const userWorkspace = { + id: 'user-workspace-id', + userId: user.id, + workspaceId: workspace.id, + } as UserWorkspace; + + jest.spyOn(service, 'checkUserWorkspaceExists').mockResolvedValue(null); + jest.spyOn(service, 'create').mockResolvedValue(userWorkspace); + jest.spyOn(service, 'createWorkspaceMember').mockResolvedValue(undefined); + jest + .spyOn(userRoleService, 'assignRoleToUserWorkspace') + .mockResolvedValue(undefined); + jest + .spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation') + .mockResolvedValue(undefined); + + await service.addUserToWorkspaceIfUserNotInWorkspace(user, workspace); + + expect(service.checkUserWorkspaceExists).toHaveBeenCalledWith( + user.id, + workspace.id, + ); + expect(service.create).toHaveBeenCalledWith(user.id, workspace.id); + expect(service.createWorkspaceMember).toHaveBeenCalledWith( + workspace.id, + user, + ); + expect(userRoleService.assignRoleToUserWorkspace).toHaveBeenCalledWith({ + workspaceId: workspace.id, + userWorkspaceId: userWorkspace.id, + roleId: workspace.defaultRoleId, + }); + expect( + workspaceInvitationService.invalidateWorkspaceInvitation, + ).toHaveBeenCalledWith(workspace.id, user.email); + }); + + it('should not add user to workspace if already in workspace', async () => { + const user = { + id: 'user-id', + email: 'test@example.com', + } as User; + const workspace = { + id: 'workspace-id', + defaultRoleId: 'default-role-id', + } as Workspace; + const userWorkspace = { + id: 'user-workspace-id', + userId: user.id, + workspaceId: workspace.id, + } as UserWorkspace; + + jest + .spyOn(service, 'checkUserWorkspaceExists') + .mockResolvedValue(userWorkspace); + jest.spyOn(service, 'create').mockResolvedValue(userWorkspace); + jest.spyOn(service, 'createWorkspaceMember').mockResolvedValue(undefined); + + await service.addUserToWorkspaceIfUserNotInWorkspace(user, workspace); + + expect(service.checkUserWorkspaceExists).toHaveBeenCalledWith( + user.id, + workspace.id, + ); + expect(service.create).not.toHaveBeenCalled(); + expect(service.createWorkspaceMember).not.toHaveBeenCalled(); + }); + + it('should throw an exception if workspace has no default role', async () => { + const user = { + id: 'user-id', + email: 'test@example.com', + } as User; + const workspace = { + id: 'workspace-id', + defaultRoleId: undefined, + } as unknown as Workspace; + + jest.spyOn(service, 'checkUserWorkspaceExists').mockResolvedValue(null); + jest.spyOn(service, 'create').mockResolvedValue({} as UserWorkspace); + jest.spyOn(service, 'createWorkspaceMember').mockResolvedValue(undefined); + + await expect( + service.addUserToWorkspaceIfUserNotInWorkspace(user, workspace), + ).rejects.toThrow(PermissionsException); + }); + }); + + describe('getUserCount', () => { + it('should return the count of users in a workspace', async () => { + const workspaceId = 'workspace-id'; + const count = 5; + + jest.spyOn(userWorkspaceRepository, 'countBy').mockResolvedValue(count); + + const result = await service.getUserCount(workspaceId); + + expect(userWorkspaceRepository.countBy).toHaveBeenCalledWith({ + workspaceId, + }); + expect(result).toEqual(count); + }); + }); + + describe('checkUserWorkspaceExists', () => { + it('should check if a user workspace exists', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const userWorkspace = { userId, workspaceId } as UserWorkspace; + + jest + .spyOn(userWorkspaceRepository, 'findOneBy') + .mockResolvedValue(userWorkspace); + + const result = await service.checkUserWorkspaceExists( + userId, + workspaceId, + ); + + expect(userWorkspaceRepository.findOneBy).toHaveBeenCalledWith({ + userId, + workspaceId, + }); + expect(result).toEqual(userWorkspace); + }); + + it('should return null if user workspace does not exist', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(userWorkspaceRepository, 'findOneBy').mockResolvedValue(null); + + const result = await service.checkUserWorkspaceExists( + userId, + workspaceId, + ); + + expect(userWorkspaceRepository.findOneBy).toHaveBeenCalledWith({ + userId, + workspaceId, + }); + expect(result).toBeNull(); + }); + }); + + describe('checkUserWorkspaceExistsByEmail', () => { + it('should check if a user workspace exists by email', async () => { + const email = 'test@example.com'; + const workspaceId = 'workspace-id'; + + jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(true); + + const result = await service.checkUserWorkspaceExistsByEmail( + email, + workspaceId, + ); + + expect(userWorkspaceRepository.exists).toHaveBeenCalledWith({ + where: { + workspaceId, + user: { + email, + }, + }, + relations: { + user: true, + }, + }); + expect(result).toBe(true); + }); + }); + + describe('findAvailableWorkspacesByEmail', () => { + it('should find available workspaces for an email', async () => { + const email = 'test@example.com'; + const workspace1 = { + id: 'workspace-id-1', + displayName: 'Workspace 1', + logo: 'logo1.png', + workspaceSSOIdentityProviders: [ + { + id: 'sso-id-1', + name: 'SSO Provider 1', + issuer: 'issuer1', + type: 'type1', + status: 'Active', + }, + { + id: 'sso-id-2', + name: 'SSO Provider 2', + issuer: 'issuer2', + type: 'type2', + status: 'Inactive', + }, + ], + } as unknown as Workspace; + const workspace2 = { + id: 'workspace-id-2', + displayName: 'Workspace 2', + logo: 'logo2.png', + workspaceSSOIdentityProviders: [], + } as unknown as Workspace; + const user = { + email, + workspaces: [ + { + workspaceId: workspace1.id, + workspace: workspace1, + }, + { + workspaceId: workspace2.id, + workspace: workspace2, + }, + ], + } as User; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); + jest + .spyOn(domainManagerService, 'getWorkspaceUrls') + .mockReturnValueOnce({ + customUrl: 'https://crm.custom1.com', + subdomainUrl: 'https://workspace1.twenty.com', + }) + .mockReturnValueOnce({ + customUrl: 'https://crm.custom2.com', + subdomainUrl: 'https://workspace2.twenty.com', + }); + + const result = await service.findAvailableWorkspacesByEmail(email); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { + email, + }, + relations: [ + 'workspaces', + 'workspaces.workspace', + 'workspaces.workspace.workspaceSSOIdentityProviders', + ], + }); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: workspace1.id, + displayName: workspace1.displayName, + workspaceUrls: { + customUrl: 'https://crm.custom1.com', + subdomainUrl: 'https://workspace1.twenty.com', + }, + logo: workspace1.logo, + sso: [ + { + id: 'sso-id-1', + name: 'SSO Provider 1', + issuer: 'issuer1', + type: 'type1', + status: 'Active', + }, + ], + }); + expect(result[1]).toEqual({ + id: workspace2.id, + displayName: workspace2.displayName, + workspaceUrls: { + customUrl: 'https://crm.custom2.com', + subdomainUrl: 'https://workspace2.twenty.com', + }, + logo: workspace2.logo, + sso: [], + }); + }); + + it('should throw an exception if user is not found', async () => { + const email = 'nonexistent@example.com'; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.findAvailableWorkspacesByEmail(email), + ).rejects.toThrow(AuthException); + }); + }); + + describe('findFirstWorkspaceByUserId', () => { + it('should find the first workspace for a user', async () => { + const userId = 'user-id'; + const workspace1 = { + id: 'workspace-id', + createdAt: '2025-01-02T00:00:00.000Z', + } as unknown as Workspace; + const workspace2 = { + id: 'workspace-id-2', + createdAt: '2025-01-01T00:00:00.000Z', + } as unknown as Workspace; + const user = { + id: userId, + workspaces: [{ workspace: workspace1 }, { workspace: workspace2 }], + } as unknown as User; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); + + const result = await service.findFirstWorkspaceByUserId(userId); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { + id: userId, + }, + relations: ['workspaces', 'workspaces.workspace'], + order: { + workspaces: { + workspace: { + createdAt: 'ASC', + }, + }, + }, + }); + expect(result).toEqual(workspace1); + }); + + it('should throw an exception if no workspace is found', async () => { + const userId = 'user-id'; + const user = { + id: userId, + workspaces: [], + } as unknown as User; + + jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); + + await expect(service.findFirstWorkspaceByUserId(userId)).rejects.toThrow( + AuthException, + ); + }); + }); + + describe('getUserWorkspaceForUserOrThrow', () => { + it('should get a user workspace or throw', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + const userWorkspace = { userId, workspaceId } as UserWorkspace; + + jest + .spyOn(userWorkspaceRepository, 'findOne') + .mockResolvedValue(userWorkspace); + + const result = await service.getUserWorkspaceForUserOrThrow({ + userId, + workspaceId, + }); + + expect(userWorkspaceRepository.findOne).toHaveBeenCalledWith({ + where: { + userId, + workspaceId, + }, + }); + expect(result).toEqual(userWorkspace); + }); + + it('should throw an exception if user workspace is not found', async () => { + const userId = 'user-id'; + const workspaceId = 'workspace-id'; + + jest.spyOn(userWorkspaceRepository, 'findOne').mockResolvedValue(null); + + await expect( + service.getUserWorkspaceForUserOrThrow({ userId, workspaceId }), + ).rejects.toThrow('User workspace not found'); + }); + }); + + describe('getWorkspaceMemberOrThrow', () => { + it('should get a workspace member or throw', async () => { + const workspaceMemberId = 'workspace-member-id'; + const workspaceId = 'workspace-id'; + const workspaceMember = { + id: workspaceMemberId, + } as WorkspaceMemberWorkspaceEntity; + const workspaceMemberRepository = { + findOne: jest.fn().mockResolvedValue(workspaceMember), + }; + + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(workspaceMemberRepository as any); + + const result = await service.getWorkspaceMemberOrThrow({ + workspaceMemberId, + workspaceId, + }); + + expect( + twentyORMGlobalManager.getRepositoryForWorkspace, + ).toHaveBeenCalledWith(workspaceId, 'workspaceMember'); + expect(workspaceMemberRepository.findOne).toHaveBeenCalledWith({ + where: { + id: workspaceMemberId, + }, + }); + expect(result).toEqual(workspaceMember); + }); + + it('should throw an exception if workspace member is not found', async () => { + const workspaceMemberId = 'workspace-member-id'; + const workspaceId = 'workspace-id'; + const workspaceMemberRepository = { + findOne: jest.fn().mockResolvedValue(null), + }; + + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(workspaceMemberRepository as any); + + await expect( + service.getWorkspaceMemberOrThrow({ workspaceMemberId, workspaceId }), + ).rejects.toThrow('Workspace member not found'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index 8b77f0d25..77aa87b7b 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -32,6 +32,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global. import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { assert } from 'src/utils/assert'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; export class UserWorkspaceService extends TypeOrmQueryService { constructor( @@ -186,20 +187,32 @@ export class UserWorkspaceService extends TypeOrmQueryService { }); } - async findFirstRandomWorkspaceByUserId(userId: string) { + async findFirstWorkspaceByUserId(userId: string) { const user = await this.userRepository.findOne({ where: { id: userId, }, relations: ['workspaces', 'workspaces.workspace'], + order: { + workspaces: { + workspace: { + createdAt: 'ASC', + }, + }, + }, }); - userValidator.assertIsDefinedOrThrow( - user, - new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND), + const workspace = user?.workspaces?.[0]?.workspace; + + workspaceValidator.assertIsDefinedOrThrow( + workspace, + new AuthException( + 'Workspace not found', + AuthExceptionCode.WORKSPACE_NOT_FOUND, + ), ); - return user.workspaces[0].workspace; + return workspace; } async findAvailableWorkspacesByEmail(email: string) {