From c407341912ac0d65b3cbe4df40fd8448c7c0ac2f Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sat, 28 Mar 2026 22:53:34 +0100 Subject: [PATCH] feat: optimize hot database queries with multi-layer caching (#19068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- packages/twenty-server/@types/express.d.ts | 17 +- .../data-arg-processor.service.spec.ts | 4 +- .../graphql-config/graphql-config.service.ts | 10 +- .../interfaces/graphql-context.interface.ts | 8 +- .../api/graphql/workspace-schema.factory.ts | 4 +- .../__tests__/mcp-core.controller.spec.ts | 8 +- .../mcp/controllers/mcp-core.controller.ts | 8 +- .../__tests__/mcp-protocol.service.spec.ts | 8 +- .../api/mcp/services/mcp-protocol.service.ts | 12 +- .../core-entity-cache.module.ts | 12 + .../decorators/core-entity-cache.decorator.ts | 13 + .../exceptions/core-entity-cache.exception.ts | 41 + .../core-entity-cache-provider.service.ts | 15 + .../services/core-entity-cache.service.ts | 332 ++++++++ .../types/core-entity-cache-key.type.ts | 17 + .../core-entity-local-cache-entry.type.ts | 10 + .../build-created-by-from-api-key.util.ts | 4 +- .../core-modules/api-key/api-key.module.ts | 2 + ...y-entity-non-cached-properties.constant.ts | 5 + .../__tests__/api-key.service.spec.ts | 7 + .../api-key/services/api-key.service.ts | 11 + .../workspace-api-key-map-cache.service.ts | 41 + .../api-key/types/flat-api-key.type.ts | 14 + .../utils/from-api-key-entity-to-flat.util.ts | 12 + .../engine/core-modules/auth/auth.module.ts | 2 + .../engine/core-modules/auth/auth.resolver.ts | 4 +- .../auth/services/sign-in-up.service.ts | 9 +- .../auth/strategies/jwt.auth.strategy.spec.ts | 735 ++++++++---------- .../auth/strategies/jwt.auth.strategy.ts | 77 +- .../workspace-agnostic-token.service.spec.ts | 11 +- .../workspace-agnostic-token.service.ts | 3 +- .../core-modules/auth/token/token.module.ts | 4 +- .../auth/types/auth-context.type.ts | 18 +- .../auth/types/flat-auth-context-user.type.ts | 7 + .../core-modules/auth/types/signInUp.type.ts | 3 +- .../types/cache-storage-namespace.enum.ts | 1 + .../hooks/use-graphql-error-handler.hook.ts | 2 +- .../triggers/cron/cron-trigger.cron.job.ts | 35 +- .../sdk-client-generation.service.ts | 4 +- .../services/tool-executor.service.ts | 7 +- ...two-factor-authentication.resolver.spec.ts | 22 +- ...e-entity-non-cached-properties.constant.ts | 11 + ...workspace-entity-cache-provider.service.ts | 33 + .../types/flat-user-workspace.type.ts | 17 + .../user-workspace/user-workspace.module.ts | 7 +- .../user-workspace.service.spec.ts | 27 +- .../user-workspace/user-workspace.service.ts | 11 +- ...from-user-workspace-entity-to-flat.util.ts | 15 + ...r-entity-non-cached-properties.constant.ts | 13 + .../user-entity-cache-provider.service.ts | 33 + .../user/services/user.service.spec.ts | 12 +- .../user/services/user.service.ts | 18 +- .../core-modules/user/types/flat-user.type.ts | 14 + .../engine/core-modules/user/user.module.ts | 4 + .../utils/from-user-entity-to-flat.util.ts | 18 + ...e-entity-non-cached-properties.constant.ts | 27 + .../__tests__/workspace.service.spec.ts | 7 + ...workspace-entity-cache-provider.service.ts | 31 + .../workspace/services/workspace.service.ts | 24 + .../workspace/types/flat-workspace.type.ts | 17 + .../from-workspace-entity-to-flat.util.ts | 43 + .../workspace/workspace.module.ts | 4 + .../utils/build-system-auth-context.util.ts | 4 +- .../types/workspace-cache-key.type.ts | 3 + .../core/utils/seed-api-keys.util.ts | 4 +- .../workflow-execution-context.service.ts | 8 +- ...workspace-member-pre-query-hook.service.ts | 4 +- .../workspace-member-query-hook.module.ts | 2 + ...kspace-member-update-one.pre-query.hook.ts | 7 + 69 files changed, 1423 insertions(+), 554 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-entity-cache/core-entity-cache.module.ts create mode 100644 packages/twenty-server/src/engine/core-entity-cache/decorators/core-entity-cache.decorator.ts create mode 100644 packages/twenty-server/src/engine/core-entity-cache/exceptions/core-entity-cache.exception.ts create mode 100644 packages/twenty-server/src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts create mode 100644 packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts create mode 100644 packages/twenty-server/src/engine/core-entity-cache/types/core-entity-local-cache-entry.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/api-key/types/flat-api-key.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/types/flat-auth-context-user.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-workspace/constants/user-workspace-entity-non-cached-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-workspace/types/flat-user-workspace.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-workspace/utils/from-user-workspace-entity-to-flat.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/constants/user-entity-non-cached-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/types/flat-user.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user/utils/from-user-entity-to-flat.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/constants/workspace-entity-non-cached-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/types/flat-workspace.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util.ts diff --git a/packages/twenty-server/@types/express.d.ts b/packages/twenty-server/@types/express.d.ts index efd06711f31..0e8b17ea311 100644 --- a/packages/twenty-server/@types/express.d.ts +++ b/packages/twenty-server/@types/express.d.ts @@ -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; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts index 6606924ae83..6056102fcef 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/__tests__/data-arg-processor.service.spec.ts @@ -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 = ( diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts index 6a9b1a428be..3922b9a0037 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/graphql-config.service.ts @@ -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>> { // Create a new contextId for each request diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface.ts b/packages/twenty-server/src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface.ts index a460c826467..d0941d998b6 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-config/interfaces/graphql-context.interface.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts index 6cfcd2f2bd2..985f6d62148 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts @@ -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 { const isDataSourceMigrated = await this.featureFlagService.isFeatureEnabled( diff --git a/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts b/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts index e216d9281d5..5b986af6bd9 100644 --- a/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts @@ -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; diff --git a/packages/twenty-server/src/engine/api/mcp/controllers/mcp-core.controller.ts b/packages/twenty-server/src/engine/api/mcp/controllers/mcp-core.controller.ts index 48602b33361..e452ec6f5e4 100644 --- a/packages/twenty-server/src/engine/api/mcp/controllers/mcp-core.controller.ts +++ b/packages/twenty-server/src/engine/api/mcp/controllers/mcp-core.controller.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts index dd235901bda..46266b33cfc 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts @@ -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; let apiKeyRoleService: jest.Mocked; - 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, diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts index aba1a312781..ffcab66eac6 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts @@ -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 | null> { try { diff --git a/packages/twenty-server/src/engine/core-entity-cache/core-entity-cache.module.ts b/packages/twenty-server/src/engine/core-entity-cache/core-entity-cache.module.ts new file mode 100644 index 00000000000..6ea0d21923a --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/core-entity-cache.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-entity-cache/decorators/core-entity-cache.decorator.ts b/packages/twenty-server/src/engine/core-entity-cache/decorators/core-entity-cache.decorator.ts new file mode 100644 index 00000000000..1f87d6fbe39 --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/decorators/core-entity-cache.decorator.ts @@ -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); + }; +}; diff --git a/packages/twenty-server/src/engine/core-entity-cache/exceptions/core-entity-cache.exception.ts b/packages/twenty-server/src/engine/core-entity-cache/exceptions/core-entity-cache.exception.ts new file mode 100644 index 00000000000..91139034a07 --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/exceptions/core-entity-cache.exception.ts @@ -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), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service.ts b/packages/twenty-server/src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service.ts new file mode 100644 index 00000000000..6faa17aff81 --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts b/packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts new file mode 100644 index 00000000000..fef15e88dfb --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/services/core-entity-cache.service.ts @@ -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 + >(); + private readonly coreEntityCacheProviders = new Map< + CoreEntityCacheKeyName, + CoreEntityCacheProvider + >(); + private readonly memoizer = new PromiseMemoizer( + 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( + CORE_ENTITY_CACHE_KEY, + instance.constructor, + ); + + if ( + isDefined(coreEntityCacheKeyName) && + instance instanceof CoreEntityCacheProvider + ) { + this.coreEntityCacheProviders.set(coreEntityCacheKeyName, instance); + } + } + } + + public async get( + cacheKeyName: K, + entityId: string, + ): Promise { + 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(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 { + 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 { + 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 { + 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([ + { 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 { + 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, + ): 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 { + 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}`; + } +} diff --git a/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts new file mode 100644 index 00000000000..991c459f39d --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-cache-key.type.ts @@ -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 = { + workspaceEntity: 'core-entity:workspace', + user: 'core-entity:user', + userWorkspaceEntity: 'core-entity:user-workspace', +}; diff --git a/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-local-cache-entry.type.ts b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-local-cache-entry.type.ts new file mode 100644 index 00000000000..a2d21b983b0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-entity-cache/types/core-entity-local-cache-entry.type.ts @@ -0,0 +1,10 @@ +export type CoreEntityVersionEntry = { + data: T; + lastReadAt: number; +}; + +export type CoreEntityLocalCacheEntry = { + versions: Map>; + latestHash: string; + lastHashCheckedAt: number; +}; diff --git a/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts b/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts index c64b73f1286..47b581bce52 100644 --- a/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts +++ b/packages/twenty-server/src/engine/core-modules/actor/utils/build-created-by-from-api-key.util.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts index cbd3e2c31a8..2b4834c8e07 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.module.ts @@ -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], diff --git a/packages/twenty-server/src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant.ts b/packages/twenty-server/src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant.ts new file mode 100644 index 00000000000..d5be8ab2456 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/constants/api-key-entity-non-cached-properties.constant.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts index 566bd806fbc..850df68f104 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/__tests__/api-key.service.spec.ts @@ -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(); diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts index 167400693c4..051412ed9df 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key.service.ts @@ -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, 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 { + await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'apiKeyMap', + ]); + } } diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service.ts new file mode 100644 index 00000000000..3afb483d2db --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/workspace-api-key-map-cache.service.ts @@ -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 +> { + constructor( + @InjectRepository(ApiKeyEntity) + private readonly apiKeyRepository: Repository, + ) { + super(); + } + + async computeForCache( + workspaceId: string, + ): Promise> { + const apiKeys = await this.apiKeyRepository.find({ + where: { workspaceId }, + }); + + return apiKeys.reduce( + (map, apiKey) => { + map[apiKey.id] = fromApiKeyEntityToFlat(apiKey); + + return map; + }, + {} as Record, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/api-key/types/flat-api-key.type.ts b/packages/twenty-server/src/engine/core-modules/api-key/types/flat-api-key.type.ts new file mode 100644 index 00000000000..f2fd659ca81 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/types/flat-api-key.type.ts @@ -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; + +export type FlatApiKey = Omit< + ApiKeyCachedFields, + keyof CastRecordTypeOrmDatePropertiesToString +> & + CastRecordTypeOrmDatePropertiesToString; diff --git a/packages/twenty-server/src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util.ts b/packages/twenty-server/src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util.ts new file mode 100644 index 00000000000..f0e34225654 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/api-key/utils/from-api-key-entity-to-flat.util.ts @@ -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(), +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts index 310892fff81..bc3a0091f01 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 129cb2888a5..ea2feab3a8b 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -512,8 +512,10 @@ export class AuthResolver { @AuthUser() currentUser: AuthContextUser, @AuthProvider() authProvider: AuthProviderEnum, ): Promise { + 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( diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index a27b6a7d2b1..5511ae3175d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -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; workspace: WorkspaceEntity; shouldShowConnectAccountStep: boolean; }, @@ -577,7 +576,11 @@ export class SignInUpService { ); await this.activateOnboardingForUser( - { user, workspace, shouldShowConnectAccountStep: true }, + { + user, + workspace, + shouldShowConnectAccountStep: true, + }, queryRunner, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts index 07c2c554a8d..c8dc8d52cc5 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.spec.ts @@ -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; + let userStore: Record; + let apiKeyStore: Record>; - 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 = {}; + + 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'); diff --git a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts index 61879f53731..ce878c8bbd3 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/strategies/jwt.auth.strategy.ts @@ -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, @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, - @InjectRepository(UserEntity) - private readonly userRepository: Repository, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, - @InjectRepository(ApiKeyEntity) - private readonly apiKeyRepository: Repository, 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 { - 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 { - 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 { - const workspace = await this.workspaceRepository.findOneBy({ - id: payload.workspaceId, - }); + const workspace = await this.coreEntityCacheService.get( + 'workspaceEntity', + payload.workspaceId, + ); if (!isDefined(workspace)) { throw new AuthException( diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts index de96d2e6622..96a295e7fd6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.spec.ts @@ -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( diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts index 2e379d3b4ce..fe52f2da591 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/workspace-agnostic-token.service.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 731abd3df22..78c849fa991 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts index 8757c49ad63..a4f817b1e3d 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/auth-context.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/flat-auth-context-user.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/flat-auth-context-user.type.ts new file mode 100644 index 00000000000..4da8aee8289 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/flat-auth-context-user.type.ts @@ -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] +>; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts index cafc0f0da9d..c8241969032 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/signInUp.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts b/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts index b6be2af0994..8ac71bf71ed 100644 --- a/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts index 75d02c73d25..7cfc7b2331f 100644 --- a/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts +++ b/packages/twenty-server/src/engine/core-modules/graphql/hooks/use-graphql-error-handler.hook.ts @@ -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, }; } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job.ts index 3acae7f9d52..8c7527ba002 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job.ts @@ -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, - @InjectRepository(LogicFunctionEntity) - private readonly logicFunctionRepository: Repository, + 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: {}, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts b/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts index 0e074835a26..1a46cfa5aa0 100644 --- a/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts @@ -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 { const graphqlSchema = await this.workspaceSchemaFactory.createGraphQLSchema( - { id: workspaceId } as WorkspaceEntity, + { id: workspaceId } as FlatWorkspace, applicationId, ); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts index 2623b8db466..fb38dfc8aad 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts @@ -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, }); diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts index dfa201432cc..96f9dc8bf7e 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.spec.ts @@ -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; - 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', ); diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/constants/user-workspace-entity-non-cached-properties.constant.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/constants/user-workspace-entity-non-cached-properties.constant.ts new file mode 100644 index 00000000000..c5584ba2250 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/constants/user-workspace-entity-non-cached-properties.constant.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service.ts new file mode 100644 index 00000000000..b222fcb81c8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/services/user-workspace-entity-cache-provider.service.ts @@ -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 { + constructor( + @InjectRepository(UserWorkspaceEntity) + private readonly userWorkspaceRepository: Repository, + ) { + super(); + } + + async computeForCache(entityId: string): Promise { + const entity = await this.userWorkspaceRepository.findOne({ + where: { id: entityId }, + }); + + if (entity === null) { + return null; + } + + return fromUserWorkspaceEntityToFlat(entity); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/types/flat-user-workspace.type.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/types/flat-user-workspace.type.ts new file mode 100644 index 00000000000..114d79bdd04 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/types/flat-user-workspace.type.ts @@ -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 +> & + CastRecordTypeOrmDatePropertiesToString; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts index 9fc98f4d14c..b8ae96a2aa8 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 3a1982818a4..fe2ec36c4e4 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts index ec625e4725b..fdee7686921 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.ts @@ -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, + ) { const authContext = buildSystemAuthContext(workspaceId); await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => { @@ -536,7 +541,7 @@ export class UserWorkspaceService extends TypeOrmQueryService; }, - user: AuthContextUser, + user: Pick, authProvider: AuthProviderEnum, ) { return { diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/utils/from-user-workspace-entity-to-flat.util.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/utils/from-user-workspace-entity-to-flat.util.ts new file mode 100644 index 00000000000..2825f9e27df --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/utils/from-user-workspace-entity-to-flat.util.ts @@ -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, +}); diff --git a/packages/twenty-server/src/engine/core-modules/user/constants/user-entity-non-cached-properties.constant.ts b/packages/twenty-server/src/engine/core-modules/user/constants/user-entity-non-cached-properties.constant.ts new file mode 100644 index 00000000000..5f3f039f284 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/constants/user-entity-non-cached-properties.constant.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts new file mode 100644 index 00000000000..4a98a541c7e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/services/user-entity-cache-provider.service.ts @@ -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 { + constructor( + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + ) { + super(); + } + + async computeForCache(entityId: string): Promise { + const entity = await this.userRepository.findOne({ + where: { id: entityId }, + }); + + if (entity === null) { + return null; + } + + return fromUserEntityToFlat(entity); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts index edbe483234a..f64cae706ed 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts @@ -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, { 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, { id: 'w1', activationStatus: WorkspaceActivationStatus.ACTIVE, diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts index 2a81a0e6071..dc96615d6a2 100644 --- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts +++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts @@ -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 { 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, + workspace: Pick, + ) { if (!isWorkspaceActiveOrSuspended(workspace)) { return null; } @@ -85,7 +90,10 @@ export class UserService extends TypeOrmQueryService { ); } - async loadWorkspaceMembers(workspace: WorkspaceEntity, withDeleted = false) { + async loadWorkspaceMembers( + workspace: Pick, + withDeleted = false, + ) { if (!isWorkspaceActiveOrSuspended(workspace)) { return []; } @@ -109,7 +117,9 @@ export class UserService extends TypeOrmQueryService { ); } - async loadDeletedWorkspaceMembersOnly(workspace: WorkspaceEntity) { + async loadDeletedWorkspaceMembersOnly( + workspace: Pick, + ) { if (!isWorkspaceActiveOrSuspended(workspace)) { return []; } @@ -151,6 +161,7 @@ export class UserService extends TypeOrmQueryService { } 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 { if (user.userWorkspaces.length === 1) { await this.userRepository.softDelete(userId); + await this.coreEntityCacheService.invalidate('user', userId); } return userWorkspace; diff --git a/packages/twenty-server/src/engine/core-modules/user/types/flat-user.type.ts b/packages/twenty-server/src/engine/core-modules/user/types/flat-user.type.ts new file mode 100644 index 00000000000..a666a33ec53 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/types/flat-user.type.ts @@ -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; + +export type FlatUser = Omit< + UserCachedFields, + keyof CastRecordTypeOrmDatePropertiesToString +> & + CastRecordTypeOrmDatePropertiesToString; diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts index c0fdf0b8305..a0c6621b392 100644 --- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts +++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/user/utils/from-user-entity-to-flat.util.ts b/packages/twenty-server/src/engine/core-modules/user/utils/from-user-entity-to-flat.util.ts new file mode 100644 index 00000000000..e592f29fbb7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user/utils/from-user-entity-to-flat.util.ts @@ -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(), +}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/constants/workspace-entity-non-cached-properties.constant.ts b/packages/twenty-server/src/engine/core-modules/workspace/constants/workspace-entity-non-cached-properties.constant.ts new file mode 100644 index 00000000000..5600565240d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/constants/workspace-entity-non-cached-properties.constant.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts index 4a52dbc6f3c..636ea07ce5d 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/__tests__/workspace.service.spec.ts @@ -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: { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service.ts new file mode 100644 index 00000000000..e23f02d733a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace-entity-cache-provider.service.ts @@ -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 { + constructor( + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, + ) { + super(); + } + + async computeForCache(entityId: string): Promise { + const entity = await this.workspaceRepository.findOneBy({ id: entityId }); + + if (entity === null) { + return null; + } + + return fromWorkspaceEntityToFlat(entity); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 5cd0b9b2300..8313f9f8aa3 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -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 { private readonly messageQueueService: MessageQueueService, @InjectDataSource() private readonly coreDataSource: DataSource, + private readonly coreEntityCacheService: CoreEntityCacheService, ) { super(workspaceRepository); } @@ -290,6 +292,11 @@ export class WorkspaceService extends TypeOrmQueryService { 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 { 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 { 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 { 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 { } 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 { userWorkspaceId: userWorkspaceOfRemovedWorkspaceMember.id, softDelete, }); + await this.coreEntityCacheService.invalidate( + 'userWorkspaceEntity', + userWorkspaceOfRemovedWorkspaceMember.id, + ); } const hasOtherUserWorkspaces = isDefined( @@ -581,6 +604,7 @@ export class WorkspaceService extends TypeOrmQueryService { if (!hasOtherUserWorkspaces) { await this.userRepository.softDelete(userId); + await this.coreEntityCacheService.invalidate('user', userId); } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/types/flat-workspace.type.ts b/packages/twenty-server/src/engine/core-modules/workspace/types/flat-workspace.type.ts new file mode 100644 index 00000000000..241836eb1f7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/types/flat-workspace.type.ts @@ -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 +> & + CastRecordTypeOrmDatePropertiesToString; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util.ts b/packages/twenty-server/src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util.ts new file mode 100644 index 00000000000..ce7051dcb1b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workspace/utils/from-workspace-entity-to-flat.util.ts @@ -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, +}); diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index c9c9ae67e17..dce4404dbf5 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/build-system-auth-context.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/build-system-auth-context.util.ts index dd7a533b394..f952f8c81e5 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/build-system-auth-context.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/build-system-auth-context.util.ts @@ -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, }); }; diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index cdc22a759a0..7f6d696d069 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -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; @@ -61,6 +63,7 @@ export type AdditionalCacheDataMaps = { rolesPermissions: ObjectsPermissionsByRoleId; userWorkspaceRoleMap: UserWorkspaceRoleMap; apiKeyRoleMap: Record; + apiKeyMap: Record; flatApplicationMaps: FlatApplicationCacheMaps; ORMEntityMetadatas: EntityMetadata[]; flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts index bdd3680b6d1..575bba18551 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-api-keys.util.ts @@ -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, }, ]) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts index 5218a2c2c6a..b53fb0519cc 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/services/workflow-execution-context.service.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts index fb802bdbfd7..ea84a7bc694 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-pre-query-hook.service.ts @@ -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; diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-query-hook.module.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-query-hook.module.ts index 84165259e12..f223c0adf8e 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-query-hook.module.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-query-hook.module.ts @@ -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, diff --git a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts index 1f8a6b7f15c..4ad3d88d93c 100644 --- a/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/workspace-member/query-hooks/workspace-member-update-one.pre-query.hook.ts @@ -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, + 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(