feat: optimize hot database queries with multi-layer caching (#19068)
## Summary Introduces multi-layer caching for the 5 most frequent database queries identified in production (Sentry data), targeting the JWT authentication hot path and cron job logic. ### Problem Our database is under heavy load from uncached queries on the auth hot path: - `WorkspaceEntity` lookups: **638 queries/min** - `ApiKeyEntity` lookups: **491 queries/min** - `UserEntity` lookups: **147 queries/min** - `UserWorkspaceEntity` lookups: **143 queries/min** - `LogicFunctionEntity` lookups: **1800 queries/min** (cron job) ### Solution **1. New `CoreEntityCacheService`** for non-workspace-scoped entities (Workspace, User, UserWorkspace): - Mirrors `WorkspaceCacheService` architecture (in-process Map + Redis with hash validation) - Provider pattern with `@CoreEntityCache` decorator - Keyed by entity primary key (not workspaceId) - 100ms local TTL, Redis-backed hash validation for cross-instance consistency - Three providers: `WorkspaceEntityCacheProviderService`, `UserEntityCacheProviderService`, `UserWorkspaceEntityCacheProviderService` **2. New `apiKeyMap` WorkspaceCache** for workspace-scoped API key lookups: - `WorkspaceApiKeyMapCacheService` loads all API keys for a workspace into a map by ID - Leverages existing `WorkspaceCacheService` infrastructure - Cache invalidation on API key create/update/revoke **3. `CronTriggerCronJob` refactored** to use existing `flatLogicFunctionMaps` workspace cache: - Eliminates per-workspace `LogicFunctionEntity` repository queries (~1800/min) - Filters cached data in-memory instead **4. `JwtAuthStrategy` refactored** to use caches for all entity lookups: - Workspace, User, UserWorkspace → `CoreEntityCacheService` - ApiKey → `WorkspaceCacheService` (`apiKeyMap`) - Impersonation queries kept as direct DB queries (rare path, requires relations) **5. Cache invalidation** wired into mutation paths: - `WorkspaceService` → invalidates `workspaceEntity` on save/update/delete - `ApiKeyService` → invalidates `apiKeyMap` on create/update/revoke ### Architecture ``` Request → JwtAuthStrategy ├── Workspace lookup → CoreEntityCacheService (in-process → Redis → DB) ├── User lookup → CoreEntityCacheService (in-process → Redis → DB) ├── UserWorkspace lookup → CoreEntityCacheService (in-process → Redis → DB) └── ApiKey lookup → WorkspaceCacheService (in-process → Redis → DB) CronTriggerCronJob └── LogicFunction lookup → WorkspaceCacheService (flatLogicFunctionMaps) ``` ### Expected Impact | Query | Before | After | |-------|--------|-------| | WorkspaceEntity | 638/min | ~0 (cached) | | ApiKeyEntity | 491/min | ~0 (cached) | | UserEntity | 147/min | ~0 (cached) | | UserWorkspaceEntity | 143/min | ~0 (cached) | | LogicFunctionEntity | 1800/min | ~0 (cached) | ### Not included (ongoing separately) - DataSourceEntity query optimization (IS_DATASOURCE_MIGRATED migration) - ObjectMetadataEntity query optimization (already partially cached)
This commit is contained in:
+9
-8
@@ -1,21 +1,22 @@
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
import { type ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { type RawAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
user?: User | null;
|
||||
apiKey?: ApiKey | null;
|
||||
user?: FlatAuthContextUser | null;
|
||||
apiKey?: FlatApiKey | null;
|
||||
application?: ApplicationEntity | null;
|
||||
userWorkspace?: UserWorkspace;
|
||||
userWorkspace?: FlatUserWorkspace;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
workspace?: Workspace;
|
||||
workspace?: FlatWorkspace;
|
||||
workspaceId?: string;
|
||||
workspaceMetadataVersion?: number;
|
||||
workspaceMemberId?: string;
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
|
||||
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
@@ -30,7 +30,7 @@ describe('DataArgProcessorService', () => {
|
||||
|
||||
const createMockAuthContext = (): SystemWorkspaceAuthContext => ({
|
||||
type: 'system',
|
||||
workspace: { id: mockWorkspaceId } as WorkspaceEntity,
|
||||
workspace: { id: mockWorkspaceId } as FlatWorkspace,
|
||||
});
|
||||
|
||||
const createFlatFieldMetadataMaps = (
|
||||
|
||||
+5
-5
@@ -32,15 +32,15 @@ import { useValidateGraphqlQueryComplexity } from 'src/engine/core-modules/graph
|
||||
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { DataloaderService } from 'src/engine/dataloaders/dataloader.service';
|
||||
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
||||
import { renderApolloPlayground } from 'src/engine/utils/render-apollo-playground.util';
|
||||
|
||||
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
|
||||
user?: UserEntity;
|
||||
workspace?: WorkspaceEntity;
|
||||
user?: FlatAuthContextUser;
|
||||
workspace?: FlatWorkspace;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -172,7 +172,7 @@ export class GraphQLConfigService
|
||||
|
||||
async createSchema(
|
||||
context: YogaDriverServerContext<'express'> & YogaInitialContext,
|
||||
workspace: WorkspaceEntity,
|
||||
workspace: FlatWorkspace,
|
||||
applicationId?: string,
|
||||
): Promise<GraphQLSchemaWithContext<YogaDriverServerContext<'express'>>> {
|
||||
// Create a new contextId for each request
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { type YogaDriverServerContext } from '@graphql-yoga/nestjs';
|
||||
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
export interface GraphQLContext extends YogaDriverServerContext<'express'> {
|
||||
user?: UserEntity;
|
||||
workspace?: WorkspaceEntity;
|
||||
user?: FlatAuthContextUser;
|
||||
workspace?: FlatWorkspace;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { workspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/work
|
||||
import { WorkspaceResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory';
|
||||
import { WorkspaceGraphQLSchemaGenerator } from 'src/engine/api/graphql/workspace-schema-builder/workspace-graphql-schema.factory';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import {
|
||||
FlatEntityMapsException,
|
||||
@@ -41,7 +41,7 @@ export class WorkspaceSchemaFactory {
|
||||
) {}
|
||||
|
||||
async createGraphQLSchema(
|
||||
workspace: WorkspaceEntity,
|
||||
workspace: FlatWorkspace,
|
||||
applicationId?: string,
|
||||
): Promise<GraphQLSchema> {
|
||||
const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled(
|
||||
|
||||
+4
-4
@@ -9,12 +9,12 @@ import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.contr
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
|
||||
@@ -69,10 +69,10 @@ describe('McpCoreController', () => {
|
||||
});
|
||||
|
||||
describe('handleMcpCore', () => {
|
||||
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
|
||||
const mockWorkspace = { id: 'workspace-1' } as FlatWorkspace;
|
||||
const mockUser = { id: 'user-1' } as UserEntity;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockApiKey = { id: 'api-key-1' } as ApiKeyEntity;
|
||||
const mockApiKey = { id: 'api-key-1' } as FlatApiKey;
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
} as unknown as import('express').Response;
|
||||
|
||||
@@ -18,9 +18,9 @@ import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
@@ -45,8 +45,8 @@ export class McpCoreController {
|
||||
)
|
||||
async handleMcpCore(
|
||||
@Body() body: JsonRpc,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@AuthApiKey() apiKey: FlatApiKey | undefined,
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
|
||||
+4
-4
@@ -8,14 +8,14 @@ import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
|
||||
import { GET_TOOL_CATALOG_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool';
|
||||
import { LEARN_TOOLS_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
|
||||
import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
|
||||
@@ -26,14 +26,14 @@ describe('McpProtocolService', () => {
|
||||
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
|
||||
let apiKeyRoleService: jest.Mocked<ApiKeyRoleService>;
|
||||
|
||||
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
|
||||
const mockWorkspace = { id: 'workspace-1' } as FlatWorkspace;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockRoleId = 'role-1';
|
||||
const mockAdminRoleId = 'admin-role-1';
|
||||
const mockApiKey = {
|
||||
id: 'api-key-1',
|
||||
workspaceId: mockWorkspace.id,
|
||||
} as ApiKeyEntity;
|
||||
} as FlatApiKey;
|
||||
|
||||
const EXPECTED_MCP_TOOL_NAMES = [
|
||||
GET_TOOL_CATALOG_TOOL_NAME,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server
|
||||
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util';
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
LOAD_SKILL_TOOL_NAME,
|
||||
loadSkillInputSchema,
|
||||
} from 'src/engine/core-modules/tool-provider/tools/load-skill.tool';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
|
||||
@@ -70,7 +70,7 @@ export class McpProtocolService {
|
||||
async getRoleId(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
apiKey?: ApiKeyEntity,
|
||||
apiKey?: FlatApiKey,
|
||||
) {
|
||||
if (isDefined(apiKey)) {
|
||||
return this.apiKeyRoleService.getRoleIdForApiKeyId(
|
||||
@@ -99,7 +99,7 @@ export class McpProtocolService {
|
||||
}
|
||||
|
||||
private async buildMcpToolSet(
|
||||
workspace: WorkspaceEntity,
|
||||
workspace: FlatWorkspace,
|
||||
roleId: string,
|
||||
options?: {
|
||||
authContext?: WorkspaceAuthContext;
|
||||
@@ -165,10 +165,10 @@ export class McpProtocolService {
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
}: {
|
||||
workspace: WorkspaceEntity;
|
||||
workspace: FlatWorkspace;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
apiKey: ApiKeyEntity | undefined;
|
||||
apiKey: FlatApiKey | undefined;
|
||||
},
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
|
||||
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
|
||||
@Module({
|
||||
imports: [CacheStorageModule, DiscoveryModule],
|
||||
providers: [CoreEntityCacheService],
|
||||
exports: [CoreEntityCacheService],
|
||||
})
|
||||
export class CoreEntityCacheModule {}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { type CoreEntityCacheKeyName } from 'src/engine/core-entity-cache/types/core-entity-cache-key.type';
|
||||
|
||||
export const CORE_ENTITY_CACHE_KEY = 'CORE_ENTITY_CACHE_KEY';
|
||||
|
||||
export const CoreEntityCache = (
|
||||
coreEntityCacheKeyName: CoreEntityCacheKeyName,
|
||||
): ClassDecorator => {
|
||||
return (target) => {
|
||||
SetMetadata(CORE_ENTITY_CACHE_KEY, coreEntityCacheKeyName)(target);
|
||||
};
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
appendCommonExceptionCode,
|
||||
CustomException,
|
||||
} from 'src/utils/custom-exception';
|
||||
|
||||
export const CoreEntityCacheExceptionCode = appendCommonExceptionCode({
|
||||
INVALID_PARAMETERS: 'INVALID_PARAMETERS',
|
||||
} as const);
|
||||
|
||||
const getCoreEntityCacheExceptionUserFriendlyMessage = (
|
||||
code: keyof typeof CoreEntityCacheExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case CoreEntityCacheExceptionCode.INVALID_PARAMETERS:
|
||||
return msg`Invalid parameters provided.`;
|
||||
case CoreEntityCacheExceptionCode.INTERNAL_SERVER_ERROR:
|
||||
return msg`An unexpected error occurred.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class CoreEntityCacheException extends CustomException<
|
||||
keyof typeof CoreEntityCacheExceptionCode
|
||||
> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: keyof typeof CoreEntityCacheExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getCoreEntityCacheExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type CoreEntityCacheDataMap,
|
||||
type CoreEntityCacheKeyName,
|
||||
} from 'src/engine/core-entity-cache/types/core-entity-cache-key.type';
|
||||
|
||||
type CoreEntityCacheDataType = CoreEntityCacheDataMap[CoreEntityCacheKeyName];
|
||||
|
||||
@Injectable()
|
||||
export abstract class CoreEntityCacheProvider<
|
||||
T extends CoreEntityCacheDataType = CoreEntityCacheDataType,
|
||||
> {
|
||||
abstract computeForCache(entityId: string): Promise<T | null>;
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { DiscoveryService, Reflector } from '@nestjs/core';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
|
||||
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { PromiseMemoizer } from 'src/engine/twenty-orm/storage/promise-memoizer.storage';
|
||||
import { CORE_ENTITY_CACHE_KEY } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
|
||||
import {
|
||||
CORE_ENTITY_CACHE_KEYS,
|
||||
CoreEntityCacheKeyName,
|
||||
type CoreEntityCacheDataMap,
|
||||
} from 'src/engine/core-entity-cache/types/core-entity-cache-key.type';
|
||||
import { type CoreEntityLocalCacheEntry } from 'src/engine/core-entity-cache/types/core-entity-local-cache-entry.type';
|
||||
|
||||
const LOCAL_TTL_MS = 100; // 100ms
|
||||
const LOCAL_ENTRY_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
const MEMOIZER_TTL_MS = 10_000; // 10 seconds
|
||||
const STALE_VERSION_TTL_MS = 5_000; // 5 seconds
|
||||
const MAX_LOCAL_STALE_VERSIONS = 5;
|
||||
const MAX_LOCAL_CACHE_ENTRIES = 5_000;
|
||||
const MIN_EVICT_KEYS = 100;
|
||||
|
||||
const NOT_FOUND_SENTINEL = '__CORE_ENTITY_NOT_FOUND__';
|
||||
type CacheDataType = CoreEntityCacheDataMap[CoreEntityCacheKeyName];
|
||||
type CacheableValue = CacheDataType | typeof NOT_FOUND_SENTINEL;
|
||||
|
||||
@Injectable()
|
||||
export class CoreEntityCacheService implements OnModuleInit {
|
||||
private readonly localCache = new Map<
|
||||
string,
|
||||
CoreEntityLocalCacheEntry<CacheableValue>
|
||||
>();
|
||||
private readonly coreEntityCacheProviders = new Map<
|
||||
CoreEntityCacheKeyName,
|
||||
CoreEntityCacheProvider<CacheDataType>
|
||||
>();
|
||||
private readonly memoizer = new PromiseMemoizer<CacheableValue>(
|
||||
MEMOIZER_TTL_MS,
|
||||
);
|
||||
|
||||
private readonly logger = new Logger(CoreEntityCacheService.name);
|
||||
|
||||
constructor(
|
||||
@InjectCacheStorage(CacheStorageNamespace.EngineCoreEntity)
|
||||
private readonly cacheStorage: CacheStorageService,
|
||||
private readonly discoveryService: DiscoveryService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const providers = this.discoveryService.getProviders();
|
||||
|
||||
for (const wrapper of providers) {
|
||||
const { instance } = wrapper;
|
||||
|
||||
if (!isDefined(instance) || typeof instance !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const coreEntityCacheKeyName = this.reflector.get<CoreEntityCacheKeyName>(
|
||||
CORE_ENTITY_CACHE_KEY,
|
||||
instance.constructor,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(coreEntityCacheKeyName) &&
|
||||
instance instanceof CoreEntityCacheProvider
|
||||
) {
|
||||
this.coreEntityCacheProviders.set(coreEntityCacheKeyName, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async get<K extends CoreEntityCacheKeyName>(
|
||||
cacheKeyName: K,
|
||||
entityId: string,
|
||||
): Promise<CoreEntityCacheDataMap[K] | null> {
|
||||
this.evictExpiredLocalEntries();
|
||||
|
||||
if (!isDefined(entityId) || !isValidUuid(entityId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoKey = `${cacheKeyName}-${entityId}` as const;
|
||||
|
||||
const result = await this.memoizer.memoizePromiseAndExecute(
|
||||
memoKey,
|
||||
async () => {
|
||||
const localKey = this.buildCacheKey(entityId, cacheKeyName);
|
||||
const localEntry = this.localCache.get(localKey);
|
||||
const now = Date.now();
|
||||
|
||||
// Stage 1: Check local TTL
|
||||
if (
|
||||
isDefined(localEntry) &&
|
||||
now - localEntry.lastHashCheckedAt < LOCAL_TTL_MS
|
||||
) {
|
||||
const version = localEntry.versions.get(localEntry.latestHash);
|
||||
|
||||
if (isDefined(version)) {
|
||||
version.lastReadAt = now;
|
||||
|
||||
return version.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 2: Validate against Redis hash
|
||||
const hashKey = `${localKey}:hash`;
|
||||
const redisHash = await this.cacheStorage.get<string>(hashKey);
|
||||
|
||||
if (
|
||||
isDefined(localEntry) &&
|
||||
isDefined(redisHash) &&
|
||||
localEntry.latestHash === redisHash
|
||||
) {
|
||||
localEntry.lastHashCheckedAt = now;
|
||||
const version = localEntry.versions.get(localEntry.latestHash);
|
||||
|
||||
if (isDefined(version)) {
|
||||
version.lastReadAt = now;
|
||||
|
||||
return version.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 3: Fetch from Redis
|
||||
const [redisData, redisDataHash] = await this.cacheStorage.mget<
|
||||
CacheableValue | string
|
||||
>([`${localKey}:data`, hashKey]);
|
||||
|
||||
if (isDefined(redisData) && isDefined(redisDataHash)) {
|
||||
this.setInLocalCache(
|
||||
entityId,
|
||||
cacheKeyName,
|
||||
redisData as CacheableValue,
|
||||
redisDataHash as string,
|
||||
);
|
||||
|
||||
return redisData as CacheableValue;
|
||||
}
|
||||
|
||||
// Stage 4: Recompute from provider
|
||||
return this.recomputeFromProvider(entityId, cacheKeyName);
|
||||
},
|
||||
);
|
||||
|
||||
if (result === NOT_FOUND_SENTINEL || result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result as CoreEntityCacheDataMap[K];
|
||||
}
|
||||
|
||||
public async invalidate(
|
||||
cacheKeyName: CoreEntityCacheKeyName,
|
||||
entityId: string,
|
||||
): Promise<void> {
|
||||
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
|
||||
await this.flush(entityId, cacheKeyName);
|
||||
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
|
||||
}
|
||||
|
||||
public async invalidateAndRecompute(
|
||||
cacheKeyName: CoreEntityCacheKeyName,
|
||||
entityId: string,
|
||||
): Promise<void> {
|
||||
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
|
||||
await this.flush(entityId, cacheKeyName);
|
||||
await this.recomputeFromProvider(entityId, cacheKeyName);
|
||||
await this.memoizer.clearKeys(`${cacheKeyName}-${entityId}`);
|
||||
}
|
||||
|
||||
private async recomputeFromProvider(
|
||||
entityId: string,
|
||||
cacheKeyName: CoreEntityCacheKeyName,
|
||||
): Promise<CacheableValue> {
|
||||
const provider = this.getProviderOrThrow(cacheKeyName);
|
||||
const data = await provider.computeForCache(entityId);
|
||||
const hash = crypto.randomUUID();
|
||||
|
||||
const valueToCache: CacheableValue =
|
||||
data === null ? NOT_FOUND_SENTINEL : data;
|
||||
|
||||
const localKey = this.buildCacheKey(entityId, cacheKeyName);
|
||||
|
||||
await this.cacheStorage.mset<unknown>([
|
||||
{ key: `${localKey}:hash`, value: hash },
|
||||
{ key: `${localKey}:data`, value: valueToCache },
|
||||
]);
|
||||
|
||||
this.setInLocalCache(entityId, cacheKeyName, valueToCache, hash);
|
||||
|
||||
return valueToCache;
|
||||
}
|
||||
|
||||
private async flush(
|
||||
entityId: string,
|
||||
cacheKeyName: CoreEntityCacheKeyName,
|
||||
): Promise<void> {
|
||||
const localKey = this.buildCacheKey(entityId, cacheKeyName);
|
||||
|
||||
await this.cacheStorage.mdel([`${localKey}:data`, `${localKey}:hash`]);
|
||||
|
||||
const entry = this.localCache.get(localKey);
|
||||
|
||||
if (isDefined(entry)) {
|
||||
entry.lastHashCheckedAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private setInLocalCache(
|
||||
entityId: string,
|
||||
keyName: CoreEntityCacheKeyName,
|
||||
data: CacheableValue,
|
||||
hash: string,
|
||||
): void {
|
||||
const localKey = this.buildCacheKey(entityId, keyName);
|
||||
let entry = this.localCache.get(localKey);
|
||||
|
||||
if (!isDefined(entry)) {
|
||||
entry = { versions: new Map(), latestHash: '', lastHashCheckedAt: 0 };
|
||||
this.localCache.set(localKey, entry);
|
||||
}
|
||||
|
||||
entry.versions.set(hash, { data, lastReadAt: Date.now() });
|
||||
entry.latestHash = hash;
|
||||
entry.lastHashCheckedAt = Date.now();
|
||||
|
||||
this.cleanupStaleVersions(entry);
|
||||
this.evictLRUEntriesIfNeeded();
|
||||
}
|
||||
|
||||
private evictLRUEntriesIfNeeded(): void {
|
||||
if (this.localCache.size <= MAX_LOCAL_CACHE_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = [...this.localCache.entries()].sort(
|
||||
(a, b) => a[1].lastHashCheckedAt - b[1].lastHashCheckedAt,
|
||||
);
|
||||
|
||||
const toEvict = entries.slice(
|
||||
0,
|
||||
Math.max(MIN_EVICT_KEYS, this.localCache.size - MAX_LOCAL_CACHE_ENTRIES),
|
||||
);
|
||||
|
||||
for (const [key] of toEvict) {
|
||||
this.localCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupStaleVersions(
|
||||
entry: CoreEntityLocalCacheEntry<CacheableValue>,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hash, version] of entry.versions) {
|
||||
if (
|
||||
hash !== entry.latestHash &&
|
||||
now - version.lastReadAt > STALE_VERSION_TTL_MS
|
||||
) {
|
||||
entry.versions.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.versions.size >= MAX_LOCAL_STALE_VERSIONS) {
|
||||
const sorted = [...entry.versions.entries()]
|
||||
.filter(([hash]) => hash !== entry.latestHash)
|
||||
.sort((entryA, entryB) => entryA[1].lastReadAt - entryB[1].lastReadAt);
|
||||
|
||||
while (
|
||||
entry.versions.size >= MAX_LOCAL_STALE_VERSIONS &&
|
||||
sorted.length > 0
|
||||
) {
|
||||
const oldestEntry = sorted.shift();
|
||||
|
||||
if (isDefined(oldestEntry)) {
|
||||
entry.versions.delete(oldestEntry[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private evictExpiredLocalEntries(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [localKey, entry] of this.localCache) {
|
||||
for (const [hash, version] of entry.versions) {
|
||||
if (now - version.lastReadAt > LOCAL_ENTRY_TTL_MS) {
|
||||
entry.versions.delete(hash);
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.versions.size === 0) {
|
||||
this.localCache.delete(localKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.versions.has(entry.latestHash)) {
|
||||
this.localCache.delete(localKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getProviderOrThrow(
|
||||
keyName: CoreEntityCacheKeyName,
|
||||
): CoreEntityCacheProvider<CacheDataType> {
|
||||
const provider = this.coreEntityCacheProviders.get(keyName);
|
||||
|
||||
if (!isDefined(provider)) {
|
||||
throw new Error(
|
||||
`Core entity cache provider with key name "${keyName}" not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
private buildCacheKey(
|
||||
entityId: string,
|
||||
keyName: CoreEntityCacheKeyName,
|
||||
): string {
|
||||
return `${CORE_ENTITY_CACHE_KEYS[keyName]}:${entityId}`;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
export type CoreEntityCacheDataMap = {
|
||||
workspaceEntity: FlatWorkspace;
|
||||
user: FlatUser;
|
||||
userWorkspaceEntity: FlatUserWorkspace;
|
||||
};
|
||||
|
||||
export type CoreEntityCacheKeyName = keyof CoreEntityCacheDataMap;
|
||||
|
||||
export const CORE_ENTITY_CACHE_KEYS: Record<CoreEntityCacheKeyName, string> = {
|
||||
workspaceEntity: 'core-entity:workspace',
|
||||
user: 'core-entity:user',
|
||||
userWorkspaceEntity: 'core-entity:user-workspace',
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
export type CoreEntityVersionEntry<T> = {
|
||||
data: T;
|
||||
lastReadAt: number;
|
||||
};
|
||||
|
||||
export type CoreEntityLocalCacheEntry<T> = {
|
||||
versions: Map<string, CoreEntityVersionEntry<T>>;
|
||||
latestHash: string;
|
||||
lastHashCheckedAt: number;
|
||||
};
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types';
|
||||
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
|
||||
type BuildCreatedByFromApiKeyArgs = {
|
||||
apiKey: ApiKeyEntity;
|
||||
apiKey: FlatApiKey;
|
||||
};
|
||||
export const buildCreatedByFromApiKey = ({
|
||||
apiKey,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiKeyResolver } from 'src/engine/core-modules/api-key/api-key.resolver
|
||||
import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command';
|
||||
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/api-key/services/api-key.service';
|
||||
import { WorkspaceApiKeyMapCacheService } from 'src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
@@ -39,6 +40,7 @@ import { ApiKeyController } from './controllers/api-key.controller';
|
||||
ApiKeyService,
|
||||
ApiKeyResolver,
|
||||
ApiKeyRoleService,
|
||||
WorkspaceApiKeyMapCacheService,
|
||||
GenerateApiKeyCommand,
|
||||
],
|
||||
controllers: [ApiKeyController],
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
|
||||
export const API_KEY_ENTITY_NON_CACHED_PROPERTIES = [
|
||||
'workspace',
|
||||
] as const satisfies ReadonlyArray<keyof ApiKeyEntity>;
|
||||
+7
@@ -14,6 +14,7 @@ import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-contex
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
describe('ApiKeyService', () => {
|
||||
let service: ApiKeyService;
|
||||
@@ -111,6 +112,12 @@ describe('ApiKeyService', () => {
|
||||
provide: getDataSourceToken(),
|
||||
useValue: mockDataSource,
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: {
|
||||
invalidateAndRecompute: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { type ApiKeyToken } from 'src/engine/core-modules/auth/dto/api-key-token
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
@@ -22,6 +23,7 @@ export class ApiKeyService {
|
||||
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly roleTargetService: RoleTargetService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
@@ -44,6 +46,8 @@ export class ApiKeyService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.invalidateApiKeyCache(savedApiKey.workspaceId);
|
||||
|
||||
return savedApiKey;
|
||||
}
|
||||
|
||||
@@ -88,6 +92,7 @@ export class ApiKeyService {
|
||||
}
|
||||
|
||||
await this.apiKeyRepository.update(id, updateData);
|
||||
await this.invalidateApiKeyCache(workspaceId);
|
||||
|
||||
return this.findById(id, workspaceId);
|
||||
}
|
||||
@@ -184,4 +189,10 @@ export class ApiKeyService {
|
||||
isActive(apiKey: ApiKeyEntity): boolean {
|
||||
return !this.isRevoked(apiKey) && !this.isExpired(apiKey);
|
||||
}
|
||||
|
||||
private async invalidateApiKeyCache(workspaceId: string): Promise<void> {
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'apiKeyMap',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { fromApiKeyEntityToFlat } from 'src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
||||
|
||||
@Injectable()
|
||||
@WorkspaceCache('apiKeyMap')
|
||||
export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider<
|
||||
Record<string, FlatApiKey>
|
||||
> {
|
||||
constructor(
|
||||
@InjectRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<Record<string, FlatApiKey>> {
|
||||
const apiKeys = await this.apiKeyRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
return apiKeys.reduce(
|
||||
(map, apiKey) => {
|
||||
map[apiKey.id] = fromApiKeyEntityToFlat(apiKey);
|
||||
|
||||
return map;
|
||||
},
|
||||
{} as Record<string, FlatApiKey>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
|
||||
import { type API_KEY_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant';
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
|
||||
type ApiKeyEntityNonCachedProperties =
|
||||
(typeof API_KEY_ENTITY_NON_CACHED_PROPERTIES)[number];
|
||||
|
||||
type ApiKeyCachedFields = Omit<ApiKeyEntity, ApiKeyEntityNonCachedProperties>;
|
||||
|
||||
export type FlatApiKey = Omit<
|
||||
ApiKeyCachedFields,
|
||||
keyof CastRecordTypeOrmDatePropertiesToString<ApiKeyCachedFields>
|
||||
> &
|
||||
CastRecordTypeOrmDatePropertiesToString<ApiKeyCachedFields>;
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
|
||||
export const fromApiKeyEntityToFlat = (entity: ApiKeyEntity): FlatApiKey => ({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
workspaceId: entity.workspaceId,
|
||||
expiresAt: entity.expiresAt.toISOString(),
|
||||
revokedAt: entity.revokedAt?.toISOString() ?? null,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
});
|
||||
@@ -57,6 +57,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
|
||||
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationModule,
|
||||
WorkspaceCacheModule,
|
||||
CoreEntityCacheModule,
|
||||
SecureHttpClientModule,
|
||||
EnterpriseModule,
|
||||
FileModule,
|
||||
|
||||
@@ -512,8 +512,10 @@ export class AuthResolver {
|
||||
@AuthUser() currentUser: AuthContextUser,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
): Promise<SignUpDTO> {
|
||||
const fullUser = await this.userService.findUserByIdOrThrow(currentUser.id);
|
||||
|
||||
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
|
||||
{ type: 'existingUser', existingUser: currentUser },
|
||||
{ type: 'existingUser', existingUser: fullUser },
|
||||
);
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
compareHash,
|
||||
hashPassword,
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import {
|
||||
type AuthProviderWithPasswordType,
|
||||
type ExistingUserOrPartialUserWithPicture,
|
||||
@@ -314,7 +313,7 @@ export class SignInUpService {
|
||||
workspace,
|
||||
shouldShowConnectAccountStep,
|
||||
}: {
|
||||
user: AuthContextUser;
|
||||
user: Pick<UserEntity, 'id' | 'firstName' | 'lastName'>;
|
||||
workspace: WorkspaceEntity;
|
||||
shouldShowConnectAccountStep: boolean;
|
||||
},
|
||||
@@ -577,7 +576,11 @@ export class SignInUpService {
|
||||
);
|
||||
|
||||
await this.activateOnboardingForUser(
|
||||
{ user, workspace, shouldShowConnectAccountStep: true },
|
||||
{
|
||||
user,
|
||||
workspace,
|
||||
shouldShowConnectAccountStep: true,
|
||||
},
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
|
||||
+328
-407
@@ -16,38 +16,31 @@ import { JwtAuthStrategy } from './jwt.auth.strategy';
|
||||
|
||||
describe('JwtAuthStrategy', () => {
|
||||
let strategy: JwtAuthStrategy;
|
||||
let workspaceRepository: any;
|
||||
let userWorkspaceRepository: any;
|
||||
let userRepository: any;
|
||||
let apiKeyRepository: any;
|
||||
let applicationRepository: any;
|
||||
let jwtWrapperService: any;
|
||||
let permissionsService: any;
|
||||
let workspaceCacheService: any;
|
||||
let workspaceMemberRepository: any;
|
||||
let coreEntityCacheService: any;
|
||||
|
||||
const jwt = {
|
||||
sub: 'sub-default',
|
||||
jti: 'jti-default',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
workspaceRepository = {
|
||||
findOneBy: jest.fn(),
|
||||
};
|
||||
let workspaceStore: Record<string, any>;
|
||||
let userStore: Record<string, any>;
|
||||
let apiKeyStore: Record<string, Record<string, any>>;
|
||||
|
||||
userRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
workspaceStore = {};
|
||||
userStore = {};
|
||||
apiKeyStore = {};
|
||||
|
||||
userWorkspaceRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
apiKeyRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
applicationRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
@@ -60,33 +53,55 @@ describe('JwtAuthStrategy', () => {
|
||||
userHasWorkspaceSettingPermission: jest.fn(),
|
||||
};
|
||||
|
||||
workspaceMemberRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
workspaceMemberRepository.findOne.mockResolvedValue({
|
||||
id: 'workspace-member-id',
|
||||
});
|
||||
|
||||
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,
|
||||
getOrRecompute: jest.fn(
|
||||
async (workspaceId: string, cacheKeys: string[]) => {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
if (cacheKeys.includes('flatWorkspaceMemberMaps')) {
|
||||
result.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',
|
||||
},
|
||||
},
|
||||
};
|
||||
idByUserId: {
|
||||
'valid-user-id': 'workspace-member-id',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (cacheKeys.includes('apiKeyMap')) {
|
||||
result.apiKeyMap = apiKeyStore[workspaceId] ?? {};
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
coreEntityCacheService = {
|
||||
get: jest.fn(async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return userWorkspaceRepository.findOne({ where: { id: entityId } });
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
invalidate: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -94,6 +109,16 @@ describe('JwtAuthStrategy', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createStrategy = () =>
|
||||
new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
applicationRepository,
|
||||
userWorkspaceRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
coreEntityCacheService,
|
||||
);
|
||||
|
||||
describe('API_KEY validation', () => {
|
||||
it('should throw AuthException if type is API_KEY and workspace is not found', async () => {
|
||||
const payload = {
|
||||
@@ -101,18 +126,7 @@ describe('JwtAuthStrategy', () => {
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -131,20 +145,10 @@ describe('JwtAuthStrategy', () => {
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = 'workspace-id';
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
workspaceStore[payload.sub] = mockWorkspace;
|
||||
apiKeyStore['workspace-id'] = {};
|
||||
|
||||
apiKeyRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -163,23 +167,15 @@ describe('JwtAuthStrategy', () => {
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = 'workspace-id';
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
workspaceStore[payload.sub] = mockWorkspace;
|
||||
apiKeyStore['workspace-id'] = {
|
||||
[payload.jti]: {
|
||||
id: 'api-key-id',
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
apiKeyRepository.findOne.mockResolvedValue({
|
||||
id: 'api-key-id',
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -198,35 +194,20 @@ describe('JwtAuthStrategy', () => {
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = 'workspace-id';
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
workspaceStore[payload.sub] = mockWorkspace;
|
||||
apiKeyStore['workspace-id'] = {
|
||||
[payload.jti]: {
|
||||
id: 'api-key-id',
|
||||
revokedAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
apiKeyRepository.findOne.mockResolvedValue({
|
||||
id: 'api-key-id',
|
||||
revokedAt: null,
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
const result = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.apiKey?.id).toBe('api-key-id');
|
||||
|
||||
expect(apiKeyRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: payload.jti,
|
||||
workspaceId: mockWorkspace.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,20 +224,9 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
|
||||
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
|
||||
|
||||
userRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -287,22 +257,12 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
|
||||
|
||||
userRepository.findOne.mockResolvedValue({ lastName: 'lastNameDefault' });
|
||||
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
|
||||
userStore[validUserId] = { lastName: 'lastNameDefault' };
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -333,30 +293,36 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
|
||||
|
||||
userRepository.findOne.mockResolvedValue({
|
||||
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
|
||||
userStore[validUserId] = {
|
||||
id: validUserId,
|
||||
lastName: 'lastNameDefault',
|
||||
});
|
||||
};
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
});
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
strategy = createStrategy();
|
||||
|
||||
const user = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(user.user?.lastName).toBe('lastNameDefault');
|
||||
@@ -376,20 +342,11 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(new WorkspaceEntity());
|
||||
workspaceStore[validWorkspaceId] = new WorkspaceEntity();
|
||||
|
||||
applicationRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('Application not found', expect.any(String), {
|
||||
@@ -418,32 +375,20 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
isImpersonating: true,
|
||||
impersonatedUserWorkspaceId: validUserWorkspaceId,
|
||||
// Missing impersonatorUserWorkspaceId
|
||||
};
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
};
|
||||
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -466,31 +411,20 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
isImpersonating: true,
|
||||
impersonatorUserWorkspaceId,
|
||||
// Missing impersonatedUserWorkspaceId
|
||||
};
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
};
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -512,34 +446,23 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
isImpersonating: true,
|
||||
impersonatorUserWorkspaceId: validUserWorkspaceId,
|
||||
impersonatedUserWorkspaceId: validUserWorkspaceId, // Same as impersonator
|
||||
};
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
impersonatedUserWorkspaceId: validUserWorkspaceId,
|
||||
};
|
||||
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userWorkspaceRepository.findOne.mockResolvedValue(mockUserWorkspace);
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userWorkspaceRepository.findOne.mockResolvedValue({
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: validUserId, lastName: 'lastNameDefault' },
|
||||
workspace: { id: validWorkspaceId },
|
||||
});
|
||||
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -572,34 +495,40 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userStore[validUserId] = mockUser;
|
||||
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
|
||||
.mockResolvedValueOnce(null) // For impersonatorUserWorkspace lookup
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
// For impersonatedUserWorkspace lookup
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: 'valid-user-id' },
|
||||
workspace: mockWorkspace,
|
||||
});
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -633,29 +562,35 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userStore[validUserId] = mockUser;
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
|
||||
.mockResolvedValueOnce(null); // For impersonatedUserWorkspace lookup
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
userWorkspaceRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
'Invalid impersonation token, cannot find impersonator or impersonated user workspace',
|
||||
@@ -684,49 +619,52 @@ describe('JwtAuthStrategy', () => {
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
mockWorkspace.allowImpersonation = false; // Disabled
|
||||
mockWorkspace.allowImpersonation = false;
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userStore[validUserId] = mockUser;
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: false }, // No server level permission
|
||||
workspace: { id: differentWorkspaceId }, // Different workspace
|
||||
};
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
const mockImpersonatedUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: 'valid-user-id' },
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
|
||||
.mockResolvedValueOnce({
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: false },
|
||||
workspace: { id: differentWorkspaceId },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: 'valid-user-id' },
|
||||
workspace: mockWorkspace,
|
||||
});
|
||||
|
||||
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
|
||||
false,
|
||||
);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -759,45 +697,48 @@ describe('JwtAuthStrategy', () => {
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userStore[validUserId] = mockUser;
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: false },
|
||||
workspace: mockWorkspace, // Same workspace
|
||||
};
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
const mockImpersonatedUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: 'valid-user-id' },
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
|
||||
.mockResolvedValueOnce({
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: false },
|
||||
workspace: mockWorkspace,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: validUserWorkspaceId,
|
||||
user: { id: 'valid-user-id' },
|
||||
workspace: mockWorkspace,
|
||||
});
|
||||
|
||||
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
|
||||
false,
|
||||
);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -821,7 +762,7 @@ describe('JwtAuthStrategy', () => {
|
||||
workspaceId: validWorkspaceId,
|
||||
isImpersonating: true,
|
||||
impersonatorUserWorkspaceId,
|
||||
impersonatedUserWorkspaceId, // Different from userWorkspaceId
|
||||
impersonatedUserWorkspaceId,
|
||||
};
|
||||
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
@@ -829,47 +770,9 @@ describe('JwtAuthStrategy', () => {
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
mockWorkspace.allowImpersonation = true;
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: true },
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
|
||||
const mockImpersonatedUserWorkspace = {
|
||||
id: impersonatedUserWorkspaceId,
|
||||
user: { id: 'valid-user-id' },
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For impersonatedUserWorkspace lookup
|
||||
|
||||
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException(
|
||||
@@ -898,43 +801,52 @@ describe('JwtAuthStrategy', () => {
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
mockWorkspace.allowImpersonation = false; // Server level disabled
|
||||
mockWorkspace.allowImpersonation = false;
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
|
||||
const mockUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userStore[validUserId] = mockUser;
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: false },
|
||||
workspace: mockWorkspace, // Same workspace
|
||||
};
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockUserWorkspace) // For the main userWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
|
||||
.mockResolvedValueOnce(mockUserWorkspace); // For impersonatedUserWorkspace lookup (same as main)
|
||||
.mockResolvedValueOnce({
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: false },
|
||||
workspace: mockWorkspace,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
});
|
||||
|
||||
permissionsService.userHasWorkspaceSettingPermission.mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
);
|
||||
strategy = createStrategy();
|
||||
|
||||
const result = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
@@ -969,40 +881,49 @@ describe('JwtAuthStrategy', () => {
|
||||
const mockWorkspace = new WorkspaceEntity();
|
||||
|
||||
mockWorkspace.id = validWorkspaceId;
|
||||
mockWorkspace.allowImpersonation = true; // Server level enabled
|
||||
mockWorkspace.allowImpersonation = true;
|
||||
|
||||
const mockUser = { id: validUserId, lastName: 'lastNameDefault' };
|
||||
|
||||
const mockImpersonatorUserWorkspace = {
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: true }, // Server level permission
|
||||
workspace: { id: differentWorkspaceId }, // Different workspace
|
||||
};
|
||||
workspaceStore[validWorkspaceId] = mockWorkspace;
|
||||
userStore[validUserId] = mockUser;
|
||||
|
||||
const mockImpersonatedUserWorkspace = {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
coreEntityCacheService.get.mockImplementation(
|
||||
async (keyName: string, entityId: string) => {
|
||||
if (keyName === 'workspaceEntity') {
|
||||
return workspaceStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
workspaceRepository.findOneBy.mockResolvedValue(mockWorkspace);
|
||||
userRepository.findOne.mockResolvedValue(mockUser);
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce(mockImpersonatorUserWorkspace) // For impersonatorUserWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatedUserWorkspace) // For impersonatedUserWorkspace lookup
|
||||
.mockResolvedValueOnce(mockImpersonatedUserWorkspace); // For access token lookup
|
||||
if (keyName === 'user') {
|
||||
return userStore[entityId] ?? null;
|
||||
}
|
||||
|
||||
strategy = new JwtAuthStrategy(
|
||||
jwtWrapperService,
|
||||
workspaceRepository,
|
||||
applicationRepository,
|
||||
userRepository,
|
||||
userWorkspaceRepository,
|
||||
apiKeyRepository,
|
||||
permissionsService,
|
||||
workspaceCacheService,
|
||||
if (keyName === 'userWorkspaceEntity') {
|
||||
return {
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
userWorkspaceRepository.findOne
|
||||
.mockResolvedValueOnce({
|
||||
id: impersonatorUserWorkspaceId,
|
||||
user: { id: 'valid-user-id', canImpersonate: true },
|
||||
workspace: { id: differentWorkspaceId },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: validUserWorkspaceId,
|
||||
user: mockUser,
|
||||
workspace: mockWorkspace,
|
||||
});
|
||||
|
||||
strategy = createStrategy();
|
||||
|
||||
const result = await strategy.validate(payload as JwtPayload);
|
||||
|
||||
expect(result.user?.lastName).toBe('lastNameDefault');
|
||||
|
||||
+37
-40
@@ -9,7 +9,6 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
AuthException,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
type AccessTokenJwtPayload,
|
||||
type ApiKeyTokenJwtPayload,
|
||||
ApplicationAccessTokenJwtPayload,
|
||||
AUTH_CONTEXT_USER_SELECT_FIELDS,
|
||||
type AuthContext,
|
||||
type AuthContextUser,
|
||||
FileTokenJwtPayloadLegacy,
|
||||
@@ -27,11 +25,10 @@ import {
|
||||
JwtTokenTypeEnum,
|
||||
type WorkspaceAgnosticTokenJwtPayload,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
@@ -39,18 +36,13 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {
|
||||
const jwtFromRequestFunction = jwtWrapperService.extractJwtFromRequest();
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
@@ -88,9 +80,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private async validateAPIKey(
|
||||
payload: ApiKeyTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload.sub,
|
||||
});
|
||||
const workspace = await this.coreEntityCacheService.get(
|
||||
'workspaceEntity',
|
||||
payload.sub,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
@@ -100,12 +93,12 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
),
|
||||
);
|
||||
|
||||
const apiKey = await this.apiKeyRepository.findOne({
|
||||
where: {
|
||||
id: payload.jti,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
const { apiKeyMap } = await this.workspaceCacheService.getOrRecompute(
|
||||
workspace.id,
|
||||
['apiKeyMap'],
|
||||
);
|
||||
|
||||
const apiKey = payload.jti ? apiKeyMap[payload.jti] : undefined;
|
||||
|
||||
if (!apiKey || apiKey.revokedAt) {
|
||||
throw new AuthException(
|
||||
@@ -114,6 +107,13 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
if (new Date(apiKey.expiresAt) < new Date()) {
|
||||
throw new AuthException(
|
||||
'This API Key is expired',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return { apiKey, workspace, workspaceMemberId: payload.workspaceMemberId };
|
||||
}
|
||||
|
||||
@@ -123,9 +123,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
let user: AuthContextUser | null = null;
|
||||
let context: AuthContext = {};
|
||||
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload.workspaceId,
|
||||
});
|
||||
const workspace = await this.coreEntityCacheService.get(
|
||||
'workspaceEntity',
|
||||
payload.workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(workspace)) {
|
||||
throw new AuthException(
|
||||
@@ -224,20 +225,18 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
expectedWorkspaceId?: string;
|
||||
}): Promise<{
|
||||
user: AuthContextUser;
|
||||
userWorkspace: UserWorkspaceEntity;
|
||||
userWorkspace: FlatUserWorkspace;
|
||||
} | null> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: params.userId },
|
||||
select: [...AUTH_CONTEXT_USER_SELECT_FIELDS],
|
||||
});
|
||||
const user = await this.coreEntityCacheService.get('user', params.userId);
|
||||
|
||||
if (!isDefined(user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: params.userWorkspaceId },
|
||||
});
|
||||
const userWorkspace = await this.coreEntityCacheService.get(
|
||||
'userWorkspaceEntity',
|
||||
params.userWorkspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(userWorkspace)) {
|
||||
return null;
|
||||
@@ -254,7 +253,6 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateImpersonation(payload: AccessTokenJwtPayload) {
|
||||
// Validate required impersonation fields
|
||||
if (
|
||||
!payload.impersonatorUserWorkspaceId ||
|
||||
!payload.impersonatedUserWorkspaceId
|
||||
@@ -282,6 +280,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
// Impersonation validation requires relations -- not cached
|
||||
const impersonatorUserWorkspace =
|
||||
await this.userWorkspaceRepository.findOne({
|
||||
where: { id: payload.impersonatorUserWorkspaceId },
|
||||
@@ -348,12 +347,9 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private async validateWorkspaceAgnosticToken(
|
||||
payload: WorkspaceAgnosticTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
select: [...AUTH_CONTEXT_USER_SELECT_FIELDS],
|
||||
});
|
||||
const user = await this.coreEntityCacheService.get('user', payload.sub);
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
assertIsDefinedOrThrow(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
@@ -364,9 +360,10 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private async validateApplicationToken(
|
||||
payload: ApplicationAccessTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload.workspaceId,
|
||||
});
|
||||
const workspace = await this.coreEntityCacheService.get(
|
||||
'workspaceEntity',
|
||||
payload.workspaceId,
|
||||
);
|
||||
|
||||
if (!isDefined(workspace)) {
|
||||
throw new AuthException(
|
||||
|
||||
+8
-3
@@ -135,7 +135,12 @@ describe('WorkspaceAgnosticToken', () => {
|
||||
userId: userId,
|
||||
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
};
|
||||
const mockUser = { id: userId };
|
||||
const mockUser = {
|
||||
id: userId,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
deletedAt: null,
|
||||
} as unknown as UserEntity;
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
|
||||
@@ -145,8 +150,8 @@ describe('WorkspaceAgnosticToken', () => {
|
||||
|
||||
const result = await service.validateToken(mockToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockUser,
|
||||
expect(result.user).toMatchObject({
|
||||
id: userId,
|
||||
});
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
|
||||
expect(jwtWrapperService.verify).toHaveBeenCalledWith(
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@@ -88,7 +89,7 @@ export class WorkspaceAgnosticTokenService {
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(user);
|
||||
|
||||
return { user };
|
||||
return { user: fromUserEntityToFlat(user) };
|
||||
} catch (error) {
|
||||
if (error instanceof AuthException) {
|
||||
throw error;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
||||
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';
|
||||
@@ -16,6 +15,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
@@ -28,13 +28,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
AppTokenEntity,
|
||||
WorkspaceEntity,
|
||||
UserWorkspaceEntity,
|
||||
ApiKeyEntity,
|
||||
ApplicationEntity,
|
||||
]),
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
PermissionsModule,
|
||||
WorkspaceCacheModule,
|
||||
CoreEntityCacheModule,
|
||||
],
|
||||
providers: [
|
||||
RenewTokenService,
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context-user.type';
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
export { AUTH_CONTEXT_USER_SELECT_FIELDS } from 'src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants';
|
||||
export { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context-user.type';
|
||||
export { type FlatAuthContextUser as AuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
|
||||
export type RawAuthContext = {
|
||||
user?: AuthContextUser | null | undefined;
|
||||
apiKey?: ApiKeyEntity | null | undefined;
|
||||
user?: FlatAuthContextUser | null | undefined;
|
||||
apiKey?: FlatApiKey | null | undefined;
|
||||
workspaceMemberId?: string;
|
||||
workspaceMember?: WorkspaceMemberWorkspaceEntity;
|
||||
workspace?: WorkspaceEntity;
|
||||
workspace?: FlatWorkspace;
|
||||
application?: ApplicationEntity | null | undefined;
|
||||
userWorkspaceId?: string;
|
||||
userWorkspace?: UserWorkspaceEntity;
|
||||
userWorkspace?: FlatUserWorkspace;
|
||||
authProvider?: AuthProviderEnum;
|
||||
impersonationContext?: {
|
||||
impersonatorUserWorkspaceId?: string;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type AUTH_CONTEXT_USER_SELECT_FIELDS } from 'src/engine/core-modules/auth/constants/auth-context-user-select-fields.constants';
|
||||
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
|
||||
|
||||
export type FlatAuthContextUser = Pick<
|
||||
FlatUser,
|
||||
(typeof AUTH_CONTEXT_USER_SELECT_FIELDS)[number]
|
||||
>;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -42,7 +41,7 @@ export type ExistingUserOrNewUser = {
|
||||
|
||||
export type ExistingUserOrPartialUserWithPicture = {
|
||||
userData:
|
||||
| { type: 'existingUser'; existingUser: AuthContextUser }
|
||||
| { type: 'existingUser'; existingUser: UserEntity }
|
||||
| {
|
||||
type: 'newUserWithPicture';
|
||||
newUserWithPicture: PartialUserWithPicture;
|
||||
|
||||
+1
@@ -3,6 +3,7 @@ export enum CacheStorageNamespace {
|
||||
ModuleCalendar = 'module:calendar',
|
||||
ModuleWorkflow = 'module:workflow',
|
||||
EngineWorkspace = 'engine:workspace',
|
||||
EngineCoreEntity = 'engine:core-entity',
|
||||
EngineLock = 'engine:lock',
|
||||
EngineHealth = 'engine:health',
|
||||
EngineMetrics = 'engine:metrics',
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ export const useGraphQLErrorHandlerHook = <
|
||||
return {
|
||||
id: req.workspace.id,
|
||||
displayName: req.workspace.displayName,
|
||||
createdAt: req.workspace.createdAt?.toISOString() ?? null,
|
||||
createdAt: req.workspace.createdAt ?? null,
|
||||
activationStatus: req.workspace.activationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
+21
-14
@@ -2,7 +2,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
LogicFunctionTriggerJob,
|
||||
LogicFunctionTriggerJobData,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { shouldRunNow } from 'src/utils/should-run-now.utils';
|
||||
|
||||
export const CRON_TRIGGER_CRON_PATTERN = '* * * * *';
|
||||
@@ -27,8 +27,7 @@ export class CronTriggerCronJob {
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@Process(CronTriggerCronJob.name)
|
||||
@@ -44,22 +43,30 @@ export class CronTriggerCronJob {
|
||||
const now = new Date();
|
||||
|
||||
for (const activeWorkspace of activeWorkspaces) {
|
||||
const logicFunctionsWithCronTrigger =
|
||||
await this.logicFunctionRepository.find({
|
||||
where: {
|
||||
workspaceId: activeWorkspace.id,
|
||||
cronTriggerSettings: Not(IsNull()),
|
||||
},
|
||||
select: ['id', 'cronTriggerSettings', 'workspaceId'],
|
||||
});
|
||||
const { flatLogicFunctionMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(activeWorkspace.id, [
|
||||
'flatLogicFunctionMaps',
|
||||
]);
|
||||
|
||||
const logicFunctions = Object.values(
|
||||
flatLogicFunctionMaps.byUniversalIdentifier,
|
||||
);
|
||||
|
||||
for (const logicFunction of logicFunctions) {
|
||||
if (!isDefined(logicFunction)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const logicFunction of logicFunctionsWithCronTrigger) {
|
||||
const cronSettings = logicFunction.cronTriggerSettings;
|
||||
|
||||
if (!isDefined(cronSettings?.pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDefined(logicFunction.deletedAt)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldRunNow(cronSettings.pattern, now)) {
|
||||
continue;
|
||||
}
|
||||
@@ -69,7 +76,7 @@ export class CronTriggerCronJob {
|
||||
[
|
||||
{
|
||||
logicFunctionId: logicFunction.id,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
workspaceId: activeWorkspace.id,
|
||||
payload: {},
|
||||
},
|
||||
],
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ import {
|
||||
SdkClientException,
|
||||
SdkClientExceptionCode,
|
||||
} from 'src/engine/core-modules/sdk-client/exceptions/sdk-client.exception';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
const SDK_CLIENT_ARCHIVE_NAME = 'twenty-client-sdk.zip';
|
||||
@@ -46,7 +46,7 @@ export class SdkClientGenerationService {
|
||||
applicationUniversalIdentifier: string;
|
||||
}): Promise<Buffer> {
|
||||
const graphqlSchema = await this.workspaceSchemaFactory.createGraphQLSchema(
|
||||
{ id: workspaceId } as WorkspaceEntity,
|
||||
{ id: workspaceId } as FlatWorkspace,
|
||||
applicationId,
|
||||
);
|
||||
|
||||
|
||||
+4
-3
@@ -5,6 +5,8 @@ import { type ToolSet } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
|
||||
import {
|
||||
@@ -28,7 +30,6 @@ import {
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
// Handler for individually registered static tools (e.g., action tools)
|
||||
@@ -303,9 +304,9 @@ export class ToolExecutorService {
|
||||
}
|
||||
|
||||
return buildUserAuthContext({
|
||||
workspace: { id: context.workspaceId } as WorkspaceEntity,
|
||||
workspace: { id: context.workspaceId } as FlatWorkspace,
|
||||
userWorkspaceId: context.userWorkspaceId,
|
||||
user,
|
||||
user: fromUserEntityToFlat(user),
|
||||
workspaceMemberId,
|
||||
workspaceMember,
|
||||
});
|
||||
|
||||
+19
-3
@@ -7,6 +7,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { type FlatAuthContextUser } from 'src/engine/core-modules/auth/types/flat-auth-context-user.type';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@@ -53,10 +54,23 @@ describe('TwoFactorAuthenticationResolver', () => {
|
||||
>;
|
||||
let repository: ReturnType<typeof createMockRepository>;
|
||||
|
||||
const mockUser: UserEntity = {
|
||||
const MOCK_USER_ISO = '2024-01-01T00:00:00.000Z';
|
||||
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: 'test@example.com',
|
||||
} as UserEntity;
|
||||
defaultAvatarUrl: '',
|
||||
isEmailVerified: true,
|
||||
disabled: false,
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: false,
|
||||
createdAt: MOCK_USER_ISO,
|
||||
updatedAt: MOCK_USER_ISO,
|
||||
deletedAt: null,
|
||||
locale: 'en',
|
||||
} as unknown as FlatAuthContextUser;
|
||||
|
||||
const mockWorkspace: WorkspaceEntity = {
|
||||
id: 'workspace-123',
|
||||
@@ -132,7 +146,9 @@ describe('TwoFactorAuthenticationResolver', () => {
|
||||
workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace.mockResolvedValue(
|
||||
mockWorkspace,
|
||||
);
|
||||
userService.findUserByEmailOrThrow.mockResolvedValue(mockUser);
|
||||
userService.findUserByEmailOrThrow.mockResolvedValue(
|
||||
mockUser as unknown as UserEntity,
|
||||
);
|
||||
twoFactorAuthenticationService.initiateStrategyConfiguration.mockResolvedValue(
|
||||
'otpauth://totp/Twenty:test@example.com?secret=SECRETKEY&issuer=Twenty',
|
||||
);
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
export const USER_WORKSPACE_ENTITY_NON_CACHED_PROPERTIES = [
|
||||
'workspace',
|
||||
'user',
|
||||
'twoFactorAuthenticationMethods',
|
||||
'permissionFlags',
|
||||
'objectPermissions',
|
||||
'objectsPermissions',
|
||||
'twoFactorAuthenticationMethodSummary',
|
||||
] as const satisfies ReadonlyArray<keyof UserWorkspaceEntity>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
|
||||
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
import { fromUserWorkspaceEntityToFlat } from 'src/engine/core-modules/user-workspace/utils/from-user-workspace-entity-to-flat.util';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
@CoreEntityCache('userWorkspaceEntity')
|
||||
export class UserWorkspaceEntityCacheProviderService extends CoreEntityCacheProvider<FlatUserWorkspace> {
|
||||
constructor(
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(entityId: string): Promise<FlatUserWorkspace | null> {
|
||||
const entity = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: entityId },
|
||||
});
|
||||
|
||||
if (entity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fromUserWorkspaceEntityToFlat(entity);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
|
||||
import { type USER_WORKSPACE_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/user-workspace/constants/user-workspace-entity-non-cached-properties.constant';
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
|
||||
type UserWorkspaceEntityNonCachedProperties =
|
||||
(typeof USER_WORKSPACE_ENTITY_NON_CACHED_PROPERTIES)[number];
|
||||
|
||||
type UserWorkspaceCachedFields = Omit<
|
||||
UserWorkspaceEntity,
|
||||
UserWorkspaceEntityNonCachedProperties
|
||||
>;
|
||||
|
||||
export type FlatUserWorkspace = Omit<
|
||||
UserWorkspaceCachedFields,
|
||||
keyof CastRecordTypeOrmDatePropertiesToString<UserWorkspaceCachedFields>
|
||||
> &
|
||||
CastRecordTypeOrmDatePropertiesToString<UserWorkspaceCachedFields>;
|
||||
+6
-1
@@ -12,6 +12,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { UploadProfilePicturePermissionGuard } from 'src/engine/core-modules/user-workspace/guards/upload-profile-picture-permission.guard';
|
||||
import { UserWorkspaceEntityCacheProviderService } from 'src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
@@ -57,6 +58,10 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
|
||||
}),
|
||||
],
|
||||
exports: [UserWorkspaceService],
|
||||
providers: [UserWorkspaceService, UploadProfilePicturePermissionGuard],
|
||||
providers: [
|
||||
UserWorkspaceService,
|
||||
UserWorkspaceEntityCacheProviderService,
|
||||
UploadProfilePicturePermissionGuard,
|
||||
],
|
||||
})
|
||||
export class UserWorkspaceModule {}
|
||||
|
||||
+22
-5
@@ -6,6 +6,7 @@ import { type DataSource, type Repository } from 'typeorm';
|
||||
import { type ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
@@ -249,7 +250,14 @@ describe('UserWorkspaceService', () => {
|
||||
lastName: 'Doe',
|
||||
defaultAvatarUrl: 'avatar-url',
|
||||
locale: 'en',
|
||||
} as UserEntity;
|
||||
isEmailVerified: false,
|
||||
disabled: false,
|
||||
canImpersonate: false,
|
||||
canAccessFullAdminPanel: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
} as unknown as AuthContextUser;
|
||||
const mainDataSource = {
|
||||
query: jest.fn(),
|
||||
} as unknown as DataSource;
|
||||
@@ -300,7 +308,10 @@ describe('UserWorkspaceService', () => {
|
||||
const user = {
|
||||
id: 'user-id',
|
||||
email: 'test@example.com',
|
||||
} as UserEntity;
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
deletedAt: null,
|
||||
} as unknown as UserEntity;
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
defaultRoleId: 'default-role-id',
|
||||
@@ -335,7 +346,7 @@ describe('UserWorkspaceService', () => {
|
||||
});
|
||||
expect(service.createWorkspaceMember).toHaveBeenCalledWith(
|
||||
workspace.id,
|
||||
user,
|
||||
expect.objectContaining({ id: user.id, email: user.email }),
|
||||
);
|
||||
expect(
|
||||
userRoleService.assignRoleToManyUserWorkspace,
|
||||
@@ -361,7 +372,10 @@ describe('UserWorkspaceService', () => {
|
||||
const user = {
|
||||
id: 'user-id',
|
||||
email: 'test@example.com',
|
||||
} as UserEntity;
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
deletedAt: null,
|
||||
} as unknown as UserEntity;
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
defaultRoleId: 'default-role-id',
|
||||
@@ -392,7 +406,10 @@ describe('UserWorkspaceService', () => {
|
||||
const user = {
|
||||
id: 'user-id',
|
||||
email: 'test@example.com',
|
||||
} as UserEntity;
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
deletedAt: null,
|
||||
} as unknown as UserEntity;
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
defaultRoleId: undefined,
|
||||
|
||||
+8
-3
@@ -23,7 +23,6 @@ import { extractFileIdFromUrl } from 'src/engine/core-modules/file/files-field/u
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
@@ -103,7 +102,13 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
: this.userWorkspaceRepository.save(userWorkspace);
|
||||
}
|
||||
|
||||
async createWorkspaceMember(workspaceId: string, user: AuthContextUser) {
|
||||
async createWorkspaceMember(
|
||||
workspaceId: string,
|
||||
user: Pick<
|
||||
UserEntity,
|
||||
'id' | 'firstName' | 'lastName' | 'email' | 'locale'
|
||||
>,
|
||||
) {
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
@@ -536,7 +541,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
|
||||
appToken?: AppTokenEntity;
|
||||
}>;
|
||||
},
|
||||
user: AuthContextUser,
|
||||
user: Pick<UserEntity, 'email'>,
|
||||
authProvider: AuthProviderEnum,
|
||||
) {
|
||||
return {
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type FlatUserWorkspace } from 'src/engine/core-modules/user-workspace/types/flat-user-workspace.type';
|
||||
|
||||
export const fromUserWorkspaceEntityToFlat = (
|
||||
entity: UserWorkspaceEntity,
|
||||
): FlatUserWorkspace => ({
|
||||
id: entity.id,
|
||||
workspaceId: entity.workspaceId,
|
||||
userId: entity.userId,
|
||||
defaultAvatarUrl: entity.defaultAvatarUrl,
|
||||
locale: entity.locale,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
deletedAt: entity.deletedAt?.toISOString() ?? null,
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
export const USER_ENTITY_NON_CACHED_PROPERTIES = [
|
||||
'formatEmail',
|
||||
'passwordHash',
|
||||
'appTokens',
|
||||
'keyValuePairs',
|
||||
'workspaceMember',
|
||||
'userWorkspaces',
|
||||
'onboardingStatus',
|
||||
'currentWorkspace',
|
||||
'currentUserWorkspace',
|
||||
] as const satisfies ReadonlyArray<keyof UserEntity>;
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
|
||||
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
|
||||
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
@Injectable()
|
||||
@CoreEntityCache('user')
|
||||
export class UserEntityCacheProviderService extends CoreEntityCacheProvider<FlatUser> {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(entityId: string): Promise<FlatUser | null> {
|
||||
const entity = await this.userRepository.findOne({
|
||||
where: { id: entityId },
|
||||
});
|
||||
|
||||
if (entity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fromUserEntityToFlat(entity);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { type Repository, type UpdateResult } from 'typeorm';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
PermissionsException,
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
@@ -95,6 +97,12 @@ describe('UserService', () => {
|
||||
provide: ApplicationService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: CoreEntityCacheService,
|
||||
useValue: {
|
||||
invalidate: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -114,7 +122,7 @@ describe('UserService', () => {
|
||||
// isWorkspaceActiveOrSuspendedSpy.mockReturnValue(false);
|
||||
|
||||
const res = await service.loadWorkspaceMember(
|
||||
{ id: 'u1' } as UserEntity,
|
||||
{ id: 'u1' } as Pick<AuthContextUser, 'id'>,
|
||||
{ id: 'w1' } as WorkspaceEntity,
|
||||
);
|
||||
|
||||
@@ -133,7 +141,7 @@ describe('UserService', () => {
|
||||
.mockResolvedValue(mockWorkspaceMemberRepo);
|
||||
|
||||
const res = await service.loadWorkspaceMember(
|
||||
{ id: 'u1' } as UserEntity,
|
||||
{ id: 'u1' } as Pick<AuthContextUser, 'id'>,
|
||||
{
|
||||
id: 'w1',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.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';
|
||||
@@ -55,11 +56,15 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
@InjectMessageQueue(MessageQueue.workspaceQueue)
|
||||
private readonly workspaceQueueService: MessageQueueService,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {
|
||||
super(userRepository);
|
||||
}
|
||||
|
||||
async loadWorkspaceMember(user: AuthContextUser, workspace: WorkspaceEntity) {
|
||||
async loadWorkspaceMember(
|
||||
user: Pick<AuthContextUser, 'id'>,
|
||||
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
|
||||
) {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace)) {
|
||||
return null;
|
||||
}
|
||||
@@ -85,7 +90,10 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
);
|
||||
}
|
||||
|
||||
async loadWorkspaceMembers(workspace: WorkspaceEntity, withDeleted = false) {
|
||||
async loadWorkspaceMembers(
|
||||
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
|
||||
withDeleted = false,
|
||||
) {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace)) {
|
||||
return [];
|
||||
}
|
||||
@@ -109,7 +117,9 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
);
|
||||
}
|
||||
|
||||
async loadDeletedWorkspaceMembersOnly(workspace: WorkspaceEntity) {
|
||||
async loadDeletedWorkspaceMembersOnly(
|
||||
workspace: Pick<WorkspaceEntity, 'id' | 'activationStatus'>,
|
||||
) {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace)) {
|
||||
return [];
|
||||
}
|
||||
@@ -151,6 +161,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
}
|
||||
|
||||
await this.userRepository.softDelete({ id: userId });
|
||||
await this.coreEntityCacheService.invalidate('user', userId);
|
||||
|
||||
return await this.userRepository.findOne({
|
||||
where: {
|
||||
@@ -190,6 +201,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
|
||||
if (user.userWorkspaces.length === 1) {
|
||||
await this.userRepository.softDelete(userId);
|
||||
await this.coreEntityCacheService.invalidate('user', userId);
|
||||
}
|
||||
|
||||
return userWorkspace;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
|
||||
import { type USER_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/user/constants/user-entity-non-cached-properties.constant';
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
type UserEntityNonCachedProperties =
|
||||
(typeof USER_ENTITY_NON_CACHED_PROPERTIES)[number];
|
||||
|
||||
type UserCachedFields = Omit<UserEntity, UserEntityNonCachedProperties>;
|
||||
|
||||
export type FlatUser = Omit<
|
||||
UserCachedFields,
|
||||
keyof CastRecordTypeOrmDatePropertiesToString<UserCachedFields>
|
||||
> &
|
||||
CastRecordTypeOrmDatePropertiesToString<UserCachedFields>;
|
||||
@@ -15,6 +15,7 @@ import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.
|
||||
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 { GlobalWorkspaceMemberListener } from 'src/engine/core-modules/user/services/global-workspace-member.listener';
|
||||
import { UserEntityCacheProviderService } from 'src/engine/core-modules/user/services/user-entity-cache-provider.service';
|
||||
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';
|
||||
@@ -25,6 +26,7 @@ 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 { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
import { userAutoResolverOpts } from './user.auto-resolver-opts';
|
||||
@@ -55,11 +57,13 @@ import { UserService } from './services/user.service';
|
||||
EmailVerificationModule,
|
||||
WorkspaceDomainsModule,
|
||||
WorkspaceCacheModule,
|
||||
CoreEntityCacheModule,
|
||||
],
|
||||
exports: [UserService, WorkspaceMemberTranspiler],
|
||||
providers: [
|
||||
UserService,
|
||||
UserResolver,
|
||||
UserEntityCacheProviderService,
|
||||
WorkspaceMemberTranspiler,
|
||||
WorkspaceFlatWorkspaceMemberMapCacheService,
|
||||
GlobalWorkspaceMemberListener,
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { type FlatUser } from 'src/engine/core-modules/user/types/flat-user.type';
|
||||
|
||||
export const fromUserEntityToFlat = (entity: UserEntity): FlatUser => ({
|
||||
id: entity.id,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
email: entity.email,
|
||||
defaultAvatarUrl: entity.defaultAvatarUrl,
|
||||
isEmailVerified: entity.isEmailVerified,
|
||||
disabled: entity.disabled,
|
||||
canImpersonate: entity.canImpersonate,
|
||||
canAccessFullAdminPanel: entity.canAccessFullAdminPanel,
|
||||
locale: entity.locale,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
deletedAt: entity.deletedAt?.toISOString(),
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export const WORKSPACE_ENTITY_NON_CACHED_PROPERTIES = [
|
||||
'logoFile',
|
||||
'appTokens',
|
||||
'keyValuePairs',
|
||||
'workspaceUsers',
|
||||
'featureFlags',
|
||||
'approvedAccessDomains',
|
||||
'emailingDomains',
|
||||
'publicDomains',
|
||||
'workspaceMembersCount',
|
||||
'allPostgresCredentials',
|
||||
'workspaceSSOIdentityProviders',
|
||||
'agents',
|
||||
'webhooks',
|
||||
'apiKeys',
|
||||
'views',
|
||||
'viewFields',
|
||||
'viewFilters',
|
||||
'viewFilterGroups',
|
||||
'viewGroups',
|
||||
'viewSorts',
|
||||
'defaultRole',
|
||||
'workspaceCustomApplication',
|
||||
'applications',
|
||||
] as const satisfies ReadonlyArray<keyof WorkspaceEntity>;
|
||||
+7
@@ -31,6 +31,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
|
||||
import { PrefillLogicFunctionService } from 'src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-logic-function.service';
|
||||
@@ -158,6 +159,12 @@ describe('WorkspaceService', () => {
|
||||
add: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CoreEntityCacheService,
|
||||
useValue: {
|
||||
invalidate: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useValue: {
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
|
||||
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
@CoreEntityCache('workspaceEntity')
|
||||
export class WorkspaceEntityCacheProviderService extends CoreEntityCacheProvider<FlatWorkspace> {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(entityId: string): Promise<FlatWorkspace | null> {
|
||||
const entity = await this.workspaceRepository.findOneBy({ id: entityId });
|
||||
|
||||
if (entity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fromWorkspaceEntityToFlat(entity);
|
||||
}
|
||||
}
|
||||
+24
@@ -38,6 +38,7 @@ import {
|
||||
WorkspaceExceptionCode,
|
||||
WorkspaceNotFoundDefaultError,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { isModelAllowedByWorkspace } from 'src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
@@ -124,6 +125,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
@InjectDataSource()
|
||||
private readonly coreDataSource: DataSource,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {
|
||||
super(workspaceRepository);
|
||||
}
|
||||
@@ -290,6 +292,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'workspaceEntity',
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (payload.logo === null && isDefined(workspace.logoFileId)) {
|
||||
await this.fileCorePictureService.deleteCorePicture({
|
||||
fileId: workspace.logoFileId,
|
||||
@@ -325,6 +332,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
activationStatus: WorkspaceActivationStatus.ONGOING_CREATION,
|
||||
});
|
||||
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'workspaceEntity',
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
DEFAULT_FEATURE_FLAGS,
|
||||
workspace.id,
|
||||
@@ -350,6 +362,11 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
version: extractVersionMajorMinorPatch(appVersion),
|
||||
});
|
||||
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'workspaceEntity',
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return await this.workspaceRepository.findOneBy({
|
||||
id: workspace.id,
|
||||
});
|
||||
@@ -387,6 +404,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
|
||||
if (softDelete) {
|
||||
await this.workspaceRepository.softDelete({ id });
|
||||
await this.coreEntityCacheService.invalidate('workspaceEntity', id);
|
||||
|
||||
this.logger.log(`workspace ${id} soft deleted`);
|
||||
|
||||
@@ -418,6 +436,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
}
|
||||
|
||||
await this.workspaceRepository.delete(id);
|
||||
await this.coreEntityCacheService.invalidate('workspaceEntity', id);
|
||||
|
||||
this.logger.log(`workspace ${id} hard deleted`);
|
||||
|
||||
@@ -571,6 +590,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
userWorkspaceId: userWorkspaceOfRemovedWorkspaceMember.id,
|
||||
softDelete,
|
||||
});
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'userWorkspaceEntity',
|
||||
userWorkspaceOfRemovedWorkspaceMember.id,
|
||||
);
|
||||
}
|
||||
|
||||
const hasOtherUserWorkspaces = isDefined(
|
||||
@@ -581,6 +604,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
|
||||
if (!hasOtherUserWorkspaces) {
|
||||
await this.userRepository.softDelete(userId);
|
||||
await this.coreEntityCacheService.invalidate('user', userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type CastRecordTypeOrmDatePropertiesToString } from 'src/engine/metadata-modules/flat-entity/types/cast-record-typeorm-date-properties-to-string.type';
|
||||
import { type WORKSPACE_ENTITY_NON_CACHED_PROPERTIES } from 'src/engine/core-modules/workspace/constants/workspace-entity-non-cached-properties.constant';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
type WorkspaceEntityNonCachedProperties =
|
||||
(typeof WORKSPACE_ENTITY_NON_CACHED_PROPERTIES)[number];
|
||||
|
||||
type WorkspaceCachedFields = Omit<
|
||||
WorkspaceEntity,
|
||||
WorkspaceEntityNonCachedProperties
|
||||
>;
|
||||
|
||||
export type FlatWorkspace = Omit<
|
||||
WorkspaceCachedFields,
|
||||
keyof CastRecordTypeOrmDatePropertiesToString<WorkspaceCachedFields>
|
||||
> &
|
||||
CastRecordTypeOrmDatePropertiesToString<WorkspaceCachedFields>;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
export const fromWorkspaceEntityToFlat = (
|
||||
entity: WorkspaceEntity,
|
||||
): FlatWorkspace => ({
|
||||
id: entity.id,
|
||||
displayName: entity.displayName,
|
||||
logo: entity.logo,
|
||||
logoFileId: entity.logoFileId,
|
||||
inviteHash: entity.inviteHash,
|
||||
allowImpersonation: entity.allowImpersonation,
|
||||
isPublicInviteLinkEnabled: entity.isPublicInviteLinkEnabled,
|
||||
trashRetentionDays: entity.trashRetentionDays,
|
||||
eventLogRetentionDays: entity.eventLogRetentionDays,
|
||||
activationStatus: entity.activationStatus,
|
||||
metadataVersion: entity.metadataVersion,
|
||||
databaseSchema: entity.databaseSchema,
|
||||
subdomain: entity.subdomain,
|
||||
customDomain: entity.customDomain,
|
||||
isGoogleAuthEnabled: entity.isGoogleAuthEnabled,
|
||||
isGoogleAuthBypassEnabled: entity.isGoogleAuthBypassEnabled,
|
||||
isTwoFactorAuthenticationEnforced: entity.isTwoFactorAuthenticationEnforced,
|
||||
isPasswordAuthEnabled: entity.isPasswordAuthEnabled,
|
||||
isPasswordAuthBypassEnabled: entity.isPasswordAuthBypassEnabled,
|
||||
isMicrosoftAuthEnabled: entity.isMicrosoftAuthEnabled,
|
||||
isMicrosoftAuthBypassEnabled: entity.isMicrosoftAuthBypassEnabled,
|
||||
isCustomDomainEnabled: entity.isCustomDomainEnabled,
|
||||
editableProfileFields: entity.editableProfileFields,
|
||||
defaultRoleId: entity.defaultRoleId,
|
||||
version: entity.version,
|
||||
fastModel: entity.fastModel,
|
||||
smartModel: entity.smartModel,
|
||||
aiAdditionalInstructions: entity.aiAdditionalInstructions,
|
||||
enabledAiModelIds: entity.enabledAiModelIds,
|
||||
useRecommendedModels: entity.useRecommendedModels,
|
||||
workspaceCustomApplicationId: entity.workspaceCustomApplicationId,
|
||||
routerModel: entity.routerModel,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
deletedAt: entity.deletedAt?.toISOString(),
|
||||
suspendedAt: entity.suspendedAt?.toISOString() ?? null,
|
||||
});
|
||||
@@ -25,6 +25,8 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
|
||||
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { WorkspaceEntityCacheProviderService } from 'src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service';
|
||||
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
|
||||
import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspace-gauge.service';
|
||||
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
|
||||
@@ -82,6 +84,7 @@ import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/stand
|
||||
ApplicationModule,
|
||||
EnterpriseModule,
|
||||
StandardObjectsPrefillModule,
|
||||
CoreEntityCacheModule,
|
||||
],
|
||||
services: [WorkspaceService],
|
||||
resolvers: workspaceAutoResolverOpts,
|
||||
@@ -92,6 +95,7 @@ import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/stand
|
||||
WorkspaceResolver,
|
||||
WorkspaceService,
|
||||
WorkspaceGaugeService,
|
||||
WorkspaceEntityCacheProviderService,
|
||||
BillingDisabledGuard,
|
||||
CheckCustomDomainValidRecordsCronCommand,
|
||||
CheckCustomDomainValidRecordsCronJob,
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildSystemAuthContext as buildSystemAuthContextUtil } from 'src/engine/core-modules/auth/utils/build-system-auth-context.util';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
|
||||
// Builds a minimal WorkspaceAuthContext for system operations (jobs, commands, crons)
|
||||
// that don't have a user context but need to operate on a workspace
|
||||
@@ -8,6 +8,6 @@ export const buildSystemAuthContext = (
|
||||
workspaceId: string,
|
||||
): SystemWorkspaceAuthContext => {
|
||||
return buildSystemAuthContextUtil({
|
||||
workspace: { id: workspaceId } as WorkspaceEntity,
|
||||
workspace: { id: workspaceId } as FlatWorkspace,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { type EntityMetadata } from 'typeorm';
|
||||
|
||||
import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { type FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
|
||||
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
|
||||
@@ -52,6 +53,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
|
||||
flatFrontComponentMaps: 'flat-maps:front-component',
|
||||
flatWebhookMaps: 'flat-maps:webhook',
|
||||
flatWorkspaceMemberMaps: 'flat-maps:workspace-member',
|
||||
apiKeyMap: 'cache:api-key-map',
|
||||
applicationVariableMaps: 'cache:application-variable',
|
||||
graphQLResolverNameMap: 'direct-execution:graphql-resolver-name-map',
|
||||
} as const satisfies Record<WorkspaceCacheKeyName, string>;
|
||||
@@ -61,6 +63,7 @@ export type AdditionalCacheDataMaps = {
|
||||
rolesPermissions: ObjectsPermissionsByRoleId;
|
||||
userWorkspaceRoleMap: UserWorkspaceRoleMap;
|
||||
apiKeyRoleMap: Record<string, string>;
|
||||
apiKeyMap: Record<string, FlatApiKey>;
|
||||
flatApplicationMaps: FlatApplicationCacheMaps;
|
||||
ORMEntityMetadatas: EntityMetadata[];
|
||||
flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps;
|
||||
|
||||
+3
-1
@@ -29,7 +29,9 @@ export const seedApiKeys = async ({
|
||||
{
|
||||
id: API_KEY_DATA_SEED_IDS.ID_1,
|
||||
name: 'My api key',
|
||||
expiresAt: '2025-12-31T23:59:59.000Z',
|
||||
expiresAt: new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24 * 365 * 100,
|
||||
).toISOString(),
|
||||
workspaceId: workspaceId,
|
||||
},
|
||||
])
|
||||
|
||||
+5
-3
@@ -7,6 +7,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { buildApplicationAuthContext } from 'src/engine/core-modules/auth/utils/build-application-auth-context.util';
|
||||
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
|
||||
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
|
||||
import { fromWorkspaceEntityToFlat } from 'src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
@@ -72,9 +74,9 @@ export class WorkflowExecutionContextService {
|
||||
});
|
||||
|
||||
const authContext: WorkspaceAuthContext = buildUserAuthContext({
|
||||
workspace: userWorkspace.workspace,
|
||||
workspace: fromWorkspaceEntityToFlat(userWorkspace.workspace),
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
user: userWorkspace.user,
|
||||
user: fromUserEntityToFlat(userWorkspace.user),
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
workspaceMember,
|
||||
});
|
||||
@@ -115,7 +117,7 @@ export class WorkflowExecutionContextService {
|
||||
: { shouldBypassPermissionChecks: true as const };
|
||||
|
||||
const authContext: WorkspaceAuthContext = buildApplicationAuthContext({
|
||||
workspace,
|
||||
workspace: fromWorkspaceEntityToFlat(workspace),
|
||||
application: {
|
||||
...application,
|
||||
defaultRoleId: roleId,
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import {
|
||||
PermissionsException,
|
||||
@@ -30,7 +30,7 @@ export class WorkspaceMemberPreQueryHookService {
|
||||
workspaceMemberId?: string;
|
||||
targettedWorkspaceMemberId?: string;
|
||||
workspaceId: string;
|
||||
apiKey?: ApiKeyEntity | null;
|
||||
apiKey?: FlatApiKey | null;
|
||||
}) {
|
||||
if (isDefined(apiKey)) {
|
||||
return;
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@@ -35,6 +36,7 @@ import { WorkspaceMemberUpdateOnePreQueryHook } from 'src/modules/workspace-memb
|
||||
WorkspaceMemberUpdateManyPreQueryHook,
|
||||
],
|
||||
imports: [
|
||||
CoreEntityCacheModule,
|
||||
FeatureFlagModule,
|
||||
OnboardingModule,
|
||||
PermissionsModule,
|
||||
|
||||
+7
@@ -8,6 +8,7 @@ import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/works
|
||||
import { type UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
|
||||
|
||||
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
@@ -27,6 +28,7 @@ export class WorkspaceMemberUpdateOnePreQueryHook
|
||||
private readonly workspaceMemberPreQueryHookService: WorkspaceMemberPreQueryHookService,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
@@ -73,6 +75,11 @@ export class WorkspaceMemberUpdateOnePreQueryHook
|
||||
...userWorkspace,
|
||||
locale: payload.data.locale,
|
||||
});
|
||||
|
||||
await this.coreEntityCacheService.invalidate(
|
||||
'userWorkspaceEntity',
|
||||
authContext.userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
await this.workspaceMemberPreQueryHookService.completeOnboardingProfileStepIfNameProvided(
|
||||
|
||||
Reference in New Issue
Block a user