diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index 9625aafcc6..59ec7a97aa 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -23,7 +23,7 @@ describe('JwtAuthStrategy', () => { let applicationRepository: any; let jwtWrapperService: any; let permissionsService: any; - let globalWorkspaceOrmManager: any; + let workspaceCacheService: any; let workspaceMemberRepository: any; const jwt = { @@ -67,11 +67,26 @@ describe('JwtAuthStrategy', () => { id: 'workspace-member-id', }); - globalWorkspaceOrmManager = { - executeInWorkspaceContext: jest.fn(async (_authContext, callback) => { - return await callback(); + workspaceCacheService = { + getOrRecompute: jest.fn(async (_workspaceId, _cacheKeys) => { + return { + flatWorkspaceMemberMaps: { + byId: { + 'workspace-member-id': { + id: 'workspace-member-id', + userId: 'valid-user-id', + workspaceId: 'workspace-id', + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, + }, + idByUserId: { + 'valid-user-id': 'workspace-member-id', + }, + }, + }; }), - getRepository: jest.fn(async () => workspaceMemberRepository), }; }); @@ -96,7 +111,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -128,7 +143,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -163,7 +178,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -198,7 +213,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); const result = await strategy.validate(payload as JwtPayload); @@ -217,7 +232,7 @@ describe('JwtAuthStrategy', () => { describe('ACCESS token validation', () => { it('should throw AuthExceptionCode if type is ACCESS, no jti, and user not found', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); @@ -240,7 +255,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -257,7 +272,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthExceptionCode if type is ACCESS, no jti, and userWorkspace not found', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); @@ -282,7 +297,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -299,7 +314,7 @@ describe('JwtAuthStrategy', () => { }); it('should not throw if type is ACCESS, no jti, and user and userWorkspace exist', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); @@ -312,7 +327,10 @@ describe('JwtAuthStrategy', () => { workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity()); - userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' }); + userRepository.findOne.mockResolvedValue({ + id: validUserId, + lastName: 'lastNameDefault', + }); userWorkspaceRepository.findOne.mockResolvedValue({ id: validUserWorkspaceId, @@ -328,7 +346,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); const user = await strategy.validate(payload as JwtPayload); @@ -362,7 +380,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -381,7 +399,7 @@ describe('JwtAuthStrategy', () => { describe('Impersonation validation', () => { it('should throw AuthException if impersonation token has missing impersonatorUserWorkspaceId', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); @@ -416,7 +434,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -428,7 +446,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException if impersonation token has missing impersonatedUserWorkspaceId', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -463,7 +481,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -475,7 +493,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException if user tries to impersonate themselves', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); @@ -512,7 +530,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -524,7 +542,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException if impersonator user workspace not found', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -560,7 +578,7 @@ describe('JwtAuthStrategy', () => { .mockResolvedValueOnce({ // For impersonatedUserWorkspace lookup id: validUserWorkspaceId, - user: { id: randomUUID() }, + user: { id: 'valid-user-id' }, workspace: mockWorkspace, }); @@ -572,7 +590,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -585,7 +603,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException if impersonated user workspace not found', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -627,7 +645,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -639,7 +657,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException for server level impersonation without permission', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -670,13 +688,13 @@ describe('JwtAuthStrategy', () => { const mockImpersonatorUserWorkspace = { id: impersonatorUserWorkspaceId, - user: { id: randomUUID(), canImpersonate: false }, // No server level permission + user: { id: 'valid-user-id', canImpersonate: false }, // No server level permission workspace: { id: differentWorkspaceId }, // Different workspace }; const mockImpersonatedUserWorkspace = { id: validUserWorkspaceId, - user: { id: randomUUID() }, + user: { id: 'valid-user-id' }, workspace: mockWorkspace, }; @@ -699,7 +717,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -711,7 +729,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException when no impersonation permissions are granted', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -741,13 +759,13 @@ describe('JwtAuthStrategy', () => { const mockImpersonatorUserWorkspace = { id: impersonatorUserWorkspaceId, - user: { id: randomUUID(), canImpersonate: false }, + user: { id: 'valid-user-id', canImpersonate: false }, workspace: mockWorkspace, // Same workspace }; const mockImpersonatedUserWorkspace = { id: validUserWorkspaceId, - user: { id: randomUUID() }, + user: { id: 'valid-user-id' }, workspace: mockWorkspace, }; @@ -770,7 +788,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -782,7 +800,7 @@ describe('JwtAuthStrategy', () => { }); it('should throw AuthException when impersonatedUserWorkspaceId does not match userWorkspaceId', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -813,13 +831,13 @@ describe('JwtAuthStrategy', () => { const mockImpersonatorUserWorkspace = { id: impersonatorUserWorkspaceId, - user: { id: randomUUID(), canImpersonate: true }, + user: { id: 'valid-user-id', canImpersonate: true }, workspace: mockWorkspace, }; const mockImpersonatedUserWorkspace = { id: impersonatedUserWorkspaceId, - user: { id: randomUUID() }, + user: { id: 'valid-user-id' }, workspace: mockWorkspace, }; @@ -842,7 +860,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow( @@ -854,7 +872,7 @@ describe('JwtAuthStrategy', () => { }); it('should successfully validate workspace level impersonation with permission', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -884,7 +902,7 @@ describe('JwtAuthStrategy', () => { const mockImpersonatorUserWorkspace = { id: impersonatorUserWorkspaceId, - user: { id: randomUUID(), canImpersonate: false }, + user: { id: 'valid-user-id', canImpersonate: false }, workspace: mockWorkspace, // Same workspace }; @@ -907,7 +925,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); const result = await strategy.validate(payload as JwtPayload); @@ -924,7 +942,7 @@ describe('JwtAuthStrategy', () => { }); it('should successfully validate server level impersonation with permission', async () => { - const validUserId = randomUUID(); + const validUserId = 'valid-user-id'; const validUserWorkspaceId = randomUUID(); const validWorkspaceId = randomUUID(); const impersonatorUserWorkspaceId = randomUUID(); @@ -949,7 +967,7 @@ describe('JwtAuthStrategy', () => { const mockImpersonatorUserWorkspace = { id: impersonatorUserWorkspaceId, - user: { id: randomUUID(), canImpersonate: true }, // Server level permission + user: { id: 'valid-user-id', canImpersonate: true }, // Server level permission workspace: { id: differentWorkspaceId }, // Different workspace }; @@ -974,7 +992,7 @@ describe('JwtAuthStrategy', () => { userWorkspaceRepository, apiKeyRepository, permissionsService, - globalWorkspaceOrmManager, + workspaceCacheService, ); const result = await strategy.validate(payload as JwtPayload); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index b4ce737a08..e0bfe78a1d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -31,9 +31,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { userValidator } from 'src/engine/core-modules/user/user.validate'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; -import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; -import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; -import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @Injectable() export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { @@ -50,7 +48,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { @InjectRepository(ApiKeyEntity) private readonly apiKeyRepository: Repository, private readonly permissionsService: PermissionsService, - private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly workspaceCacheService: WorkspaceCacheService, ) { const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest(); // @ts-expect-error legacy noImplicitAny @@ -199,37 +197,27 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') { return context; } - const workspaceMember = - await this.globalWorkspaceOrmManager.executeInWorkspaceContext( - buildSystemAuthContext(workspace.id), - async () => { - const workspaceMemberRepository = - await this.globalWorkspaceOrmManager.getRepository( - workspace.id, - 'workspaceMember', - { shouldBypassPermissionChecks: true }, - ); + const { flatWorkspaceMemberMaps } = + await this.workspaceCacheService.getOrRecompute(workspace.id, [ + 'flatWorkspaceMemberMaps', + ]); - const workspaceMember = await workspaceMemberRepository.findOne({ - where: { - userId: user.id, - }, - }); + const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id]; - assertIsDefinedOrThrow( - workspaceMember, - new AuthException( - 'User is not a member of the workspace', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - { - userFriendlyMessage: msg`User is not a member of the workspace.`, - }, - ), - ); + const workspaceMember = isDefined(workspaceMemberId) + ? flatWorkspaceMemberMaps.byId[workspaceMemberId] + : undefined; - return workspaceMember; + assertIsDefinedOrThrow( + workspaceMember, + new AuthException( + 'User is not a member of the workspace', + AuthExceptionCode.FORBIDDEN_EXCEPTION, + { + userFriendlyMessage: msg`User is not a member of the workspace.`, }, - ); + ), + ); return { ...context, diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 5a8d5f3ad7..731abd3df2 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -4,8 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; @@ -16,8 +18,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; -import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; -import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati TypeORMModule, DataSourceModule, PermissionsModule, + WorkspaceCacheModule, ], providers: [ RenewTokenService, diff --git a/packages/twenty-server/src/engine/core-modules/user/services/global-workspace-member.listener.ts b/packages/twenty-server/src/engine/core-modules/user/services/global-workspace-member.listener.ts new file mode 100644 index 0000000000..656970bc12 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/services/global-workspace-member.listener.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; + +import { + ObjectRecordCreateEvent, + ObjectRecordRestoreEvent, + ObjectRecordUpdateEvent, + ObjectRecordUpsertEvent, + type ObjectRecordDeleteEvent, + type ObjectRecordDestroyEvent, +} from 'twenty-shared/database-events'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@Injectable() +export class GlobalWorkspaceMemberListener { + constructor(private readonly workspaceCacheService: WorkspaceCacheService) {} + + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.CREATED) + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.UPDATED) + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DELETED) + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED) + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.RESTORED) + @OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.UPSERTED) + async handleWorkspaceMemberEvent( + payload: WorkspaceEventBatch< + | ObjectRecordCreateEvent + | ObjectRecordUpdateEvent + | ObjectRecordDeleteEvent + | ObjectRecordDestroyEvent + | ObjectRecordRestoreEvent + | ObjectRecordUpsertEvent + >, + ) { + await this.workspaceCacheService.invalidateAndRecompute( + payload.workspaceId, + ['flatWorkspaceMemberMaps'], + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service.ts new file mode 100644 index 0000000000..75060ca168 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; + +import { FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +@Injectable() +@WorkspaceCache('flatWorkspaceMemberMaps', { localDataOnly: true }) +export class WorkspaceFlatWorkspaceMemberMapCacheService extends WorkspaceCacheProvider { + constructor( + protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + ) { + super(); + } + + async computeForCache(workspaceId: string): Promise { + const flatWorkspaceMemberMaps = + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + buildSystemAuthContext(workspaceId), + async () => { + const workspaceMemberRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'workspaceMember', + { shouldBypassPermissionChecks: true }, + ); + + const flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps = { + byId: {}, + idByUserId: {}, + }; + const workspaceMembers = await workspaceMemberRepository.find({ + withDeleted: true, + }); + + for (const workspaceMember of workspaceMembers) { + flatWorkspaceMemberMaps.byId[workspaceMember.id] = workspaceMember; + flatWorkspaceMemberMaps.idByUserId[workspaceMember.userId] = + workspaceMember.id; + } + + return flatWorkspaceMemberMaps; + }, + ); + + return flatWorkspaceMemberMaps; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member-maps.type.ts b/packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member-maps.type.ts new file mode 100644 index 0000000000..fe73451a4b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member-maps.type.ts @@ -0,0 +1,6 @@ +import { type FlatWorkspaceMember } from 'src/engine/core-modules/user/types/flat-workspace-member.type'; + +export type FlatWorkspaceMemberMaps = { + byId: Partial>; + idByUserId: Partial>; +}; diff --git a/packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member.type.ts b/packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member.type.ts new file mode 100644 index 0000000000..27992affd0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member.type.ts @@ -0,0 +1,5 @@ +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type'; +import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +export type FlatWorkspaceMember = + FlatEntityFrom; diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index 87ca3b2524..cfcf3497f6 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -6,6 +6,8 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; +import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; +import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; @@ -13,6 +15,7 @@ import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-v import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; +import { WorkspaceFlatWorkspaceMemberMapCacheService } from 'src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service'; import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service'; import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -22,8 +25,8 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; -import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; -import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module'; +import { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { userAutoResolverOpts } from './user.auto-resolver-opts'; @@ -53,8 +56,15 @@ import { UserService } from './services/user.service'; PermissionsModule, EmailVerificationModule, WorkspaceDomainsModule, + WorkspaceCacheModule, ], exports: [UserService, WorkspaceMemberTranspiler], - providers: [UserService, UserResolver, WorkspaceMemberTranspiler], + providers: [ + UserService, + UserResolver, + WorkspaceMemberTranspiler, + WorkspaceFlatWorkspaceMemberMapCacheService, + GlobalWorkspaceMemberListener, + ], }) export class UserModule {} diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index 88ea9302a1..1cbf4d7b3e 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -3,6 +3,7 @@ import { type EntityMetadata } from 'typeorm'; import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type'; import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; import { type FlatRoleTargetByAgentIdMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-role-target-by-agent-id-maps.type'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { type UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/services/workspace-user-workspace-role-map-cache.service'; @@ -42,6 +43,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatRowLevelPermissionPredicateGroupMaps: 'flat-maps:row-level-permission-predicate-group', flatFrontComponentMaps: 'flat-maps:front-component', + flatWorkspaceMemberMaps: 'flat-maps:workspace-member', } as const satisfies Record; export type AdditionalCacheDataMaps = { @@ -54,6 +56,7 @@ export type AdditionalCacheDataMaps = { flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps; flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps; flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; + flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps; }; export type WorkspaceCacheDataMap = AllFlatEntityMaps & AdditionalCacheDataMaps; diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts index cbbc3b68f1..be81050387 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts @@ -2,10 +2,10 @@ import { registerEnumType } from '@nestjs/graphql'; import { type APP_LOCALES } from 'twenty-shared/translations'; import { FieldMetadataType, type FullNameMetadata } from 'twenty-shared/types'; +import { type Relation } from 'typeorm'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; -import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface'; import { type AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity'; import { type CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; @@ -74,18 +74,18 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity { timeZone: string; dateFormat: string; timeFormat: string; - assignedTasks: EntityRelation; - favorites: EntityRelation; - accountOwnerForCompanies: EntityRelation; - authoredAttachments: EntityRelation; - connectedAccounts: EntityRelation; - messageParticipants: EntityRelation; - blocklist: EntityRelation; - calendarEventParticipants: EntityRelation< + assignedTasks: Relation; + favorites: Relation; + accountOwnerForCompanies: Relation; + authoredAttachments: Relation; + connectedAccounts: Relation; + messageParticipants: Relation; + blocklist: Relation; + calendarEventParticipants: Relation< CalendarEventParticipantWorkspaceEntity[] >; - timelineActivities: EntityRelation; - ownedOpportunities: EntityRelation; + timelineActivities: Relation; + ownedOpportunities: Relation; searchVector: string; numberFormat: string; }