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
This commit is contained in:
Weiko
2026-01-19 18:13:10 +01:00
committed by GitHub
parent b15c042e77
commit 1f1ccb281e
10 changed files with 220 additions and 93 deletions
@@ -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<WorkspaceMemberWorkspaceEntity>
| ObjectRecordUpdateEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordRestoreEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordUpsertEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
await this.workspaceCacheService.invalidateAndRecompute(
payload.workspaceId,
['flatWorkspaceMemberMaps'],
);
}
}
@@ -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<FlatWorkspaceMemberMaps> {
constructor(
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatWorkspaceMemberMaps> {
const flatWorkspaceMemberMaps =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
buildSystemAuthContext(workspaceId),
async () => {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
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;
}
}
@@ -0,0 +1,6 @@
import { type FlatWorkspaceMember } from 'src/engine/core-modules/user/types/flat-workspace-member.type';
export type FlatWorkspaceMemberMaps = {
byId: Partial<Record<string, FlatWorkspaceMember>>;
idByUserId: Partial<Record<string, string>>;
};
@@ -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<WorkspaceMemberWorkspaceEntity>;
@@ -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 {}