From 1f1ccb281e66b8530e1aeba8dcd0b6252849d22d Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 19 Jan 2026 18:13:10 +0100 Subject: [PATCH] Cache WorkspaceMember (#17247) ## Context Due to RLS feature, workspaceMember entity and its properties is becoming necessary in different places of the backend. To avoid querying many times, this PR adds a map of workspace members per workspaceId, leveraging WorkspaceCache pattern for WorkspaceEntity (in opposition with CoreEntity) Due to its content (mostly PII), I prefer to cache it locally only (node process VS Redis) using localDataOnly. TODO: invalidation logic when the workspaceMember object is updated (more precisely, when its field map is updated). There is no easy way today to update that list so it can be done in another PR imho --- .../auth/strategies/jwt.auth.strategy.spec.ts | 110 ++++++++++-------- .../auth/strategies/jwt.auth.strategy.ts | 50 +++----- .../core-modules/auth/token/token.module.ts | 6 +- .../global-workspace-member.listener.ts | 43 +++++++ ...flat-workspace-member-map-cache.service.ts | 52 +++++++++ .../types/flat-workspace-member-maps.type.ts | 6 + .../user/types/flat-workspace-member.type.ts | 5 + .../engine/core-modules/user/user.module.ts | 16 ++- .../types/workspace-cache-key.type.ts | 3 + .../workspace-member.workspace-entity.ts | 22 ++-- 10 files changed, 220 insertions(+), 93 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/user/services/global-workspace-member.listener.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/services/workspace-flat-workspace-member-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member-maps.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/types/flat-workspace-member.type.ts 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; }