diff --git a/packages/twenty-oxlint-rules/oxlint-plugin.ts b/packages/twenty-oxlint-rules/oxlint-plugin.ts index 83c550534a..058503f240 100644 --- a/packages/twenty-oxlint-rules/oxlint-plugin.ts +++ b/packages/twenty-oxlint-rules/oxlint-plugin.ts @@ -52,6 +52,10 @@ import { rule as noStateUseref, RULE_NAME as noStateUserefName, } from './rules/no-state-useref'; +import { + rule as preferWorkspaceScopedRepository, + RULE_NAME as preferWorkspaceScopedRepositoryName, +} from './rules/prefer-workspace-scoped-repository'; import { rule as restApiMethodsShouldBeGuarded, RULE_NAME as restApiMethodsShouldBeGuardedName, @@ -85,6 +89,7 @@ export default definePlugin({ [noJotaiStoreInSelectorName]: noJotaiStoreInSelector, [noNavigatePreferLinkName]: noNavigatePreferLink, [noStateUserefName]: noStateUseref, + [preferWorkspaceScopedRepositoryName]: preferWorkspaceScopedRepository, [restApiMethodsShouldBeGuardedName]: restApiMethodsShouldBeGuarded, [sortCssPropertiesAlphabeticallyName]: sortCssPropertiesAlphabetically, [styledComponentsPrefixedWithStyledName]: diff --git a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.spec.ts b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.spec.ts new file mode 100644 index 0000000000..ee880a7174 --- /dev/null +++ b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.spec.ts @@ -0,0 +1,110 @@ +import { RuleTester } from 'oxlint/plugins-dev'; + +import { rule, RULE_NAME } from './prefer-workspace-scoped-repository'; + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + // Entity not on the blacklist — raw @InjectRepository is fine. + code: ` + class WorkspaceService { + constructor( + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, + ) {} + } + `, + filename: 'workspace.service.ts', + }, + { + // Blacklisted entity injected through the scoped wrapper. + code: ` + class AgentTurnGraderService { + constructor( + @InjectWorkspaceScopedRepository(AgentTurnEntity) + private readonly turnRepository: WorkspaceScopedRepository, + ) {} + } + `, + filename: 'agent-turn-grader.service.ts', + }, + { + // Non-constructor methods with decorators must be ignored. + code: ` + class Service { + @SomeDecorator() + doStuff() {} + } + `, + filename: 'something.service.ts', + }, + ], + invalid: [ + { + code: ` + class AgentChatService { + constructor( + @InjectRepository(AgentChatThreadEntity) + private readonly threadRepository: Repository, + ) {} + } + `, + filename: 'agent-chat.service.ts', + errors: [ + { + messageId: 'preferWorkspaceScopedRepository', + data: { entityName: 'AgentChatThreadEntity' }, + }, + ], + }, + { + // Multiple blacklisted entities → multiple errors. + code: ` + class AgentChatService { + constructor( + @InjectRepository(AgentTurnEntity) + private readonly turnRepository: Repository, + @InjectRepository(AgentMessageEntity) + private readonly messageRepository: Repository, + ) {} + } + `, + filename: 'agent-chat.service.ts', + errors: [ + { + messageId: 'preferWorkspaceScopedRepository', + data: { entityName: 'AgentTurnEntity' }, + }, + { + messageId: 'preferWorkspaceScopedRepository', + data: { entityName: 'AgentMessageEntity' }, + }, + ], + }, + { + // Plain (non-parameter-property) constructor parameter with an + // explicit assignment in the body. Must still be caught — the + // rule should not depend on the TSParameterProperty shorthand. + code: ` + class AgentChatService { + private readonly threadRepository: Repository; + constructor( + @InjectRepository(AgentChatThreadEntity) + threadRepository: Repository, + ) { + this.threadRepository = threadRepository; + } + } + `, + filename: 'agent-chat.service.ts', + errors: [ + { + messageId: 'preferWorkspaceScopedRepository', + data: { entityName: 'AgentChatThreadEntity' }, + }, + ], + }, + ], +}); diff --git a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts new file mode 100644 index 0000000000..ed004bdd4e --- /dev/null +++ b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts @@ -0,0 +1,133 @@ +import { defineRule } from '@oxlint/plugins'; + +export const RULE_NAME = 'prefer-workspace-scoped-repository'; + +// Entities that legitimately do not carry a workspaceId column. +const STRUCTURAL_EXEMPTIONS = new Set([ + 'WorkspaceEntity', + 'UserWorkspaceEntity', + 'AppTokenEntity', + 'ApplicationRegistrationEntity', + + 'ApplicationVariableEntity', + 'BillingMeterEntity', + 'BillingPriceEntity', + 'BillingProductEntity', + 'BillingSubscriptionItemEntity', + 'ConnectedAccountEntity', + 'ConnectionProviderEntity', + 'FrontComponentEntity', + 'LogicFunctionEntity', + 'MessageFolderEntity', + 'RolePermissionFlagEntity', + 'SigningKeyEntity', + 'UserEntity', + 'WorkspaceSSOIdentityProviderEntity', +]); + +// Workspace-scoped entities exempted from the wrapper at the call site. +const WORKSPACE_SCOPED_EXEMPTIONS = new Set([ + 'ApplicationEntity', + 'ApplicationRegistrationVariableEntity', + 'CalendarChannelEntity', + 'CommandMenuItemEntity', + 'DataSourceEntity', + 'FieldMetadataEntity', + 'FieldPermissionEntity', + 'IndexMetadataEntity', + 'KeyValuePairEntity', + 'MessageChannelEntity', + 'NavigationMenuItemEntity', + 'ObjectMetadataEntity', + 'ObjectPermissionEntity', + 'PageLayoutEntity', + 'PageLayoutTabEntity', + 'PageLayoutWidgetEntity', + 'PermissionFlagEntity', + 'RoleEntity', + 'RoleTargetEntity', + 'RowLevelPermissionPredicateEntity', + 'RowLevelPermissionPredicateGroupEntity', + 'SkillEntity', + 'UpgradeMigrationEntity', + 'ViewEntity', + 'ViewFieldEntity', + 'ViewFieldGroupEntity', + 'ViewFilterEntity', + 'ViewFilterGroupEntity', + 'ViewGroupEntity', + 'ViewSortEntity', + 'WebhookEntity', +]); + +// Everything else must use @InjectWorkspaceScopedRepository. +const EXCLUSIONS = new Set([ + ...STRUCTURAL_EXEMPTIONS, + ...WORKSPACE_SCOPED_EXEMPTIONS, +]); + +const matchInjectRepositoryEntity = (decorator: any): string | null => { + if (decorator.expression?.type !== 'CallExpression') { + return null; + } + + const callee = decorator.expression.callee; + + if (callee?.type !== 'Identifier' || callee.name !== 'InjectRepository') { + return null; + } + + const [arg] = decorator.expression.arguments; + + if (arg?.type !== 'Identifier') { + return null; + } + + if (!arg.name.endsWith('Entity')) { + return null; + } + + if (EXCLUSIONS.has(arg.name)) { + return null; + } + + return arg.name; +}; + +export const rule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow raw @InjectRepository for workspace-scoped entities. Use @InjectWorkspaceScopedRepository so workspaceId is enforced on every read/write.', + }, + schema: [], + messages: { + preferWorkspaceScopedRepository: + 'Use @InjectWorkspaceScopedRepository({{entityName}}) instead of raw @InjectRepository so the workspaceId guard is enforced. If {{entityName}} genuinely does not fit, add it to EXCLUSIONS or suppress with `// eslint-disable-next-line twenty/prefer-workspace-scoped-repository` and a short reason.', + }, + }, + create: (context) => { + return { + MethodDefinition: (node: any) => { + if (node.kind !== 'constructor') { + return; + } + + for (const param of node.value.params ?? []) { + for (const decorator of param.decorators ?? []) { + const entityName = matchInjectRepositoryEntity(decorator); + + if (entityName !== null) { + context.report({ + node: decorator, + messageId: 'preferWorkspaceScopedRepository', + data: { entityName }, + }); + } + } + } + }, + }; + }, +}); diff --git a/packages/twenty-server/.oxlintrc.json b/packages/twenty-server/.oxlintrc.json index 75eeeb839a..bed13e34f6 100644 --- a/packages/twenty-server/.oxlintrc.json +++ b/packages/twenty-server/.oxlintrc.json @@ -78,6 +78,7 @@ ], "twenty/inject-workspace-repository": "warn", + "twenty/prefer-workspace-scoped-repository": "error", "twenty/rest-api-methods-should-be-guarded": "error", "twenty/graphql-resolvers-should-be-guarded": "error", "twenty/upgrade-command-filename": "error", diff --git a/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts b/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts index ab0a39ef21..9244d65da6 100644 --- a/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts +++ b/packages/twenty-server/src/database/commands/secret-encryption-rotation/services/secret-encryption-rotation-runner.service.ts @@ -63,6 +63,8 @@ export class SecretEncryptionRotationRunnerService { connectedAccountRepository: Repository, @InjectRepository(SigningKeyEntity) signingKeyRepository: Repository, + // Secret-encryption key rotation sweeps every row across every workspace. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(TwoFactorAuthenticationMethodEntity) twoFactorAuthenticationMethodRepository: Repository, ) { diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts index d56887d7c9..a4233bb0f7 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.module.ts @@ -46,7 +46,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ TypeOrmModule.forFeature([ @@ -98,6 +98,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi WorkerHealthIndicator, ConnectedAccountHealth, AppHealthIndicator, + provideWorkspaceScopedRepository(AgentMessageEntity), + provideWorkspaceScopedRepository(FeatureFlagEntity), + provideWorkspaceScopedRepository(BillingCustomerEntity), ], exports: [ AdminPanelUserLookupService, diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-billing.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-billing.service.ts index b89d1e09d6..090038f844 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-billing.service.ts @@ -9,7 +9,8 @@ import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/bil import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; const CREDIT_BALANCE_MICRO_UNIT = 1_000_000; const KNOWN_PLAN_KEYS: ReadonlySet = new Set( @@ -19,8 +20,8 @@ const KNOWN_PLAN_KEYS: ReadonlySet = new Set( @Injectable() export class AdminPanelBillingService { constructor( - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, @InjectRepository(BillingPriceEntity) private readonly billingPriceRepository: Repository, private readonly billingSubscriptionService: BillingSubscriptionService, @@ -35,7 +36,7 @@ export class AdminPanelBillingService { } const [customer, subscription] = await Promise.all([ - this.billingCustomerRepository.findOne({ where: { workspaceId } }), + this.billingCustomerRepository.findOne(workspaceId, { where: {} }), this.billingSubscriptionService.getCurrentBillingSubscription({ workspaceId, }), diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts index 46ee9bc5b6..0fb393a2f0 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-chat.service.ts @@ -7,18 +7,22 @@ import { type AdminChatMessageDTO } from 'src/engine/core-modules/admin-panel/dt import { type AdminWorkspaceChatThreadDTO } from 'src/engine/core-modules/admin-panel/dtos/admin-workspace-chat-thread.dto'; import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; - +import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class AdminPanelChatService { constructor( @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + // Thread lookup is by id alone; the admin does not know the workspaceId + // upfront. assertWorkspaceAllowsImpersonation gates every other read. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(AgentChatThreadEntity) private readonly agentChatThreadRepository: Repository, - @InjectRepository(AgentMessageEntity) - private readonly agentMessageRepository: Repository, + @InjectWorkspaceScopedRepository(AgentMessageEntity) + private readonly agentMessageRepository: WorkspaceScopedRepository, ) {} private async assertWorkspaceAllowsImpersonation( @@ -74,11 +78,14 @@ export class AdminPanelChatService { await this.assertWorkspaceAllowsImpersonation(thread.workspaceId); - const messages = await this.agentMessageRepository.find({ - where: { threadId }, - relations: { parts: true }, - order: { createdAt: 'ASC' }, - }); + const messages = await this.agentMessageRepository.find( + thread.workspaceId, + { + where: { threadId }, + relations: { parts: true }, + order: { createdAt: 'ASC' }, + }, + ); return { thread: { diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts index e6b7806e46..b840cace58 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service.ts @@ -19,7 +19,8 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service' 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 { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class AdminPanelUserLookupService { constructor( @@ -32,8 +33,8 @@ export class AdminPanelUserLookupService { private readonly workspaceRepository: Repository, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, - @InjectRepository(FeatureFlagEntity) - private readonly featureFlagRepository: Repository, + @InjectWorkspaceScopedRepository(FeatureFlagEntity) + private readonly featureFlagRepository: WorkspaceScopedRepository, ) {} private buildFallbackAvatarUrlsByUserId( @@ -160,9 +161,7 @@ export class AdminPanelUserLookupService { where: { workspaceId }, relations: { user: true }, }), - this.featureFlagRepository.find({ - where: { workspaceId }, - }), + this.featureFlagRepository.find(workspaceId), ]); const allFeatureFlagKeys = Object.values(FeatureFlagKey); 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 2b4834c8e0..e4e245ebec 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 @@ -15,6 +15,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -42,6 +43,7 @@ import { ApiKeyController } from './controllers/api-key.controller'; ApiKeyRoleService, WorkspaceApiKeyMapCacheService, GenerateApiKeyCommand, + provideWorkspaceScopedRepository(ApiKeyEntity), ], controllers: [ApiKeyController], exports: [ 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 3e57eb59fa..e4297dec57 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 { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; describe('ApiKeyService', () => { @@ -88,7 +89,7 @@ describe('ApiKeyService', () => { providers: [ ApiKeyService, { - provide: getRepositoryToken(ApiKeyEntity), + provide: getWorkspaceScopedRepositoryToken(ApiKeyEntity), useValue: mockApiKeyRepository, }, { @@ -143,7 +144,6 @@ describe('ApiKeyService', () => { const expectedApiKeyFields = { name: 'New API Key', expiresAt: new Date('2025-12-31'), - workspaceId: mockWorkspaceId, }; mockApiKeyRepository.save.mockResolvedValue(mockApiKey); @@ -152,6 +152,7 @@ describe('ApiKeyService', () => { const result = await service.create(apiKeyData); expect(mockApiKeyRepository.save).toHaveBeenCalledWith( + mockWorkspaceId, expectedApiKeyFields, ); expect(mockRoleTargetService.create).toHaveBeenCalledWith({ @@ -185,7 +186,10 @@ describe('ApiKeyService', () => { expect(mockApiKeyRepository.save).toHaveBeenCalled(); expect(mockRoleTargetService.create).toHaveBeenCalled(); - expect(mockApiKeyRepository.delete).toHaveBeenCalledWith(mockApiKey.id); + expect(mockApiKeyRepository.delete).toHaveBeenCalledWith( + mockWorkspaceId, + { id: mockApiKey.id }, + ); }); it('should handle save failures gracefully', async () => { @@ -211,12 +215,10 @@ describe('ApiKeyService', () => { const result = await service.findById(mockApiKeyId, mockWorkspaceId); - expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith({ - where: { - id: mockApiKeyId, - workspaceId: mockWorkspaceId, - }, - }); + expect(mockApiKeyRepository.findOne).toHaveBeenCalledWith( + mockWorkspaceId, + { where: { id: mockApiKeyId } }, + ); expect(result).toEqual(mockApiKey); }); @@ -237,11 +239,7 @@ describe('ApiKeyService', () => { const result = await service.findByWorkspaceId(mockWorkspaceId); - expect(mockApiKeyRepository.find).toHaveBeenCalledWith({ - where: { - workspaceId: mockWorkspaceId, - }, - }); + expect(mockApiKeyRepository.find).toHaveBeenCalledWith(mockWorkspaceId); expect(result).toEqual(mockApiKeys); }); }); @@ -254,11 +252,8 @@ describe('ApiKeyService', () => { const result = await service.findActiveByWorkspaceId(mockWorkspaceId); - expect(mockApiKeyRepository.find).toHaveBeenCalledWith({ - where: { - workspaceId: mockWorkspaceId, - revokedAt: IsNull(), - }, + expect(mockApiKeyRepository.find).toHaveBeenCalledWith(mockWorkspaceId, { + where: { revokedAt: IsNull() }, }); expect(result).toEqual(activeApiKeys); }); @@ -281,7 +276,8 @@ describe('ApiKeyService', () => { ); expect(mockApiKeyRepository.update).toHaveBeenCalledWith( - mockApiKeyId, + mockWorkspaceId, + { id: mockApiKeyId }, updateData, ); expect(result).toEqual(updatedApiKey); @@ -311,10 +307,9 @@ describe('ApiKeyService', () => { const result = await service.revoke(mockApiKeyId, mockWorkspaceId); expect(mockApiKeyRepository.update).toHaveBeenCalledWith( - mockApiKeyId, - expect.objectContaining({ - revokedAt: expect.any(Date), - }), + mockWorkspaceId, + { id: mockApiKeyId }, + expect.objectContaining({ revokedAt: expect.any(Date) }), ); expect(result).toEqual(revokedApiKey); }); diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts index 9eafcef398..52f9506b69 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts @@ -16,6 +16,8 @@ import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { fromFlatRoleToRoleDto } from 'src/engine/metadata-modules/role/utils/fromFlatRoleToRoleDto.util'; import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @Injectable() @@ -26,8 +28,8 @@ export class ApiKeyRoleService { @InjectRepository(RoleEntity) private readonly roleRepository: Repository, - @InjectRepository(ApiKeyEntity) - private readonly apiKeyRepository: Repository, + @InjectWorkspaceScopedRepository(ApiKeyEntity) + private readonly apiKeyRepository: WorkspaceScopedRepository, private readonly workspaceCacheService: WorkspaceCacheService, private readonly roleTargetService: RoleTargetService, ) {} @@ -128,8 +130,8 @@ export class ApiKeyRoleService { workspaceId: string; roleId: string; }) { - const apiKey = await this.apiKeyRepository.findOne({ - where: { id: apiKeyId, workspaceId }, + const apiKey = await this.apiKeyRepository.findOne(workspaceId, { + where: { id: apiKeyId }, }); if (!apiKey) { @@ -223,12 +225,8 @@ export class ApiKeyRoleService { return []; } - const apiKeys = await this.apiKeyRepository.find({ - where: { - id: In(apiKeyIds), - workspaceId, - revokedAt: IsNull(), - }, + const apiKeys = await this.apiKeyRepository.find(workspaceId, { + where: { id: In(apiKeyIds), revokedAt: IsNull() }, }); return apiKeys; 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 943c32cdde..2269cfd06e 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 @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; -import { IsNull, Repository } from 'typeorm'; +import { IsNull } from 'typeorm'; import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; @@ -14,23 +13,28 @@ 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 { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @Injectable() export class ApiKeyService { constructor( - @InjectRepository(ApiKeyEntity) - private readonly apiKeyRepository: Repository, + @InjectWorkspaceScopedRepository(ApiKeyEntity) + private readonly apiKeyRepository: WorkspaceScopedRepository, private readonly jwtWrapperService: JwtWrapperService, private readonly roleTargetService: RoleTargetService, private readonly workspaceCacheService: WorkspaceCacheService, ) {} async create( - apiKeyData: Partial & { roleId: string }, + apiKeyData: Partial & { roleId: string; workspaceId: string }, ): Promise { - const { roleId, ...apiKeyFields } = apiKeyData; - const savedApiKey = await this.apiKeyRepository.save(apiKeyFields); + const { roleId, workspaceId, ...apiKeyFields } = apiKeyData; + const savedApiKey = await this.apiKeyRepository.save( + workspaceId, + apiKeyFields, + ); try { await this.roleTargetService.create({ @@ -42,7 +46,7 @@ export class ApiKeyService { workspaceId: savedApiKey.workspaceId, }); } catch (error) { - await this.apiKeyRepository.delete(savedApiKey.id); + await this.apiKeyRepository.delete(workspaceId, { id: savedApiKey.id }); throw error; } @@ -55,28 +59,18 @@ export class ApiKeyService { id: string, workspaceId: string, ): Promise { - return await this.apiKeyRepository.findOne({ - where: { - id, - workspaceId, - }, + return this.apiKeyRepository.findOne(workspaceId, { + where: { id }, }); } async findByWorkspaceId(workspaceId: string): Promise { - return await this.apiKeyRepository.find({ - where: { - workspaceId, - }, - }); + return this.apiKeyRepository.find(workspaceId); } async findActiveByWorkspaceId(workspaceId: string): Promise { - return await this.apiKeyRepository.find({ - where: { - workspaceId, - revokedAt: IsNull(), - }, + return this.apiKeyRepository.find(workspaceId, { + where: { revokedAt: IsNull() }, }); } @@ -91,16 +85,14 @@ export class ApiKeyService { return null; } - await this.apiKeyRepository.update(id, updateData); + await this.apiKeyRepository.update(workspaceId, { id }, updateData); await this.invalidateApiKeyCache(workspaceId); return this.findById(id, workspaceId); } async revoke(id: string, workspaceId: string): Promise { - return await this.update(id, workspaceId, { - revokedAt: new Date(), - }); + return this.update(id, workspaceId, { revokedAt: new Date() }); } async validateApiKey(id: string, workspaceId: string): Promise { 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 index 3afb483d2d..c6bd5c4c7b 100644 --- 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 @@ -1,13 +1,12 @@ 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 { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; @Injectable() @@ -16,8 +15,8 @@ export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider< Record > { constructor( - @InjectRepository(ApiKeyEntity) - private readonly apiKeyRepository: Repository, + @InjectWorkspaceScopedRepository(ApiKeyEntity) + private readonly apiKeyRepository: WorkspaceScopedRepository, ) { super(); } @@ -25,9 +24,7 @@ export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider< async computeForCache( workspaceId: string, ): Promise> { - const apiKeys = await this.apiKeyRepository.find({ - where: { workspaceId }, - }); + const apiKeys = await this.apiKeyRepository.find(workspaceId); return apiKeys.reduce( (map, apiKey) => { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts index 5032f780a0..7d694111f0 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts @@ -49,6 +49,8 @@ export class ApplicationPackageFetcherService implements OnModuleInit { private readonly twentyConfigService: TwentyConfigService, private readonly fileStorageService: FileStorageService, private readonly secureHttpClientService: SecureHttpClientService, + // Tarball lookup keyed by ApplicationRegistration id (catalog rows have null ownerWorkspaceId). + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(FileEntity) private readonly fileRepository: Repository, @InjectRepository(ApplicationEntity) diff --git a/packages/twenty-server/src/engine/core-modules/application/application.module.ts b/packages/twenty-server/src/engine/core-modules/application/application.module.ts index deb352f66b..b24e4814bd 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.module.ts @@ -14,6 +14,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ @@ -34,6 +35,10 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache FeatureFlagModule, ], exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService], - providers: [ApplicationService, WorkspaceFlatApplicationMapCacheService], + providers: [ + ApplicationService, + WorkspaceFlatApplicationMapCacheService, + provideWorkspaceScopedRepository(AgentEntity), + ], }) export class ApplicationModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 335935d2e4..e9ccdf5db5 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -23,6 +23,8 @@ import { FrontComponentEntity } from 'src/engine/metadata-modules/front-componen import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; import { logicFunctionCreateHash } from 'src/engine/metadata-modules/logic-function/utils/logic-function-create-hash.utils'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications'; @@ -37,8 +39,8 @@ export class ApplicationService { private readonly workspaceRepository: Repository, @InjectRepository(LogicFunctionEntity) private readonly logicFunctionRepository: Repository, - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, @InjectRepository(FrontComponentEntity) private readonly frontComponentRepository: Repository, @InjectRepository(CommandMenuItemEntity) @@ -198,8 +200,8 @@ export class ApplicationService { this.logicFunctionRepository.find({ where: { applicationId: application.id, workspaceId }, }), - this.agentRepository.find({ - where: { applicationId: application.id, workspaceId }, + this.agentRepository.find(workspaceId, { + where: { applicationId: application.id }, }), this.frontComponentRepository.find({ where: { applicationId: application.id, workspaceId }, diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts index de5f2e4c3d..634f7d8bdd 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/approved-access-domain.module.ts @@ -9,7 +9,7 @@ import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace import { FileModule } from 'src/engine/core-modules/file/file.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ WorkspaceDomainsModule, @@ -19,6 +19,10 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi PermissionsModule, ], exports: [ApprovedAccessDomainService], - providers: [ApprovedAccessDomainService, ApprovedAccessDomainResolver], + providers: [ + ApprovedAccessDomainService, + ApprovedAccessDomainResolver, + provideWorkspaceScopedRepository(ApprovedAccessDomainEntity), + ], }) export class ApprovedAccessDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts index 21563844f2..202f715230 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts @@ -9,6 +9,8 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; import { ApprovedAccessDomainEntity } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { ApprovedAccessDomainException, ApprovedAccessDomainExceptionCode, @@ -36,8 +38,12 @@ export class ApprovedAccessDomainService { private readonly logger = new Logger(ApprovedAccessDomainService.name); constructor( + @InjectWorkspaceScopedRepository(ApprovedAccessDomainEntity) + private readonly approvedAccessDomainRepository: WorkspaceScopedRepository, + // Cross-workspace lookups for token validation and SSO discovery. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(ApprovedAccessDomainEntity) - private readonly approvedAccessDomainRepository: Repository, + private readonly approvedAccessDomainRepositoryUnscoped: Repository, private readonly emailService: EmailService, private readonly twentyConfigService: TwentyConfigService, private readonly fileUrlService: FileUrlService, @@ -199,7 +205,7 @@ export class ApprovedAccessDomainService { } const approvedAccessDomain = - await this.approvedAccessDomainRepository.findOneBy({ + await this.approvedAccessDomainRepositoryUnscoped.findOneBy({ id: approvedAccessDomainId, }); @@ -225,10 +231,10 @@ export class ApprovedAccessDomainService { ); } - return await this.approvedAccessDomainRepository.save({ - ...approvedAccessDomain, - isValidated: true, - }); + return this.approvedAccessDomainRepository.save( + approvedAccessDomain.workspaceId, + { ...approvedAccessDomain, isValidated: true }, + ); } async createApprovedAccessDomain( @@ -244,12 +250,12 @@ export class ApprovedAccessDomainService { ); } - if ( - await this.approvedAccessDomainRepository.findOneBy({ - domain, - workspaceId: inWorkspace.id, - }) - ) { + const existing = await this.approvedAccessDomainRepository.findOne( + inWorkspace.id, + { where: { domain } }, + ); + + if (existing) { throw new ApprovedAccessDomainException( 'Approved access domain already registered.', ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_REGISTERED, @@ -260,10 +266,8 @@ export class ApprovedAccessDomainService { } const approvedAccessDomain = await this.approvedAccessDomainRepository.save( - { - workspaceId: inWorkspace.id, - domain, - }, + inWorkspace.id, + { domain }, ); await this.sendApprovedAccessDomainValidationEmail( @@ -281,30 +285,25 @@ export class ApprovedAccessDomainService { approvedAccessDomainId: string, ) { const approvedAccessDomain = - await this.approvedAccessDomainRepository.findOneBy({ - id: approvedAccessDomainId, - workspaceId: workspace.id, + await this.approvedAccessDomainRepository.findOne(workspace.id, { + where: { id: approvedAccessDomainId }, }); approvedAccessDomainValidator.assertIsDefinedOrThrow(approvedAccessDomain); - await this.approvedAccessDomainRepository.delete({ + await this.approvedAccessDomainRepository.delete(workspace.id, { id: approvedAccessDomain.id, }); } async getApprovedAccessDomains(workspace: WorkspaceEntity) { - return await this.approvedAccessDomainRepository.find({ - where: { - workspaceId: workspace.id, - }, - }); + return this.approvedAccessDomainRepository.find(workspace.id); } async findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain( domain: string, ) { - return await this.approvedAccessDomainRepository.find({ + return this.approvedAccessDomainRepositoryUnscoped.find({ relations: [ 'workspace', 'workspace.workspaceSSOIdentityProviders', diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts index 47a50896f9..c94f585413 100644 --- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts @@ -16,6 +16,8 @@ import { EmailService } from 'src/engine/core-modules/email/email.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; +import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; @@ -34,7 +36,8 @@ jest.mock('@react-email/render', () => ({ describe('ApprovedAccessDomainService', () => { let service: ApprovedAccessDomainService; - let approvedAccessDomainRepository: Repository; + let approvedAccessDomainRepository: WorkspaceScopedRepository; + let approvedAccessDomainRepositoryUnscoped: Repository; let emailService: EmailService; let twentyConfigService: TwentyConfigService; let workspaceDomainsService: WorkspaceDomainsService; @@ -45,14 +48,23 @@ describe('ApprovedAccessDomainService', () => { providers: [ ApprovedAccessDomainService, { - provide: getRepositoryToken(ApprovedAccessDomainEntity), + provide: getWorkspaceScopedRepositoryToken( + ApprovedAccessDomainEntity, + ), useValue: { delete: jest.fn(), - findOneBy: jest.fn(), + findOne: jest.fn(), find: jest.fn(), save: jest.fn(), }, }, + { + provide: getRepositoryToken(ApprovedAccessDomainEntity), + useValue: { + findOneBy: jest.fn(), + find: jest.fn(), + }, + }, { provide: EmailService, useValue: { @@ -93,6 +105,9 @@ describe('ApprovedAccessDomainService', () => { ApprovedAccessDomainService, ); approvedAccessDomainRepository = module.get( + getWorkspaceScopedRepositoryToken(ApprovedAccessDomainEntity), + ); + approvedAccessDomainRepositoryUnscoped = module.get( getRepositoryToken(ApprovedAccessDomainEntity), ); emailService = module.get(EmailService); @@ -141,10 +156,8 @@ describe('ApprovedAccessDomainService', () => { ); expect(approvedAccessDomainRepository.save).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'workspace-id', - domain, - }), + 'workspace-id', + expect.objectContaining({ domain }), ); expect(result).toEqual(expectedApprovedAccessDomain); }); @@ -180,7 +193,7 @@ describe('ApprovedAccessDomainService', () => { } as ApprovedAccessDomainEntity; jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepository, 'findOne') .mockResolvedValue(approvedAccessDomainEntity); jest .spyOn(approvedAccessDomainRepository, 'delete') @@ -191,13 +204,14 @@ describe('ApprovedAccessDomainService', () => { approvedAccessDomainId, ); - expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: approvedAccessDomainId, - workspaceId: workspace.id, - }); - expect(approvedAccessDomainRepository.delete).toHaveBeenCalledWith({ - id: approvedAccessDomainEntity.id, - }); + expect(approvedAccessDomainRepository.findOne).toHaveBeenCalledWith( + workspace.id, + { where: { id: approvedAccessDomainId } }, + ); + expect(approvedAccessDomainRepository.delete).toHaveBeenCalledWith( + workspace.id, + { id: approvedAccessDomainEntity.id }, + ); }); it('should throw an error if the approved access domain does not exist', async () => { @@ -207,17 +221,17 @@ describe('ApprovedAccessDomainService', () => { const approvedAccessDomainId = 'approved-access-domain-id'; jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepository, 'findOne') .mockResolvedValue(null); await expect( service.deleteApprovedAccessDomain(workspace, approvedAccessDomainId), ).rejects.toThrow(); - expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: approvedAccessDomainId, - workspaceId: workspace.id, - }); + expect(approvedAccessDomainRepository.findOne).toHaveBeenCalledWith( + workspace.id, + { where: { id: approvedAccessDomainId } }, + ); expect(approvedAccessDomainRepository.delete).not.toHaveBeenCalled(); }); }); @@ -234,10 +248,6 @@ describe('ApprovedAccessDomainService', () => { isValidated: true, } as ApprovedAccessDomainEntity; - jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') - .mockResolvedValue(approvedAccessDomain); - await expect( service.sendApprovedAccessDomainValidationEmail( sender, @@ -264,10 +274,6 @@ describe('ApprovedAccessDomainService', () => { domain: 'example.com', } as ApprovedAccessDomainEntity; - jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') - .mockResolvedValue(approvedAccessDomain); - await expect( service.sendApprovedAccessDomainValidationEmail( sender, @@ -301,10 +307,6 @@ describe('ApprovedAccessDomainService', () => { domain: 'custom-domain.com', } as ApprovedAccessDomainEntity; - jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') - .mockResolvedValue(approvedAccessDomain); - jest .spyOn(workspaceDomainsService, 'buildWorkspaceURL') .mockReturnValue(new URL('https://sub.twenty.com')); @@ -382,7 +384,7 @@ describe('ApprovedAccessDomainService', () => { jwtWrapperService.verifyJwtToken.mockResolvedValue(buildPayload()); jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepositoryUnscoped, 'findOneBy') .mockResolvedValue(approvedAccessDomain); const saveSpy = jest.spyOn(approvedAccessDomainRepository, 'save'); @@ -394,10 +396,11 @@ describe('ApprovedAccessDomainService', () => { expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith( validationToken, ); - expect(approvedAccessDomainRepository.findOneBy).toHaveBeenCalledWith({ - id: approvedAccessDomainId, - }); + expect( + approvedAccessDomainRepositoryUnscoped.findOneBy, + ).toHaveBeenCalledWith({ id: approvedAccessDomainId }); expect(saveSpy).toHaveBeenCalledWith( + workspaceId, expect.objectContaining({ isValidated: true }), ); }); @@ -417,7 +420,9 @@ describe('ApprovedAccessDomainService', () => { ), ); expect(jwtWrapperService.verifyJwtToken).not.toHaveBeenCalled(); - expect(approvedAccessDomainRepository.findOneBy).not.toHaveBeenCalled(); + expect( + approvedAccessDomainRepositoryUnscoped.findOneBy, + ).not.toHaveBeenCalled(); }); it('should reject when the JWT verification fails (bad signature or expired)', async () => { @@ -436,7 +441,9 @@ describe('ApprovedAccessDomainService', () => { ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, ), ); - expect(approvedAccessDomainRepository.findOneBy).not.toHaveBeenCalled(); + expect( + approvedAccessDomainRepositoryUnscoped.findOneBy, + ).not.toHaveBeenCalled(); }); it('should reject a JWT minted with a different token type', async () => { @@ -455,7 +462,9 @@ describe('ApprovedAccessDomainService', () => { ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, ), ); - expect(approvedAccessDomainRepository.findOneBy).not.toHaveBeenCalled(); + expect( + approvedAccessDomainRepositoryUnscoped.findOneBy, + ).not.toHaveBeenCalled(); }); it('should reject when the JWT approvedAccessDomainId does not match the input id', async () => { @@ -474,7 +483,9 @@ describe('ApprovedAccessDomainService', () => { ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_VALIDATION_TOKEN_INVALID, ), ); - expect(approvedAccessDomainRepository.findOneBy).not.toHaveBeenCalled(); + expect( + approvedAccessDomainRepositoryUnscoped.findOneBy, + ).not.toHaveBeenCalled(); }); it('should reject when the JWT-claimed domain does not match the stored row', async () => { @@ -482,7 +493,7 @@ describe('ApprovedAccessDomainService', () => { buildPayload({ domain: 'attacker.com' }), ); jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepositoryUnscoped, 'findOneBy') .mockResolvedValue({ id: approvedAccessDomainId, workspaceId, @@ -508,7 +519,7 @@ describe('ApprovedAccessDomainService', () => { buildPayload({ workspaceId: 'other-workspace-id' }), ); jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepositoryUnscoped, 'findOneBy') .mockResolvedValue({ id: approvedAccessDomainId, workspaceId, @@ -532,7 +543,7 @@ describe('ApprovedAccessDomainService', () => { it('should throw an error if the approved access domain does not exist', async () => { jwtWrapperService.verifyJwtToken.mockResolvedValue(buildPayload()); jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepositoryUnscoped, 'findOneBy') .mockResolvedValue(null); await expect( @@ -551,7 +562,7 @@ describe('ApprovedAccessDomainService', () => { it('should throw an error if the approved access domain is already validated', async () => { jwtWrapperService.verifyJwtToken.mockResolvedValue(buildPayload()); jest - .spyOn(approvedAccessDomainRepository, 'findOneBy') + .spyOn(approvedAccessDomainRepositoryUnscoped, 'findOneBy') .mockResolvedValue({ id: approvedAccessDomainId, workspaceId, diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts index 40189a59ea..b924b8c323 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts @@ -27,6 +27,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ @@ -62,6 +63,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache BillingWebhookSubscriptionService, BillingWebhookSubscriptionScheduleService, BillingWebhookEntitlementService, + provideWorkspaceScopedRepository(BillingEntitlementEntity), + provideWorkspaceScopedRepository(BillingCustomerEntity), ], }) export class BillingWebhookModule {} diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service.ts index 7d48d730e4..56839d1a1a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-customer.service.ts @@ -1,9 +1,6 @@ /* @license Enterprise */ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; import type Stripe from 'stripe'; @@ -12,13 +9,14 @@ import { BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class BillingWebhookCustomerService { protected readonly logger = new Logger(BillingWebhookCustomerService.name); constructor( - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, ) {} async processStripeEvent(data: Stripe.CustomerCreatedEvent.Data) { @@ -34,10 +32,8 @@ export class BillingWebhookCustomerService { } await this.billingCustomerRepository.upsert( - { - stripeCustomerId, - workspaceId, - }, + workspaceId, + { stripeCustomerId }, { conflictPaths: ['workspaceId'], skipUpdateIfNoValuesChanged: true, diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service.ts index 16657a03ed..401416f3fc 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-entitlement.service.ts @@ -16,14 +16,17 @@ import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/ import { BillingEntitlementEntity } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class BillingWebhookEntitlementService { constructor( + // Stripe webhook: workspace discovered from BillingCustomer by stripeCustomerId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(BillingCustomerEntity) private readonly billingCustomerRepository: Repository, - @InjectRepository(BillingEntitlementEntity) - private readonly billingEntitlementRepository: Repository, + @InjectWorkspaceScopedRepository(BillingEntitlementEntity) + private readonly billingEntitlementRepository: WorkspaceScopedRepository, private readonly rowLevelPermissionPredicateGroupService: RowLevelPermissionPredicateGroupService, ) {} @@ -49,10 +52,14 @@ export class BillingWebhookEntitlementService { data, ); - await this.billingEntitlementRepository.upsert(billingEntitlements, { - conflictPaths: ['workspaceId', 'key'], - skipUpdateIfNoValuesChanged: true, - }); + await this.billingEntitlementRepository.upsert( + workspaceId, + billingEntitlements, + { + conflictPaths: ['workspaceId', 'key'], + skipUpdateIfNoValuesChanged: true, + }, + ); const isRowLevelPermissionDisabled = billingEntitlements.some( (entitlement) => diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts index 997dcb9ef2..148e9ce19c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts @@ -33,6 +33,8 @@ export class BillingWebhookInvoiceService { constructor( @InjectRepository(BillingSubscriptionItemEntity) private readonly billingSubscriptionItemRepository: Repository, + // Stripe webhook: workspace discovered from BillingCustomer by stripeCustomerId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(BillingCustomerEntity) private readonly billingCustomerRepository: Repository, @InjectRepository(WorkspaceEntity) @@ -120,6 +122,7 @@ export class BillingWebhookInvoiceService { ): Promise { const params = await this.resourceCreditService.getResourceCreditRolloverParameters( + subscription.workspaceId, subscription.id, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service.ts index 897ce6a4d6..2a6cab509c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription-schedule.service.ts @@ -18,6 +18,8 @@ export class BillingWebhookSubscriptionScheduleService { ); constructor( + // Stripe webhook: subscription lookup by stripeSubscriptionId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(BillingSubscriptionEntity) private readonly billingSubscriptionRepository: Repository, private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService, diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts index bd2eddc445..a78c98c0ed 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-subscription.service.ts @@ -31,6 +31,8 @@ import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queu import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { CleanWorkspaceDeletionWarningUserVarsJob, @@ -47,14 +49,16 @@ export class BillingWebhookSubscriptionService { private readonly stripeCustomerService: StripeCustomerService, @InjectMessageQueue(MessageQueue.workspaceQueue) private readonly messageQueueService: MessageQueueService, + // Stripe webhook upserts conflict-resolve globally on stripeSubscriptionId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(BillingSubscriptionEntity) private readonly billingSubscriptionRepository: Repository, @InjectRepository(BillingSubscriptionItemEntity) private readonly billingSubscriptionItemRepository: Repository, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, private readonly workspaceService: WorkspaceService, private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService, private readonly billingUsageService: BillingUsageService, @@ -99,6 +103,7 @@ export class BillingWebhookSubscriptionService { } await this.billingCustomerRepository.upsert( + workspaceId, transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, data), { conflictPaths: ['workspaceId'], diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing-gauge.service.ts b/packages/twenty-server/src/engine/core-modules/billing/billing-gauge.service.ts index 1b52476dd4..a5df2aa114 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing-gauge.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing-gauge.service.ts @@ -22,6 +22,8 @@ export class BillingGaugeService implements OnModuleInit { private readonly twentyConfigService: TwentyConfigService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + // Observability gauges count subscriptions across every workspace. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(BillingSubscriptionEntity) private readonly billingSubscriptionRepository: Repository, ) {} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 2e9df39283..9a071ab9d2 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -43,6 +43,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ @@ -92,6 +93,9 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache ResourceCreditService, BillingGaugeService, WorkspaceBillingSubscriptionCacheService, + provideWorkspaceScopedRepository(BillingEntitlementEntity), + provideWorkspaceScopedRepository(BillingCustomerEntity), + provideWorkspaceScopedRepository(BillingSubscriptionEntity), ], exports: [ BillingSubscriptionService, diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts index 2c03d89d0d..66147cf3bd 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts @@ -1,17 +1,15 @@ /* @license Enterprise */ -import { InjectRepository } from '@nestjs/typeorm'; - import chalk from 'chalk'; import { Command } from 'nest-commander'; -import { Repository } from 'typeorm'; import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Command({ name: 'billing:sync-customer-data', description: 'Sync customer data from Stripe for all active workspaces', @@ -20,8 +18,8 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo constructor( protected readonly workspaceIteratorService: WorkspaceIteratorService, private readonly stripeSubscriptionService: StripeSubscriptionService, - @InjectRepository(BillingCustomerEntity) - protected readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + protected readonly billingCustomerRepository: WorkspaceScopedRepository, ) { super(workspaceIteratorService); } @@ -30,11 +28,10 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo workspaceId, options, }: RunOnWorkspaceArgs): Promise { - const billingCustomer = await this.billingCustomerRepository.findOne({ - where: { - workspaceId, - }, - }); + const billingCustomer = await this.billingCustomerRepository.findOne( + workspaceId, + { where: {} }, + ); if (!options.dryRun && !billingCustomer) { const stripeCustomerId = @@ -44,13 +41,9 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo if (typeof stripeCustomerId === 'string') { await this.billingCustomerRepository.upsert( - { - stripeCustomerId, - workspaceId, - }, - { - conflictPaths: ['workspaceId'], - }, + workspaceId, + { stripeCustomerId }, + { conflictPaths: ['workspaceId'] }, ); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts index 4629c05ecc..4399734515 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-update-subscription-price.command.ts @@ -1,15 +1,11 @@ /* @license Enterprise */ -import { InjectRepository } from '@nestjs/typeorm'; - import { Command, Option } from 'nest-commander'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; -import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; @@ -24,8 +20,6 @@ export class BillingUpdateSubscriptionPriceCommand extends ActiveOrSuspendedWork constructor( protected readonly workspaceIteratorService: WorkspaceIteratorService, - @InjectRepository(BillingSubscriptionEntity) - protected readonly billingSubscriptionRepository: Repository, private readonly billingSubscriptionService: BillingSubscriptionService, private readonly stripeSubscriptionItemService: StripeSubscriptionItemService, ) { diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit-rollover.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit-rollover.service.spec.ts index fe8be789fe..d0a486df81 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit-rollover.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-credit-rollover.service.spec.ts @@ -1,12 +1,11 @@ /* @license Enterprise */ import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingCreditRolloverService } from 'src/engine/core-modules/billing/services/billing-credit-rollover.service'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; describe('BillingCreditRolloverService', () => { let service: BillingCreditRolloverService; let billingUsageService: jest.Mocked< @@ -25,7 +24,7 @@ describe('BillingCreditRolloverService', () => { }, }, { - provide: getRepositoryToken(BillingCustomerEntity), + provide: getWorkspaceScopedRepositoryToken(BillingCustomerEntity), useValue: { update: jest.fn(), }, @@ -38,7 +37,7 @@ describe('BillingCreditRolloverService', () => { ); billingUsageService = module.get(BillingUsageService); billingCustomerRepository = module.get( - getRepositoryToken(BillingCustomerEntity), + getWorkspaceScopedRepositoryToken(BillingCustomerEntity), ); }); @@ -62,6 +61,7 @@ describe('BillingCreditRolloverService', () => { await service.processRolloverOnPeriodTransition(baseParams); expect(billingCustomerRepository.update).toHaveBeenCalledWith( + 'ws_123', { stripeCustomerId: 'cus_123' }, { creditBalanceMicro: 700 }, ); @@ -75,6 +75,7 @@ describe('BillingCreditRolloverService', () => { await service.processRolloverOnPeriodTransition(baseParams); expect(billingCustomerRepository.update).toHaveBeenCalledWith( + 'ws_123', { stripeCustomerId: 'cus_123' }, { creditBalanceMicro: 1000 }, ); @@ -88,6 +89,7 @@ describe('BillingCreditRolloverService', () => { await service.processRolloverOnPeriodTransition(baseParams); expect(billingCustomerRepository.update).toHaveBeenCalledWith( + 'ws_123', { stripeCustomerId: 'cus_123' }, { creditBalanceMicro: 0 }, ); @@ -101,6 +103,7 @@ describe('BillingCreditRolloverService', () => { await service.processRolloverOnPeriodTransition(baseParams); expect(billingCustomerRepository.update).toHaveBeenCalledWith( + 'ws_123', { stripeCustomerId: 'cus_123' }, { creditBalanceMicro: 0 }, ); @@ -115,6 +118,7 @@ describe('BillingCreditRolloverService', () => { await service.processRolloverOnPeriodTransition(params); expect(billingCustomerRepository.update).toHaveBeenCalledWith( + 'ws_123', { stripeCustomerId: 'cus_123' }, { creditBalanceMicro: 500 }, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts index 8d600323ad..9d0c9586b8 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/billing-subscription-update.service.spec.ts @@ -20,7 +20,8 @@ import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/ser import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; import { SubscriptionUpdateType } from 'src/engine/core-modules/billing/types/billing-subscription-update.type'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; +import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { arrangeBillingPriceRepositoryFindOneOrFail, arrangeBillingProductServiceGetProductPrices, @@ -48,7 +49,7 @@ describe('BillingSubscriptionUpdateService', () => { let module: TestingModule; let service: BillingSubscriptionUpdateService; let billingSubscriptionRepository: jest.Mocked< - Repository + WorkspaceScopedRepository >; let billingPriceRepository: jest.Mocked>; let billingProductService: jest.Mocked; @@ -136,7 +137,7 @@ describe('BillingSubscriptionUpdateService', () => { }, }, { - provide: getRepositoryToken(BillingSubscriptionEntity), + provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity), useValue: repoMock(), }, { @@ -161,7 +162,7 @@ describe('BillingSubscriptionUpdateService', () => { service = module.get(BillingSubscriptionUpdateService); billingSubscriptionRepository = module.get( - getRepositoryToken(BillingSubscriptionEntity), + getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity), ); billingPriceRepository = module.get(getRepositoryToken(BillingPriceEntity)); billingProductService = module.get(BillingProductService); @@ -229,7 +230,7 @@ describe('BillingSubscriptionUpdateService', () => { }) as BillingPriceEntity, ]); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.PLAN, newPlan: BillingPlanKey.ENTERPRISE, }); @@ -355,7 +356,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.PLAN, newPlan: BillingPlanKey.ENTERPRISE, }); @@ -464,7 +465,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.PLAN, newPlan: BillingPlanKey.PRO, }); @@ -577,7 +578,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.PLAN, newPlan: BillingPlanKey.PRO, }); @@ -654,7 +655,7 @@ describe('BillingSubscriptionUpdateService', () => { }) as BillingPriceEntity, ]); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.INTERVAL, newInterval: SubscriptionInterval.Year, }); @@ -775,7 +776,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.INTERVAL, newInterval: SubscriptionInterval.Year, }); @@ -884,7 +885,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.INTERVAL, newInterval: SubscriptionInterval.Month, }); @@ -997,7 +998,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.INTERVAL, newInterval: SubscriptionInterval.Month, }); @@ -1058,7 +1059,7 @@ describe('BillingSubscriptionUpdateService', () => { {}, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.SEATS, newSeats: 2, }); @@ -1162,7 +1163,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.SEATS, newSeats: 2, }); @@ -1236,7 +1237,7 @@ describe('BillingSubscriptionUpdateService', () => { {}, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.SEATS, newSeats: 1, }); @@ -1340,7 +1341,7 @@ describe('BillingSubscriptionUpdateService', () => { } as Stripe.SubscriptionScheduleUpdateParams.Phase, ); - await service.updateSubscription('sub_db_1', { + await service.updateSubscription('ws_1', 'sub_db_1', { type: SubscriptionUpdateType.SEATS, newSeats: 1, }); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/resource-credit.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/resource-credit.service.spec.ts index abf96167d7..7649ac51d7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/resource-credit.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/resource-credit.service.spec.ts @@ -7,7 +7,7 @@ import { BillingPriceEntity } from 'src/engine/core-modules/billing/entities/bil import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; import { ResourceCreditService } from 'src/engine/core-modules/billing/services/resource-credit.service'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; describe('ResourceCreditService', () => { let service: ResourceCreditService; let billingSubscriptionRepository: jest.Mocked; @@ -39,7 +39,7 @@ describe('ResourceCreditService', () => { providers: [ ResourceCreditService, { - provide: getRepositoryToken(BillingSubscriptionEntity), + provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity), useValue: { findOne: jest.fn(), }, @@ -55,7 +55,7 @@ describe('ResourceCreditService', () => { service = module.get(ResourceCreditService); billingSubscriptionRepository = module.get( - getRepositoryToken(BillingSubscriptionEntity), + getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity), ); }); @@ -126,8 +126,10 @@ describe('ResourceCreditService', () => { billingSubscriptionRepository.findOne.mockResolvedValue(subscription); - const result = - await service.getResourceCreditRolloverParameters('sub_123'); + const result = await service.getResourceCreditRolloverParameters( + 'ws_1', + 'sub_123', + ); expect(result).toEqual({ tierQuantity: 5000, unitPriceCents: 5 }); }); @@ -135,8 +137,10 @@ describe('ResourceCreditService', () => { it('returns null when subscription not found', async () => { billingSubscriptionRepository.findOne.mockResolvedValue(null); - const result = - await service.getResourceCreditRolloverParameters('sub_123'); + const result = await service.getResourceCreditRolloverParameters( + 'ws_1', + 'sub_123', + ); expect(result).toBeNull(); }); @@ -146,8 +150,10 @@ describe('ResourceCreditService', () => { billingSubscriptionItems: [], }); - const result = - await service.getResourceCreditRolloverParameters('sub_123'); + const result = await service.getResourceCreditRolloverParameters( + 'ws_1', + 'sub_123', + ); expect(result).toBeNull(); }); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts index 98269f5656..0c195feee6 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/__test__/utils/mock-builders.util.ts @@ -12,7 +12,7 @@ import { type BillingProductService } from 'src/engine/core-modules/billing/serv import { type BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service'; import { type StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service'; import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type'; - +import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { buildSubscription } from './build-subscription.util'; export const repoMock = () => @@ -81,7 +81,7 @@ export const buildDefaultMeteredTiers = ( export const arrangeBillingSubscriptionRepositoryFindOneOrFail = ( billingSubscriptionRepository: jest.Mocked< - Repository + WorkspaceScopedRepository >, params: { planKey?: BillingPlanKey; diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit-rollover.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit-rollover.service.ts index 6aad578eee..21e0b6f502 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit-rollover.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-credit-rollover.service.ts @@ -1,19 +1,17 @@ /* @license Enterprise */ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class BillingCreditRolloverService { constructor( private readonly billingUsageService: BillingUsageService, - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, ) {} async processRolloverOnPeriodTransition({ @@ -37,6 +35,7 @@ export class BillingCreditRolloverService { const rolloverAmount = Math.min(unusedCredits, tierQuantity); await this.billingCustomerRepository.update( + workspaceId, { stripeCustomerId }, { creditBalanceMicro: rolloverAmount }, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index 7c6f66523f..1ada61efe7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -29,7 +29,8 @@ import { type BillingPortalCheckoutSessionParameters } from 'src/engine/core-mod import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class BillingPortalWorkspaceService { protected readonly logger = new Logger(BillingPortalWorkspaceService.name); @@ -38,10 +39,10 @@ export class BillingPortalWorkspaceService { private readonly stripeBillingPortalService: StripeBillingPortalService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly billingSubscriptionService: BillingSubscriptionService, - @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, ) {} @@ -156,10 +157,13 @@ export class BillingPortalWorkspaceService { workspaceId: workspace.id, }); - const customer = await this.billingCustomerRepository.findOne({ - where: { workspaceId: workspace.id }, - relations: ['billingSubscriptions'], - }); + const customer = await this.billingCustomerRepository.findOne( + workspace.id, + { + where: {}, + relations: ['billingSubscriptions'], + }, + ); const stripeSubscriptionLineItems = this.getStripeSubscriptionLineItems({ quantity, @@ -180,13 +184,13 @@ export class BillingPortalWorkspaceService { workspace: WorkspaceEntity, returnUrlPath?: string, ) { - const lastSubscription = await this.billingSubscriptionRepository.findOne({ - where: { - workspaceId: workspace.id, - status: Not(SubscriptionStatus.Canceled), + const lastSubscription = await this.billingSubscriptionRepository.findOne( + workspace.id, + { + where: { status: Not(SubscriptionStatus.Canceled) }, + order: { createdAt: 'DESC' }, }, - order: { createdAt: 'DESC' }, - }); + ); if (!lastSubscription) { throw new Error('Error: missing subscription'); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts index a08041162b..1ee559732c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription-update.service.ts @@ -37,7 +37,8 @@ import { getCurrentLicensedBillingSubscriptionItemOrThrow } from 'src/engine/cor import { getCurrentResourceCreditSubscriptionItemOrThrow } from 'src/engine/core-modules/billing/utils/get-resource-credit-subscription-item-or-throw.util'; import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; export type SubscriptionStripePrices = { licensedPriceId: string; seats: number; @@ -57,8 +58,8 @@ export class BillingSubscriptionUpdateService { private readonly billingPriceRepository: Repository, @InjectRepository(BillingSubscriptionItemEntity) private readonly billingSubscriptionItemRepository: Repository, - @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService, private readonly billingSubscriptionPhaseService: BillingSubscriptionPhaseService, private readonly billingSubscriptionService: BillingSubscriptionService, @@ -77,7 +78,11 @@ export class BillingSubscriptionUpdateService { newResourceCreditPriceId: resourceCreditPriceId, } as const; - await this.updateSubscription(billingSubscription.id, subscriptionUpdate); + await this.updateSubscription( + workspaceId, + billingSubscription.id, + subscriptionUpdate, + ); } async cancelSwitchResourceCreditPrice( @@ -95,7 +100,11 @@ export class BillingSubscriptionUpdateService { newResourceCreditPriceId: currentResourceCreditPrice.stripePriceId, } as const; - await this.updateSubscription(billingSubscription.id, subscriptionUpdate); + await this.updateSubscription( + workspace.id, + billingSubscription.id, + subscriptionUpdate, + ); } async cancelSwitchPlan(workspaceId: string) { @@ -108,7 +117,7 @@ export class BillingSubscriptionUpdateService { getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription) .billingProduct?.metadata.planKey; - await this.updateSubscription(billingSubscription.id, { + await this.updateSubscription(workspaceId, billingSubscription.id, { type: SubscriptionUpdateType.PLAN, newPlan: currentPlan, }); @@ -122,7 +131,7 @@ export class BillingSubscriptionUpdateService { const currentInterval = billingSubscription.interval; - await this.updateSubscription(billingSubscription.id, { + await this.updateSubscription(workspaceId, billingSubscription.id, { type: SubscriptionUpdateType.INTERVAL, newInterval: currentInterval, }); @@ -136,7 +145,7 @@ export class BillingSubscriptionUpdateService { const currentInterval = billingSubscription.interval; - await this.updateSubscription(billingSubscription.id, { + await this.updateSubscription(workspaceId, billingSubscription.id, { type: SubscriptionUpdateType.INTERVAL, newInterval: currentInterval === SubscriptionInterval.Month @@ -155,7 +164,7 @@ export class BillingSubscriptionUpdateService { getCurrentLicensedBillingSubscriptionItemOrThrow(billingSubscription) .billingProduct?.metadata.planKey; - await this.updateSubscription(billingSubscription.id, { + await this.updateSubscription(workspaceId, billingSubscription.id, { type: SubscriptionUpdateType.PLAN, newPlan: currentPlan === BillingPlanKey.ENTERPRISE @@ -170,17 +179,19 @@ export class BillingSubscriptionUpdateService { { workspaceId }, ); - await this.updateSubscription(billingSubscription.id, { + await this.updateSubscription(workspaceId, billingSubscription.id, { type: SubscriptionUpdateType.SEATS, newSeats, }); } async updateSubscription( + workspaceId: string, subscriptionId: string, subscriptionUpdate: SubscriptionUpdate, ): Promise { const subscription = await this.billingSubscriptionRepository.findOneOrFail( + workspaceId, { where: { id: subscriptionId }, relations: [ diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index c1a88b42a0..42727b3530 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -35,7 +35,8 @@ import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/util import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class BillingSubscriptionService { protected readonly logger = new Logger(BillingSubscriptionService.name); @@ -44,38 +45,49 @@ export class BillingSubscriptionService { private readonly stripeSubscriptionService: StripeSubscriptionService, private readonly billingPriceService: BillingPriceService, private readonly billingPlanService: BillingPlanService, - @InjectRepository(BillingEntitlementEntity) - private readonly billingEntitlementRepository: Repository, + @InjectWorkspaceScopedRepository(BillingEntitlementEntity) + private readonly billingEntitlementRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, + // Stripe webhooks resolve by stripeCustomerId before any workspaceId + // is known. Used only when the criteria has no workspaceId. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, + private readonly billingSubscriptionRepositoryUnscoped: Repository, private readonly stripeCustomerService: StripeCustomerService, private readonly twentyConfigService: TwentyConfigService, @InjectRepository(BillingSubscriptionItemEntity) private readonly billingSubscriptionItemRepository: Repository, private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService, - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, private readonly enterprisePlanService: EnterprisePlanService, ) {} async getBillingSubscriptions(workspaceId: string) { - return await this.billingSubscriptionRepository.find({ - where: { workspaceId }, - }); + return await this.billingSubscriptionRepository.find(workspaceId); } async getCurrentBillingSubscription(criteria: { workspaceId?: string; stripeCustomerId?: string; }): Promise { - const notCanceledSubscriptions = - await this.billingSubscriptionRepository.find({ - where: { ...criteria, status: Not(SubscriptionStatus.Canceled) }, - relations: [ - 'billingSubscriptionItems', - 'billingSubscriptionItems.billingProduct', - ], - }); + const baseFindOptions = { + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + ], + }; + + const notCanceledSubscriptions = isDefined(criteria.workspaceId) + ? await this.billingSubscriptionRepository.find(criteria.workspaceId, { + ...baseFindOptions, + where: { status: Not(SubscriptionStatus.Canceled) }, + }) + : await this.billingSubscriptionRepositoryUnscoped.find({ + ...baseFindOptions, + where: { ...criteria, status: Not(SubscriptionStatus.Canceled) }, + }); if (notCanceledSubscriptions.length > 1) { throw new BillingException( @@ -190,9 +202,7 @@ export class BillingSubscriptionService { const hasValidEnterprisePlan = this.enterprisePlanService.isValid(); const entitlements = isBillingEnabled - ? await this.billingEntitlementRepository.find({ - where: { workspaceId }, - }) + ? await this.billingEntitlementRepository.find(workspaceId) : []; const entitlementsByKey = entitlements.reduce( @@ -216,11 +226,10 @@ export class BillingSubscriptionService { workspaceId: string, key: BillingEntitlementKey, ): Promise { - const entitlement = await this.billingEntitlementRepository.findOneBy({ + const entitlement = await this.billingEntitlementRepository.findOne( workspaceId, - key, - value: true, - }); + { where: { key, value: true } }, + ); return entitlement?.value ?? false; } @@ -278,6 +287,7 @@ export class BillingSubscriptionService { ); await this.billingCustomerRepository.upsert( + workspaceId, transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, { object: subscription, }), @@ -288,6 +298,7 @@ export class BillingSubscriptionService { ); await this.billingSubscriptionRepository.upsert( + workspaceId, transformStripeSubscriptionEventToDatabaseSubscription( workspaceId, subscription, @@ -298,9 +309,8 @@ export class BillingSubscriptionService { }, ); - const billingSubscriptions = await this.billingSubscriptionRepository.find({ - where: { workspaceId }, - }); + const billingSubscriptions = + await this.billingSubscriptionRepository.find(workspaceId); const currentBillingSubscription = billingSubscriptions.find( (sub) => sub.stripeSubscriptionId === subscription.id, diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts index 6000bf8ad3..4c2e28e8e4 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-usage.service.ts @@ -1,10 +1,8 @@ /* @license Enterprise */ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { type Repository } from 'typeorm'; import { differenceInDays } from 'date-fns'; import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service'; @@ -27,6 +25,8 @@ import { CacheStorageService } from 'src/engine/core-modules/cache-storage/servi import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; type UsageSumRow = { @@ -37,15 +37,15 @@ type UsageSumRow = { export class BillingUsageService { protected readonly logger = new Logger(BillingUsageService.name); constructor( - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, private readonly billingSubscriptionService: BillingSubscriptionService, private readonly twentyConfigService: TwentyConfigService, private readonly billingSubscriptionItemService: BillingSubscriptionItemService, @InjectCacheStorage(CacheStorageNamespace.EngineBillingUsage) private readonly billingUsageCacheStorage: CacheStorageService, - @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, private readonly workspaceCacheService: WorkspaceCacheService, private readonly clickHouseService: ClickHouseService, private readonly billingUsageCapService: BillingUsageCapService, @@ -123,9 +123,10 @@ export class BillingUsageService { ? item.freeTrialQuantity : item.creditAmount; - const billingCustomer = await this.billingCustomerRepository.findOne({ - where: { workspaceId }, - }); + const billingCustomer = await this.billingCustomerRepository.findOne( + workspaceId, + { where: {} }, + ); const rolloverCredits = billingCustomer?.creditBalanceMicro ?? 0; return { @@ -199,14 +200,17 @@ export class BillingUsageService { workspaceId: string; currentPeriodStart: Date | string; }): Promise { - const subscription = await this.billingSubscriptionRepository.findOne({ - where: { workspaceId, currentPeriodStart: new Date(currentPeriodStart) }, - relations: [ - 'billingSubscriptionItems', - 'billingSubscriptionItems.billingProduct', - 'billingSubscriptionItems.billingProduct.billingPrices', - ], - }); + const subscription = await this.billingSubscriptionRepository.findOne( + workspaceId, + { + where: { currentPeriodStart: new Date(currentPeriodStart) }, + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + 'billingSubscriptionItems.billingProduct.billingPrices', + ], + }, + ); if (!isDefined(subscription)) { throw new BillingException( @@ -218,9 +222,9 @@ export class BillingUsageService { const resourceUsageCap = this.getResourceUsageCap(subscription); const { creditBalanceMicro: creditBalance } = - await this.billingCustomerRepository.findOneOrFail({ + await this.billingCustomerRepository.findOneOrFail(workspaceId, { select: { creditBalanceMicro: true }, - where: { workspaceId }, + where: {}, }); const usage = await this.getCurrentPeriodCreditsUsed( diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts index de12ec91b8..f509f99e9e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts @@ -1,17 +1,16 @@ /* @license Enterprise */ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { type Repository } from 'typeorm'; import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { type BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class BillingService { protected readonly logger = new Logger(BillingService.name); @@ -19,8 +18,8 @@ export class BillingService { private readonly twentyConfigService: TwentyConfigService, private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingProductService: BillingProductService, - @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, ) {} isBillingEnabled() { @@ -34,9 +33,10 @@ export class BillingService { return true; } - const subscription = await this.billingSubscriptionRepository.findOne({ - where: { workspaceId }, - }); + const subscription = await this.billingSubscriptionRepository.findOne( + workspaceId, + { where: {} }, + ); return isDefined(subscription); } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/resource-credit.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/resource-credit.service.ts index c9a2605223..b8b85f0c1f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/resource-credit.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/resource-credit.service.ts @@ -1,14 +1,13 @@ /* @license Enterprise */ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { type Repository } from 'typeorm'; import { BillingSubscriptionEntity } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; export type ResourceCreditPricingInfo = { tierCap: number; unitPriceCents: number; @@ -19,8 +18,8 @@ export class ResourceCreditService { protected readonly logger = new Logger(ResourceCreditService.name); constructor( - @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, ) {} extractResourceCreditPricingInfo( @@ -57,18 +56,24 @@ export class ResourceCreditService { }; } - async getResourceCreditRolloverParameters(subscriptionId: string): Promise<{ + async getResourceCreditRolloverParameters( + workspaceId: string, + subscriptionId: string, + ): Promise<{ tierQuantity: number; unitPriceCents: number; } | null> { - const subscription = await this.billingSubscriptionRepository.findOne({ - where: { id: subscriptionId }, - relations: [ - 'billingSubscriptionItems', - 'billingSubscriptionItems.billingProduct', - 'billingSubscriptionItems.billingProduct.billingPrices', - ], - }); + const subscription = await this.billingSubscriptionRepository.findOne( + workspaceId, + { + where: { id: subscriptionId }, + relations: [ + 'billingSubscriptionItems', + 'billingSubscriptionItems.billingProduct', + 'billingSubscriptionItems.billingProduct.billingPrices', + ], + }, + ); if (!isDefined(subscription)) { return null; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts index 04cb386178..5e41340489 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-customer.service.ts @@ -1,16 +1,14 @@ /* @license Enterprise */ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; import type Stripe from 'stripe'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class StripeCustomerService { protected readonly logger = new Logger(StripeCustomerService.name); @@ -19,8 +17,8 @@ export class StripeCustomerService { constructor( private readonly twentyConfigService: TwentyConfigService, private readonly stripeSDKService: StripeSDKService, - @InjectRepository(BillingCustomerEntity) - private readonly billingCustomerRepository: Repository, + @InjectWorkspaceScopedRepository(BillingCustomerEntity) + private readonly billingCustomerRepository: WorkspaceScopedRepository, ) { if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) { return; @@ -59,9 +57,8 @@ export class StripeCustomerService { }, }); - await this.billingCustomerRepository.save({ + await this.billingCustomerRepository.save(workspaceId, { stripeCustomerId: customer.id, - workspaceId, }); return customer; diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts index adbbf09ba5..8c0f231b3f 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.module.ts @@ -19,7 +19,7 @@ import { StripeInvoiceService } from 'src/engine/core-modules/billing/stripe/ser import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module'; import { BillingCustomerEntity } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ DomainServerConfigModule, @@ -40,6 +40,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain- StripeBillingMeterEventService, StripeCreditGrantService, StripeInvoiceService, + provideWorkspaceScopedRepository(BillingCustomerEntity), ], exports: [ StripeWebhookService, diff --git a/packages/twenty-server/src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service.ts index 30756864a2..186da4ad65 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service.ts @@ -24,6 +24,8 @@ export class CustomDomainManagerService { constructor( @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + // Enforces global uniqueness of a custom domain across all workspaces. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(PublicDomainEntity) private readonly publicDomainRepository: Repository, private readonly billingService: BillingService, diff --git a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts index f34cd2682e..b10033b417 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts @@ -20,6 +20,8 @@ export class WorkspaceDomainsService { private readonly twentyConfigService: TwentyConfigService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, + // Request routing resolves workspace via the public domain registry. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(PublicDomainEntity) private readonly publicDomainRepository: Repository, ) {} diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts index 652fb36019..a6a0d94088 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.module.ts @@ -10,7 +10,7 @@ import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/em import { EmailingDomainResolver } from 'src/engine/core-modules/emailing-domain/emailing-domain.resolver'; import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ TypeORMModule, @@ -24,6 +24,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi EmailingDomainDriverFactory, AwsSesClientProvider, AwsSesHandleErrorService, + provideWorkspaceScopedRepository(EmailingDomainEntity), ], }) export class EmailingDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts index 1f691d283b..64d94f6a9d 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts @@ -1,7 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory'; import { @@ -10,12 +7,13 @@ import { } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain'; import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class EmailingDomainService { constructor( - @InjectRepository(EmailingDomainEntity) - private readonly emailingDomainRepository: Repository, + @InjectWorkspaceScopedRepository(EmailingDomainEntity) + private readonly emailingDomainRepository: WorkspaceScopedRepository, private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory, ) {} @@ -24,10 +22,12 @@ export class EmailingDomainService { driver: EmailingDomainDriver, workspace: WorkspaceEntity, ): Promise { - const existingDomain = await this.emailingDomainRepository.findOneBy({ - domain, - workspaceId: workspace.id, - }); + const existingDomain = await this.emailingDomainRepository.findOne( + workspace.id, + { + where: { domain }, + }, + ); if (existingDomain) { throw new Error('Emailing domain already exists for this workspace'); @@ -39,33 +39,29 @@ export class EmailingDomainService { workspaceId: workspace.id, }); - const domainToCreate = { + return this.emailingDomainRepository.save(workspace.id, { domain, driver, - workspaceId: workspace.id, ...verificationResult, - }; - - const savedDomain = - await this.emailingDomainRepository.save(domainToCreate); - - return savedDomain; + }); } async deleteEmailingDomain( workspace: WorkspaceEntity, emailingDomainId: string, ): Promise { - const emailingDomain = await this.emailingDomainRepository.findOneBy({ - id: emailingDomainId, - workspaceId: workspace.id, - }); + const emailingDomain = await this.emailingDomainRepository.findOne( + workspace.id, + { + where: { id: emailingDomainId }, + }, + ); if (!emailingDomain) { throw new Error('Emailing domain not found'); } - await this.emailingDomainRepository.delete({ + await this.emailingDomainRepository.delete(workspace.id, { id: emailingDomain.id, }); } @@ -73,13 +69,8 @@ export class EmailingDomainService { async getEmailingDomains( workspace: WorkspaceEntity, ): Promise { - return await this.emailingDomainRepository.find({ - where: { - workspaceId: workspace.id, - }, - order: { - createdAt: 'DESC', - }, + return this.emailingDomainRepository.find(workspace.id, { + order: { createdAt: 'DESC' }, }); } @@ -87,9 +78,8 @@ export class EmailingDomainService { workspace: WorkspaceEntity, emailingDomainId: string, ): Promise { - return await this.emailingDomainRepository.findOneBy({ - id: emailingDomainId, - workspaceId: workspace.id, + return this.emailingDomainRepository.findOne(workspace.id, { + where: { id: emailingDomainId }, }); } @@ -116,12 +106,10 @@ export class EmailingDomainService { workspaceId: emailingDomain.workspaceId, }); - const updatedDomain = await this.emailingDomainRepository.save({ + return this.emailingDomainRepository.save(workspace.id, { ...emailingDomain, ...verificationResult, }); - - return updatedDomain; } async syncEmailingDomain( @@ -138,9 +126,8 @@ export class EmailingDomainService { } await this.emailingDomainRepository.update( - { - id: emailingDomainId, - }, + workspace.id, + { id: emailingDomainId }, { verificationRecords: emailingDomain.verificationRecords, status: EmailingDomainStatus.PENDING, @@ -154,14 +141,13 @@ export class EmailingDomainService { workspaceId: emailingDomain.workspaceId, }); - const updatedDomain = await this.emailingDomainRepository.save({ + return this.emailingDomainRepository.save(workspace.id, { ...emailingDomain, ...statusResult, }); - - return updatedDomain; } catch (error) { await this.emailingDomainRepository.update( + workspace.id, { id: emailingDomainId }, { verificationRecords: emailingDomain.verificationRecords, diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts index 97c266761d..d636e2919f 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/feature-flag.module.ts @@ -5,6 +5,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { WorkspaceFeatureFlagsMapCacheModule } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ @@ -15,6 +16,9 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceCacheModule, ], exports: [FeatureFlagService], - providers: [FeatureFlagService], + providers: [ + FeatureFlagService, + provideWorkspaceScopedRepository(FeatureFlagEntity), + ], }) export class FeatureFlagModule {} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts index 775dc0ec61..ccecac554d 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/services/__tests__/feature-flag.service.spec.ts @@ -1,5 +1,4 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; import { FeatureFlagKey } from 'twenty-shared/types'; @@ -11,6 +10,7 @@ import { import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate'; import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; jest.mock( @@ -51,7 +51,7 @@ describe('FeatureFlagService', () => { providers: [ FeatureFlagService, { - provide: getRepositoryToken(FeatureFlagEntity), + provide: getWorkspaceScopedRepositoryToken(FeatureFlagEntity), useValue: mockFeatureFlagRepository, }, { @@ -178,7 +178,8 @@ describe('FeatureFlagService', () => { // Assert expect(mockFeatureFlagRepository.upsert).toHaveBeenCalledWith( - keys.map((key) => ({ workspaceId, key, value: true })), + workspaceId, + keys.map((key) => ({ key, value: true })), { conflictPaths: ['workspaceId', 'key'], skipUpdateIfNoValuesChanged: true, @@ -218,10 +219,9 @@ describe('FeatureFlagService', () => { // Assert expect(result).toEqual(mockFeatureFlag); - expect(mockFeatureFlagRepository.save).toHaveBeenCalledWith({ + expect(mockFeatureFlagRepository.save).toHaveBeenCalledWith(workspaceId, { key: FeatureFlagKey[featureFlag], value, - workspaceId, }); expect( mockWorkspaceCacheService.invalidateAndRecompute, diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts index 4e5e33a2f1..2e857b580f 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/services/feature-flag.service.ts @@ -1,8 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { FeatureFlagKey } from 'twenty-shared/types'; -import { Repository } from 'typeorm'; import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; @@ -14,13 +12,15 @@ import { } from 'src/engine/core-modules/feature-flag/feature-flag.exception'; import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate'; import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @Injectable() export class FeatureFlagService { constructor( - @InjectRepository(FeatureFlagEntity) - private readonly featureFlagRepository: Repository, + @InjectWorkspaceScopedRepository(FeatureFlagEntity) + private readonly featureFlagRepository: WorkspaceScopedRepository, private readonly workspaceCacheService: WorkspaceCacheService, ) {} @@ -64,7 +64,8 @@ export class FeatureFlagService { ): Promise { if (keys.length > 0) { await this.featureFlagRepository.upsert( - keys.map((key) => ({ workspaceId, key, value: true })), + workspaceId, + keys.map((key) => ({ key, value: true })), { conflictPaths: ['workspaceId', 'key'], skipUpdateIfNoValuesChanged: true, @@ -106,25 +107,19 @@ export class FeatureFlagService { ); } - const existingFeatureFlag = await this.featureFlagRepository.findOne({ - where: { - key: featureFlag, - workspaceId: workspaceId, - }, - }); + const existingFeatureFlag = await this.featureFlagRepository.findOne( + workspaceId, + { where: { key: featureFlag } }, + ); const featureFlagToSave = existingFeatureFlag - ? { - ...existingFeatureFlag, - value, - } - : { - key: featureFlag, - value, - workspaceId: workspaceId, - }; + ? { ...existingFeatureFlag, value } + : { key: featureFlag, value }; - const result = await this.featureFlagRepository.save(featureFlagToSave); + const result = await this.featureFlagRepository.save( + workspaceId, + featureFlagToSave, + ); await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ 'featureFlagsMap', diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts index 851ecbc90c..b1c1a4ab33 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts @@ -12,7 +12,7 @@ import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/f import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; describe('FileStorageService', () => { let service: FileStorageService; let fileStorageDriverFactory: FileStorageDriverFactory; @@ -41,7 +41,7 @@ describe('FileStorageService', () => { useValue: mockFileStorageDriverFactory, }, { - provide: getRepositoryToken(FileEntity), + provide: getWorkspaceScopedRepositoryToken(FileEntity), useValue: mockFileRepository, }, { @@ -443,6 +443,7 @@ describe('FileStorageService', () => { expect.objectContaining({ mimeType: 'image/png' }), ); expect(mockFileRepository.upsert).toHaveBeenCalledWith( + 'workspace-123', expect.objectContaining({ mimeType: 'image/png' }), expect.anything(), ); @@ -708,11 +709,13 @@ describe('FileStorageService', () => { filename: 'my-component.mjs', }); - expect(mockFileRepository.delete).toHaveBeenCalledWith({ - path: 'built-front-component/src/components/my-component.mjs', - applicationId: 'app-id', - workspaceId: 'workspace-123', - }); + expect(mockFileRepository.delete).toHaveBeenCalledWith( + 'workspace-123', + { + path: 'built-front-component/src/components/my-component.mjs', + applicationId: 'app-id', + }, + ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts index 8cf4745ead..ee7d9a5dc3 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts @@ -8,7 +8,7 @@ import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/f import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Global() export class FileStorageModule { static forRoot(): DynamicModule { @@ -21,6 +21,7 @@ export class FileStorageModule { providers: [ FileStorageDriverFactory, FileStorageService, + provideWorkspaceScopedRepository(FileEntity), { provide: APP_FILTER, useClass: FileStorageExceptionFilter, diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index f716ed9ecb..f564e2cda9 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -20,7 +20,8 @@ import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-mod import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; export type ResourceIdentifier = { workspaceId: string; applicationUniversalIdentifier: string; @@ -32,8 +33,8 @@ export type ResourceIdentifier = { export class FileStorageService { constructor( private readonly fileStorageDriverFactory: FileStorageDriverFactory, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, ) {} @@ -139,7 +140,7 @@ export class FileStorageService { ? queryRunner.manager.getRepository(ApplicationEntity) : this.applicationRepository; const fileRepository = queryRunner - ? queryRunner.manager.getRepository(FileEntity) + ? this.fileRepository.withManager(queryRunner.manager) : this.fileRepository; const application = await applicationRepository.findOneOrFail({ @@ -170,9 +171,9 @@ export class FileStorageService { }); await fileRepository.upsert( + workspaceId, { path: filePath, - workspaceId, applicationId: application.id, id: fileId, mimeType, @@ -185,11 +186,10 @@ export class FileStorageService { ['path', 'workspaceId', 'applicationId'], ); - return await fileRepository.findOneOrFail({ + return fileRepository.findOneOrFail(workspaceId, { where: { path: filePath, applicationId: application.id, - workspaceId, }, }); } @@ -255,9 +255,8 @@ export class FileStorageService { folderPath: `${workspaceId}/${applicationUniversalIdentifier}/`, }); - await this.fileRepository.delete({ + await this.fileRepository.delete(workspaceId, { applicationId: application.id, - workspaceId, }); } @@ -278,10 +277,9 @@ export class FileStorageService { }, }); - await this.fileRepository.delete({ + await this.fileRepository.delete(params.workspaceId, { path: filePath, applicationId: application.id, - workspaceId: params.workspaceId, }); } @@ -314,10 +312,9 @@ export class FileStorageService { }, }); - await this.fileRepository.delete({ + await this.fileRepository.delete(workspaceId, { path: Like(`${validatedFolderPath}%`), applicationId: application.id, - workspaceId, }); } @@ -330,10 +327,9 @@ export class FileStorageService { workspaceId: string; fileFolder: FileFolder; }): Promise { - const file = await this.fileRepository.findOneOrFail({ + const file = await this.fileRepository.findOneOrFail(workspaceId, { where: { id: fileId, - workspaceId, path: Like(`${fileFolder}/%`), }, }); diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts index 8235bb4e20..a03fd2e58b 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/file-core-picture.module.ts @@ -10,7 +10,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ JwtModule, @@ -20,7 +20,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi FileUrlModule, SecureHttpClientModule, ], - providers: [FileCorePictureService, FileCorePictureResolver], + providers: [ + FileCorePictureService, + FileCorePictureResolver, + provideWorkspaceScopedRepository(FileEntity), + ], exports: [FileCorePictureService], }) export class FileCorePictureModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts index 74e2f7197e..b152397246 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts @@ -23,6 +23,8 @@ import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extra import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { getImageBufferFromUrl } from 'src/utils/image'; @Injectable() @@ -33,8 +35,8 @@ export class FileCorePictureService { private readonly fileStorageService: FileStorageService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, private readonly fileUrlService: FileUrlService, private readonly secureHttpClientService: SecureHttpClientService, ) {} @@ -175,11 +177,10 @@ export class FileCorePictureService { fileId: string; workspaceId: string; }): Promise { - const file = await this.fileRepository.findOneOrFail({ + const file = await this.fileRepository.findOneOrFail(workspaceId, { where: { id: fileId, path: Like(`${FileFolder.CorePicture}/%`), - workspaceId, }, }); @@ -287,13 +288,15 @@ export class FileCorePictureService { targetApplicationUniversalIdentifier?: string; queryRunner?: QueryRunner; }): Promise { - const sourceFile = await this.fileRepository.findOneOrFail({ - where: { - id: sourceFileId, - workspaceId: sourceWorkspaceId, - path: Like(`${FileFolder.CorePicture}/%`), + const sourceFile = await this.fileRepository.findOneOrFail( + sourceWorkspaceId, + { + where: { + id: sourceFileId, + path: Like(`${FileFolder.CorePicture}/%`), + }, }, - }); + ); const sourceApplicationUniversalIdentifier = await this.findCustomApplicationUniversalIdentifier(sourceWorkspaceId); diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts index aec7d0d78c..dc9cc4f6df 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts @@ -11,7 +11,7 @@ import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { FileController } from './controllers/file.controller'; import { FileEntity } from './entities/file.entity'; import { FileCorePictureModule } from './file-core-picture/file-core-picture.module'; @@ -42,6 +42,7 @@ import { FileService } from './services/file.service'; FileByIdGuard, FileWorkspaceFolderDeletionJob, FileDeletionJob, + provideWorkspaceScopedRepository(FileEntity), ], exports: [ FileService, diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts index c12d38cb22..8744809358 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts @@ -6,7 +6,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; import { FileService } from './file.service'; jest.mock('uuid', () => ({ @@ -33,7 +33,7 @@ describe('FileService', () => { useValue: {}, }, { - provide: getRepositoryToken(FileEntity), + provide: getWorkspaceScopedRepositoryToken(FileEntity), useValue: {}, }, { diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index a13c57a72d..3489090e6c 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -18,6 +18,8 @@ import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-co import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; @Injectable() @@ -28,8 +30,8 @@ export class FileService { private readonly jwtWrapperService: JwtWrapperService, private readonly fileStorageService: FileStorageService, private readonly twentyConfigService: TwentyConfigService, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, ) {} @@ -56,10 +58,9 @@ export class FileService { return null; } - const file = await this.fileRepository.findOne({ + const file = await this.fileRepository.findOne(workspaceId, { where: { path: `${fileFolder}/${filepath}`, - workspaceId, applicationId, }, }); @@ -101,10 +102,9 @@ export class FileService { workspaceId: string; fileFolder: FileFolder; }): Promise<{ stream: Readable; mimeType: string } | null> { - const file = await this.fileRepository.findOne({ + const file = await this.fileRepository.findOne(workspaceId, { where: { id: fileId, - workspaceId, path: Like(`${fileFolder}/%`), }, }); @@ -157,10 +157,9 @@ export class FileService { workspaceId: string; fileFolder: FileFolder; }): Promise { - const file = await this.fileRepository.findOne({ + const file = await this.fileRepository.findOne(params.workspaceId, { where: { id: params.fileId, - workspaceId: params.workspaceId, path: Like(`${params.fileFolder}/%`), }, }); @@ -230,10 +229,9 @@ export class FileService { workspaceId: string; fileFolder: FileFolder; }): Promise<{ buffer: Buffer; mimeType: string } | null> { - const file = await this.fileRepository.findOne({ + const file = await this.fileRepository.findOne(workspaceId, { where: { id: fileId, - workspaceId, path: Like(`${fileFolder}/%`), }, }); diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts b/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts index fcac605bba..e31cafff9b 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job.ts @@ -14,6 +14,8 @@ export const CHECK_PUBLIC_DOMAINS_VALID_RECORDS_CRON_PATTERN = '0 * * * *'; @Processor(MessageQueue.cronQueue) export class CheckPublicDomainsValidRecordsCronJob { constructor( + // Cron sweeps unvalidated domains across every workspace. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(PublicDomainEntity) private readonly publicDomainRepository: Repository, private readonly publicDomainService: PublicDomainService, diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts index 23e3d0496c..9f5f9c809a 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.module.ts @@ -11,7 +11,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent import { CheckPublicDomainsValidRecordsCronCommand } from 'src/engine/core-modules/public-domain/crons/commands/check-public-domains-valid-records.cron.command'; import { CheckPublicDomainsValidRecordsCronJob } from 'src/engine/core-modules/public-domain/crons/jobs/check-public-domains-valid-records.cron.job'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ NestjsQueryTypeOrmModule.forFeature([ @@ -28,6 +28,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi PublicDomainResolver, CheckPublicDomainsValidRecordsCronCommand, CheckPublicDomainsValidRecordsCronJob, + provideWorkspaceScopedRepository(PublicDomainEntity), ], }) export class PublicDomainModule {} diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts index eb49981450..4993874e1f 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts @@ -1,11 +1,11 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records'; import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service'; @@ -39,8 +39,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @MetadataResolver() export class PublicDomainResolver { constructor( - @InjectRepository(PublicDomainEntity) - private readonly publicDomainRepository: Repository, + @InjectWorkspaceScopedRepository(PublicDomainEntity) + private readonly publicDomainRepository: WorkspaceScopedRepository, private readonly publicDomainService: PublicDomainService, private readonly dnsManagerService: DnsManagerService, ) {} @@ -49,9 +49,7 @@ export class PublicDomainResolver { async findManyPublicDomains( @AuthWorkspace() currentWorkspace: WorkspaceEntity, ): Promise { - return await this.publicDomainRepository.find({ - where: { workspaceId: currentWorkspace.id }, - }); + return this.publicDomainRepository.find(currentWorkspace.id); } @Mutation(() => PublicDomainDTO) @@ -96,9 +94,10 @@ export class PublicDomainResolver { @Args() { domain }: PublicDomainInput, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - const publicDomain = await this.publicDomainRepository.findOne({ - where: { workspaceId: workspace.id, domain }, - }); + const publicDomain = await this.publicDomainRepository.findOne( + workspace.id, + { where: { domain } }, + ); assertIsDefinedOrThrow( publicDomain, diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts index 66c53c4170..c1792fdde8 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.service.ts @@ -16,13 +16,18 @@ import { } from 'src/engine/core-modules/public-domain/public-domain.exception'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class PublicDomainService { constructor( private readonly dnsManagerService: DnsManagerService, + @InjectWorkspaceScopedRepository(PublicDomainEntity) + private readonly publicDomainRepository: WorkspaceScopedRepository, + // Hostname-to-workspace resolution at request-routing time, before workspace context exists. + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository @InjectRepository(PublicDomainEntity) - private readonly publicDomainRepository: Repository, + private readonly publicDomainRepositoryUnscoped: Repository, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, @InjectRepository(ApplicationEntity) @@ -42,9 +47,8 @@ export class PublicDomainService { isPublicDomain: true, }); - await this.publicDomainRepository.delete({ + await this.publicDomainRepository.delete(workspace.id, { domain: formattedDomain, - workspaceId: workspace.id, }); } @@ -62,9 +66,8 @@ export class PublicDomainService { const [workspaceWithCustomDomain, existingPublicDomain, application] = await Promise.all([ this.workspaceRepository.findOneBy({ customDomain: formattedDomain }), - this.publicDomainRepository.findOneBy({ - domain: formattedDomain, - workspaceId: workspace.id, + this.publicDomainRepository.findOne(workspace.id, { + where: { domain: formattedDomain }, }), isDefined(applicationId) ? this.applicationRepository.findOneBy({ @@ -101,11 +104,11 @@ export class PublicDomainService { ); } - const publicDomain = this.publicDomainRepository.create({ + const publicDomain = { domain: formattedDomain, workspaceId: workspace.id, applicationId, - }); + } as PublicDomainEntity; await this.dnsManagerService.registerHostname(formattedDomain, { isPublicDomain: true, @@ -113,9 +116,8 @@ export class PublicDomainService { try { await this.publicDomainRepository.insert( - publicDomain as QueryDeepPartialEntity< - Omit - >, + workspace.id, + publicDomain as QueryDeepPartialEntity, ); } catch (error) { await this.dnsManagerService.deleteHostnameSilently(formattedDomain, { @@ -140,9 +142,8 @@ export class PublicDomainService { const formattedDomain = domain.trim().toLowerCase(); const [publicDomain, application] = await Promise.all([ - this.publicDomainRepository.findOneBy({ - domain: formattedDomain, - workspaceId: workspace.id, + this.publicDomainRepository.findOne(workspace.id, { + where: { domain: formattedDomain }, }), isDefined(applicationId) ? this.applicationRepository.findOneBy({ @@ -168,7 +169,7 @@ export class PublicDomainService { publicDomain.applicationId = applicationId; - return this.publicDomainRepository.save(publicDomain); + return this.publicDomainRepository.save(workspace.id, publicDomain); } async checkPublicDomainValidRecords( @@ -194,13 +195,16 @@ export class PublicDomainService { if (publicDomain.isValidated !== isCustomDomainWorking) { publicDomain.isValidated = isCustomDomainWorking; - await this.publicDomainRepository.save(publicDomain); + await this.publicDomainRepository.save( + publicDomain.workspaceId, + publicDomain, + ); } return publicDomainWithRecords; } async findByDomain(domain: string) { - return this.publicDomainRepository.findOne({ where: { domain } }); + return this.publicDomainRepositoryUnscoped.findOne({ where: { domain } }); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 19948b9bcd..333ae79821 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -21,7 +21,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module'; import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ MessagingImportManagerModule, @@ -45,6 +45,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou SearchHelpCenterTool, CodeInterpreterTool, NavigateAppTool, + provideWorkspaceScopedRepository(FileEntity), ], exports: [ HttpTool, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts index fd03b6d6fb..29f3f90cd8 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts @@ -27,6 +27,8 @@ import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/to import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; 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 { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity'; import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity'; import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message'; @@ -45,8 +47,8 @@ export class EmailComposerService { private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, @InjectRepository(ConnectedAccountEntity) private readonly connectedAccountRepository: Repository, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, private readonly fileService: FileService, ) {} @@ -191,8 +193,8 @@ export class EmailComposerService { const fileIds = files.map((file) => file.id); - const fileEntities = await this.fileRepository.find({ - where: { id: In(fileIds), workspaceId }, + const fileEntities = await this.fileRepository.find(workspaceId, { + where: { id: In(fileIds) }, }); const fileEntityMap = new Map( diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts index c66f5da7e5..eeefe4c46e 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.module.ts @@ -10,7 +10,7 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { UserModule } from 'src/engine/core-modules/user/user.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { TwoFactorAuthenticationResolver } from './two-factor-authentication.resolver'; import { TwoFactorAuthenticationService } from './two-factor-authentication.service'; @@ -38,6 +38,7 @@ import { SimpleSecretEncryptionUtil } from './utils/simple-secret-encryption.uti TwoFactorAuthenticationService, TwoFactorAuthenticationResolver, SimpleSecretEncryptionUtil, + provideWorkspaceScopedRepository(TwoFactorAuthenticationMethodEntity), ], exports: [TwoFactorAuthenticationService], }) 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 52c7297a0f..23e70ad4ce 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 @@ -1,6 +1,5 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; import { AuthException, AuthExceptionCode, @@ -105,7 +104,9 @@ describe('TwoFactorAuthenticationResolver', () => { useFactory: createMockWorkspaceDomainsService, }, { - provide: getRepositoryToken(TwoFactorAuthenticationMethodEntity), + provide: getWorkspaceScopedRepositoryToken( + TwoFactorAuthenticationMethodEntity, + ), useFactory: createMockRepository, }, ], @@ -119,7 +120,7 @@ describe('TwoFactorAuthenticationResolver', () => { userService = module.get(UserService); workspaceDomainsService = module.get(WorkspaceDomainsService); repository = module.get( - getRepositoryToken(TwoFactorAuthenticationMethodEntity), + getWorkspaceScopedRepositoryToken(TwoFactorAuthenticationMethodEntity), ); }); @@ -288,15 +289,13 @@ describe('TwoFactorAuthenticationResolver', () => { ); expect(result).toEqual({ success: true }); - expect(repository.findOne).toHaveBeenCalledWith({ - where: { - id: mockInput.twoFactorAuthenticationMethodId, - }, + expect(repository.findOne).toHaveBeenCalledWith(mockWorkspace.id, { + where: { id: mockInput.twoFactorAuthenticationMethodId }, relations: ['userWorkspace'], }); - expect(repository.delete).toHaveBeenCalledWith( - mockInput.twoFactorAuthenticationMethodId, - ); + expect(repository.delete).toHaveBeenCalledWith(mockWorkspace.id, { + id: mockInput.twoFactorAuthenticationMethodId, + }); }); it('should throw INVALID_INPUT when method is not found', async () => { @@ -340,31 +339,6 @@ describe('TwoFactorAuthenticationResolver', () => { ), ); }); - - it('should throw FORBIDDEN_EXCEPTION when workspace does not match', async () => { - const wrongWorkspaceMethod = { - ...mockTwoFactorMethod, - userWorkspace: { - userId: mockUser.id, - workspaceId: 'different-workspace-id', - }, - }; - - repository.findOne.mockResolvedValue(wrongWorkspaceMethod); - - await expect( - resolver.deleteTwoFactorAuthenticationMethod( - mockInput, - mockWorkspace, - mockUser, - ), - ).rejects.toThrow( - new AuthException( - 'You can only delete your own two-factor authentication methods', - AuthExceptionCode.FORBIDDEN_EXCEPTION, - ), - ); - }); }); describe('verifyTwoFactorAuthenticationMethodForAuthenticatedUser', () => { diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts index eca8b03885..f1dea4a565 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.resolver.ts @@ -1,10 +1,10 @@ import { UseFilters, UseGuards } from '@nestjs/common'; import { Args, Mutation } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { AuthException, @@ -43,8 +43,8 @@ export class TwoFactorAuthenticationResolver { private readonly loginTokenService: LoginTokenService, private readonly userService: UserService, private readonly workspaceDomainsService: WorkspaceDomainsService, - @InjectRepository(TwoFactorAuthenticationMethodEntity) - private readonly twoFactorAuthenticationMethodRepository: Repository, + @InjectWorkspaceScopedRepository(TwoFactorAuthenticationMethodEntity) + private readonly twoFactorAuthenticationMethodRepository: WorkspaceScopedRepository, ) {} @Mutation(() => InitiateTwoFactorAuthenticationProvisioningDTO) @@ -132,7 +132,7 @@ export class TwoFactorAuthenticationResolver { @AuthUser() user: AuthContextUser, ): Promise { const twoFactorMethod = - await this.twoFactorAuthenticationMethodRepository.findOne({ + await this.twoFactorAuthenticationMethodRepository.findOne(workspace.id, { where: { id: deleteTwoFactorAuthenticationMethodInput.twoFactorAuthenticationMethodId, }, @@ -146,19 +146,16 @@ export class TwoFactorAuthenticationResolver { ); } - if ( - twoFactorMethod.userWorkspace.userId !== user.id || - twoFactorMethod.userWorkspace.workspaceId !== workspace.id - ) { + if (twoFactorMethod.userWorkspace.userId !== user.id) { throw new AuthException( 'You can only delete your own two-factor authentication methods', AuthExceptionCode.FORBIDDEN_EXCEPTION, ); } - await this.twoFactorAuthenticationMethodRepository.delete( - deleteTwoFactorAuthenticationMethodInput.twoFactorAuthenticationMethodId, - ); + await this.twoFactorAuthenticationMethodRepository.delete(workspace.id, { + id: deleteTwoFactorAuthenticationMethodInput.twoFactorAuthenticationMethodId, + }); return { success: true }; } diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts index b3deaac65c..df1e36c1b0 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.spec.ts @@ -1,5 +1,4 @@ import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types'; @@ -10,7 +9,7 @@ import { import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; import { TwoFactorAuthenticationException, TwoFactorAuthenticationExceptionCode, @@ -79,7 +78,9 @@ describe('TwoFactorAuthenticationService', () => { providers: [ TwoFactorAuthenticationService, { - provide: getRepositoryToken(TwoFactorAuthenticationMethodEntity), + provide: getWorkspaceScopedRepositoryToken( + TwoFactorAuthenticationMethodEntity, + ), useValue: { findOne: jest.fn(), save: jest.fn(), @@ -111,7 +112,7 @@ describe('TwoFactorAuthenticationService', () => { TwoFactorAuthenticationService, ); repository = module.get( - getRepositoryToken(TwoFactorAuthenticationMethodEntity), + getWorkspaceScopedRepositoryToken(TwoFactorAuthenticationMethodEntity), ); userWorkspaceService = module.get(UserWorkspaceService); @@ -201,9 +202,8 @@ describe('TwoFactorAuthenticationService', () => { rawSecret, { workspaceId: workspace.id }, ); - expect(repository.save).toHaveBeenCalledWith({ + expect(repository.save).toHaveBeenCalledWith(workspace.id, { id: undefined, - workspaceId: workspace.id, userWorkspace: mockUserWorkspace, secret: encryptedSecret, status: 'PENDING', @@ -223,6 +223,7 @@ describe('TwoFactorAuthenticationService', () => { ); expect(repository.save).toHaveBeenCalledWith( + workspace.id, expect.objectContaining({ secret: encryptedSecret, status: 'PENDING', @@ -251,6 +252,7 @@ describe('TwoFactorAuthenticationService', () => { 'otpauth://totp/test@example.com?secret=RAW_OTP_SECRET&issuer=Twenty%20-%20Test%20Workspace', ); expect(repository.save).toHaveBeenCalledWith( + workspace.id, expect.objectContaining({ id: existingMethod.id, secret: encryptedSecret, @@ -374,6 +376,7 @@ describe('TwoFactorAuthenticationService', () => { // Should create new method since existing one is too old // (Don't check if totpStrategyMocks.initiate was called due to mocking complexity) expect(repository.save).toHaveBeenCalledWith( + workspace.id, expect.objectContaining({ id: existingMethod.id, secret: encryptedSecret, @@ -437,6 +440,7 @@ describe('TwoFactorAuthenticationService', () => { // Should create new method since createdAt is null // (Don't check if totpStrategyMocks.initiate was called due to mocking complexity) expect(repository.save).toHaveBeenCalledWith( + workspace.id, expect.objectContaining({ id: existingMethod.id, secret: encryptedSecret, @@ -484,6 +488,7 @@ describe('TwoFactorAuthenticationService', () => { }); expect(repository.save).toHaveBeenCalledWith( + workspace.id, expect.objectContaining({ status: OTPStatus.VERIFIED, }), @@ -630,6 +635,7 @@ describe('TwoFactorAuthenticationService', () => { }); expect(repository.save).toHaveBeenCalledWith( + workspace.id, expect.objectContaining({ status: OTPStatus.VERIFIED, }), diff --git a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts index c853fc0582..5db5708216 100644 --- a/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts +++ b/packages/twenty-server/src/engine/core-modules/two-factor-authentication/two-factor-authentication.service.ts @@ -1,10 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { authenticator } from 'otplib'; import { TwoFactorAuthenticationStrategy } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; import { AuthException, @@ -16,6 +14,8 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity'; import { TOTP_DEFAULT_CONFIGURATION } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/totp/constants/totp.strategy.constants'; import { TotpStrategy } from 'src/engine/core-modules/two-factor-authentication/strategies/otp/totp/totp.strategy'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -43,8 +43,8 @@ const buildLegacyTotpCbcPurpose = ( // oxlint-disable-next-line twenty/inject-workspace-repository export class TwoFactorAuthenticationService { constructor( - @InjectRepository(TwoFactorAuthenticationMethodEntity) - private readonly twoFactorAuthenticationMethodRepository: Repository, + @InjectWorkspaceScopedRepository(TwoFactorAuthenticationMethodEntity) + private readonly twoFactorAuthenticationMethodRepository: WorkspaceScopedRepository, private readonly userWorkspaceService: UserWorkspaceService, private readonly secretEncryptionService: SecretEncryptionService, private readonly simpleSecretEncryptionUtil: SimpleSecretEncryptionUtil, @@ -116,7 +116,7 @@ export class TwoFactorAuthenticationService { }); const existing2FAMethod = - await this.twoFactorAuthenticationMethodRepository.findOne({ + await this.twoFactorAuthenticationMethodRepository.findOne(workspaceId, { where: { userWorkspace: { id: userWorkspace.id }, strategy: TwoFactorAuthenticationStrategy.TOTP, @@ -161,9 +161,8 @@ export class TwoFactorAuthenticationService { { workspaceId }, ); - await this.twoFactorAuthenticationMethodRepository.save({ + await this.twoFactorAuthenticationMethodRepository.save(workspaceId, { id: existing2FAMethod?.id, - workspaceId, userWorkspace: userWorkspace, secret: encryptedSecret, status: context.status, @@ -180,7 +179,7 @@ export class TwoFactorAuthenticationService { twoFactorAuthenticationStrategy: TwoFactorAuthenticationStrategy, ) { const userTwoFactorAuthenticationMethod = - await this.twoFactorAuthenticationMethodRepository.findOne({ + await this.twoFactorAuthenticationMethodRepository.findOne(workspaceId, { where: { strategy: twoFactorAuthenticationStrategy, userWorkspace: { @@ -226,7 +225,7 @@ export class TwoFactorAuthenticationService { ); } - await this.twoFactorAuthenticationMethodRepository.save({ + await this.twoFactorAuthenticationMethodRepository.save(workspaceId, { ...userTwoFactorAuthenticationMethod, status: OTPStatus.VERIFIED, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module.ts index 9bf3ce0f00..185821ff9e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module.ts @@ -9,7 +9,7 @@ import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.mod import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { AgentTurnEvaluationEntity } from './entities/agent-turn-evaluation.entity'; import { EvaluateAgentTurnJob } from './jobs/evaluate-agent-turn.job'; import { RunEvaluationInputJob } from './jobs/run-evaluation-input.job'; @@ -35,6 +35,10 @@ import { AgentTurnGraderService } from './services/agent-turn-grader.service'; AgentTurnResolver, EvaluateAgentTurnJob, RunEvaluationInputJob, + provideWorkspaceScopedRepository(AgentTurnEvaluationEntity), + provideWorkspaceScopedRepository(AgentTurnEntity), + provideWorkspaceScopedRepository(AgentChatThreadEntity), + provideWorkspaceScopedRepository(AgentEntity), ], exports: [AgentTurnGraderService], }) diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/evaluate-agent-turn.job.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/evaluate-agent-turn.job.ts index db38f6a5c7..1d6aa91ce3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/evaluate-agent-turn.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/evaluate-agent-turn.job.ts @@ -26,7 +26,10 @@ export class EvaluateAgentTurnJob { throw new Error('Workspace ID is required'); } - const evaluation = await this.graderService.evaluateTurn(data.turnId); + const evaluation = await this.graderService.evaluateTurn({ + turnId: data.turnId, + workspaceId: data.workspaceId, + }); this.logger.log( `Evaluation completed for turn ${data.turnId}: score=${evaluation.score}`, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts index 424723046a..f9e8f2c8c8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts @@ -1,7 +1,4 @@ import { Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; @@ -11,7 +8,8 @@ import { MessageQueueService } from 'src/engine/core-modules/message-queue/servi import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { EvaluateAgentTurnJob } from './evaluate-agent-turn.job'; export type RunEvaluationInputJobData = { @@ -27,8 +25,8 @@ export class RunEvaluationInputJob { private readonly logger = new Logger(RunEvaluationInputJob.name); constructor( - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, private readonly agentChatService: AgentChatService, private readonly aiAgentExecutorService: AgentAsyncExecutorService, @InjectMessageQueue(MessageQueue.aiQueue) @@ -47,7 +45,7 @@ export class RunEvaluationInputJob { workspaceId: data.workspaceId, }); - const agent = await this.agentRepository.findOne({ + const agent = await this.agentRepository.findOne(data.workspaceId, { where: { id: data.agentId }, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts index bbdaa5a750..f9a4d18d3f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/resolvers/agent-turn.resolver.ts @@ -1,10 +1,8 @@ import { Logger, UseGuards } from '@nestjs/common'; import { Args, Mutation, Query } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; import { PermissionFlagType } from 'twenty-shared/constants'; -import { Repository } from 'typeorm'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -22,28 +20,32 @@ import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-executi import { AgentTurnEvaluationDTO } from 'src/engine/metadata-modules/ai/ai-agent-monitor/dtos/agent-turn-evaluation.dto'; import { RunEvaluationInputJob } from 'src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job'; import { AgentTurnGraderService } from 'src/engine/metadata-modules/ai/ai-agent-monitor/services/agent-turn-grader.service'; +import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI)) @MetadataResolver() export class AgentTurnResolver { private readonly logger = new Logger(AgentTurnResolver.name); constructor( - @InjectRepository(AgentTurnEntity) - private readonly turnRepository: Repository, - @InjectRepository(AgentChatThreadEntity) - private readonly threadRepository: Repository, + @InjectWorkspaceScopedRepository(AgentTurnEntity) + private readonly turnRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, @InjectMessageQueue(MessageQueue.aiQueue) private readonly messageQueueService: MessageQueueService, private readonly graderService: AgentTurnGraderService, + private readonly agentService: AgentService, ) {} @Query(() => [AgentTurnDTO]) async agentTurns( @Args('agentId', { type: () => UUIDScalarType }) agentId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { - return this.turnRepository.find({ + return this.turnRepository.find(workspaceId, { where: { agentId }, relations: ['evaluations', 'messages', 'messages.parts'], order: { createdAt: 'DESC' }, @@ -53,10 +55,9 @@ export class AgentTurnResolver { @Mutation(() => AgentTurnEvaluationDTO) async evaluateAgentTurn( @Args('turnId', { type: () => UUIDScalarType }) turnId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { - const evaluation = await this.graderService.evaluateTurn(turnId); - - return evaluation; + return this.graderService.evaluateTurn({ turnId, workspaceId }); } @Mutation(() => AgentTurnDTO) @@ -66,19 +67,23 @@ export class AgentTurnResolver { @AuthWorkspace() workspace: WorkspaceEntity, @AuthUserWorkspaceId() userWorkspaceId: string, ): Promise { - const thread = this.threadRepository.create({ - userWorkspaceId, + // Resolver-level ownership check: throws if the agent doesn't belong + // to the caller's workspace. Defense in depth: the job also re-fetches + // the agent through a workspace-scoped repository. + await this.agentService.findOneAgentById({ + id: agentId, workspaceId: workspace.id, + }); + + const savedThread = await this.threadRepository.save(workspace.id, { + userWorkspaceId, title: `Eval: ${input.substring(0, 50)}...`, }); - const savedThread = await this.threadRepository.save(thread); - const turn = this.turnRepository.create({ + const savedTurn = await this.turnRepository.save(workspace.id, { threadId: savedThread.id, agentId, - workspaceId: workspace.id, }); - const savedTurn = await this.turnRepository.save(turn); await this.messageQueueService.add<{ turnId: string; @@ -94,7 +99,7 @@ export class AgentTurnResolver { workspaceId: workspace.id, }); - const turnWithRelations = await this.turnRepository.findOne({ + const turnWithRelations = await this.turnRepository.findOne(workspace.id, { where: { id: savedTurn.id }, relations: ['evaluations', 'messages', 'messages.parts'], }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/services/agent-turn-grader.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/services/agent-turn-grader.service.ts index d7c781b141..60657b9d62 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/services/agent-turn-grader.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/services/agent-turn-grader.service.ts @@ -1,47 +1,53 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { msg } from '@lingui/core/macro'; import { generateText } from 'ai'; -import { Repository } from 'typeorm'; +import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity'; import { AgentTurnEvaluationEntity } from 'src/engine/metadata-modules/ai/ai-agent-monitor/entities/agent-turn-evaluation.entity'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class AgentTurnGraderService { private readonly logger = new Logger(AgentTurnGraderService.name); constructor( - @InjectRepository(AgentTurnEntity) - private readonly turnRepository: Repository, - @InjectRepository(AgentTurnEvaluationEntity) - private readonly evaluationRepository: Repository, + @InjectWorkspaceScopedRepository(AgentTurnEntity) + private readonly turnRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentTurnEvaluationEntity) + private readonly evaluationRepository: WorkspaceScopedRepository, private readonly aiModelRegistryService: AiModelRegistryService, ) {} - async evaluateTurn(turnId: string): Promise { - const turn = await this.turnRepository.findOne({ + async evaluateTurn({ + turnId, + workspaceId, + }: { + turnId: string; + workspaceId: string; + }): Promise { + const turn = await this.turnRepository.findOne(workspaceId, { where: { id: turnId }, relations: ['messages', 'messages.parts'], }); if (!turn) { - throw new Error(`Turn ${turnId} not found`); + throw new NotFoundError(`Turn ${turnId} not found`, { + userFriendlyMessage: msg`This evaluation target could not be found.`, + }); } const { score, comment } = await this.evaluateWithAI(turn); - const evaluation = this.evaluationRepository.create({ + return this.evaluationRepository.save(workspaceId, { turnId, - workspaceId: turn.workspaceId, score, comment, }); - - return this.evaluationRepository.save(evaluation); } private async evaluateWithAI( diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts index de4c683645..94d37e9369 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts @@ -13,12 +13,13 @@ import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-targe 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 { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; - +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; +import { type WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { AiAgentRoleService } from './ai-agent-role.service'; describe('AiAgentRoleService', () => { let service: AiAgentRoleService; - let agentRepository: Repository; + let agentRepository: WorkspaceScopedRepository; let roleRepository: Repository; let roleTargetRepository: Repository; let roleTargetService: RoleTargetService; @@ -33,9 +34,10 @@ describe('AiAgentRoleService', () => { providers: [ AiAgentRoleService, { - provide: getRepositoryToken(AgentEntity), + provide: getWorkspaceScopedRepositoryToken(AgentEntity), useValue: { findOne: jest.fn(), + find: jest.fn(), save: jest.fn(), }, }, @@ -66,8 +68,8 @@ describe('AiAgentRoleService', () => { }).compile(); service = module.get(AiAgentRoleService); - agentRepository = module.get>( - getRepositoryToken(AgentEntity), + agentRepository = module.get>( + getWorkspaceScopedRepositoryToken(AgentEntity), ); roleRepository = module.get>( getRepositoryToken(RoleEntity), @@ -148,8 +150,8 @@ describe('AiAgentRoleService', () => { }); // Assert - expect(agentRepository.findOne).toHaveBeenCalledWith({ - where: { id: testAgent.id, workspaceId: testWorkspaceId }, + expect(agentRepository.findOne).toHaveBeenCalledWith(testWorkspaceId, { + where: { id: testAgent.id }, }); expect(roleRepository.findOne).toHaveBeenCalledWith({ where: { id: testRole.id, workspaceId: testWorkspaceId }, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module.ts index 5794ecd21f..c426287f1d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module.ts @@ -5,7 +5,7 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { AiAgentRoleService } from './ai-agent-role.service'; @Module({ @@ -13,7 +13,10 @@ import { AiAgentRoleService } from './ai-agent-role.service'; TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetEntity]), RoleTargetModule, ], - providers: [AiAgentRoleService], + providers: [ + AiAgentRoleService, + provideWorkspaceScopedRepository(AgentEntity), + ], exports: [AiAgentRoleService], }) export class AiAgentRoleModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts index bb5616c931..9388475e60 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts @@ -12,12 +12,13 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag 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 { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @Injectable() export class AiAgentRoleService { constructor( - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, @InjectRepository(RoleEntity) private readonly roleRepository: Repository, @InjectRepository(RoleTargetEntity) @@ -101,11 +102,8 @@ export class AiAgentRoleService { return []; } - const agents = await this.agentRepository.find({ - where: { - id: In(agentIds), - workspaceId, - }, + const agents = await this.agentRepository.find(workspaceId, { + where: { id: In(agentIds) }, }); return agents; @@ -120,8 +118,8 @@ export class AiAgentRoleService { workspaceId: string; roleId: string; }) { - const agent = await this.agentRepository.findOne({ - where: { id: agentId, workspaceId }, + const agent = await this.agentRepository.findOne(workspaceId, { + where: { id: agentId }, }); if (!agent) { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts index 3f2599b847..b2a2b75f03 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { ILike, IsNull, Repository } from 'typeorm'; +import { ILike, IsNull } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input'; @@ -12,6 +11,8 @@ import { fromUpdateAgentInputToFlatAgentToUpdate } from 'src/engine/metadata-mod import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; @@ -26,8 +27,8 @@ import { AgentEntity } from './entities/agent.entity'; @Injectable() export class AgentService { constructor( - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly applicationService: ApplicationService, private readonly workspaceCacheService: WorkspaceCacheService, @@ -59,8 +60,8 @@ export class AgentService { workspaceId: string; name: string; }): Promise { - const agent = await this.agentRepository.findOne({ - where: { name, workspaceId }, + const agent = await this.agentRepository.findOne(workspaceId, { + where: { name }, }); if (!agent) { @@ -377,15 +378,14 @@ export class AgentService { ): Promise { const queryLower = query.toLowerCase(); - return this.agentRepository.find({ + return this.agentRepository.find(workspaceId, { where: [ - { workspaceId, deletedAt: IsNull(), name: ILike(`%${queryLower}%`) }, + { deletedAt: IsNull(), name: ILike(`%${queryLower}%`) }, { - workspaceId, deletedAt: IsNull(), description: ILike(`%${queryLower}%`), }, - { workspaceId, deletedAt: IsNull(), label: ILike(`%${queryLower}%`) }, + { deletedAt: IsNull(), label: ILike(`%${queryLower}%`) }, ], take: options.limit, order: { name: 'ASC' }, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts index 37c938bddf..b3cbaa449d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts @@ -13,6 +13,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; @@ -44,6 +45,7 @@ import { AgentEntity } from './entities/agent.entity'; AgentService, WorkspaceMigrationGraphqlApiExceptionInterceptor, AiGraphqlApiExceptionInterceptor, + provideWorkspaceScopedRepository(AgentEntity), ], exports: [AgentService, TypeOrmModule.forFeature([AgentEntity])], }) diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts index 0f4f36d9e3..2e8f2248cd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts @@ -12,12 +12,16 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; +import { AgentMessagePartEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message-part.entity'; +import { AgentMessageEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; +import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { DashboardToolsModule } from 'src/modules/dashboard/tools/dashboard-tools.module'; @@ -75,6 +79,11 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser StreamAgentChatJob, SystemPromptBuilderService, AiGraphqlApiExceptionInterceptor, + provideWorkspaceScopedRepository(AgentChatThreadEntity), + provideWorkspaceScopedRepository(AgentTurnEntity), + provideWorkspaceScopedRepository(AgentMessageEntity), + provideWorkspaceScopedRepository(AgentMessagePartEntity), + provideWorkspaceScopedRepository(FileEntity), ], exports: [ AgentChatService, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts index d7dd95a2b5..24e8dc349a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat.job.ts @@ -9,6 +9,8 @@ import type { } from 'twenty-shared/ai'; import { Repository } from 'typeorm'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; @@ -37,8 +39,8 @@ export class StreamAgentChatJob { private readonly logger = new Logger(StreamAgentChatJob.name); constructor( - @InjectRepository(AgentChatThreadEntity) - private readonly threadRepository: Repository, + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, private readonly agentChatService: AgentChatService, @@ -100,14 +102,11 @@ export class StreamAgentChatJob { } finally { await this.cancelSubscriberService.unsubscribe(cancelChannel); await this.threadRepository - .createQueryBuilder() - .update(AgentChatThreadEntity) - .set({ activeStreamId: null }) - .where('id = :id AND "activeStreamId" = :streamId', { - id: data.threadId, - streamId: data.streamId, - }) - .execute() + .update( + data.workspaceId, + { id: data.threadId, activeStreamId: data.streamId }, + { activeStreamId: null }, + ) .catch(() => {}); if (!abortController.signal.aborted) { @@ -462,7 +461,7 @@ export class StreamAgentChatJob { return; } - const threadStatus = await this.threadRepository.findOne({ + const threadStatus = await this.threadRepository.findOne(workspaceId, { where: { id: threadId }, select: ['id', 'deletedAt'], }); @@ -480,25 +479,31 @@ export class StreamAgentChatJob { workspaceId, }); - await this.threadRepository.update(threadId, { - totalInputTokens: () => `"totalInputTokens" + ${streamUsage.inputTokens}`, - totalOutputTokens: () => - `"totalOutputTokens" + ${streamUsage.outputTokens}`, - totalInputCredits: () => - `"totalInputCredits" + ${streamUsage.inputCredits}`, - totalOutputCredits: () => - `"totalOutputCredits" + ${streamUsage.outputCredits}`, - totalCacheReadTokens: () => - `"totalCacheReadTokens" + ${streamUsage.cacheReadTokens}`, - totalCacheCreationTokens: () => - `"totalCacheCreationTokens" + ${totalCacheCreationTokens}`, - contextWindowTokens: modelConfig.contextWindowTokens, - conversationSize: lastStepConversationSize, - }); + await this.threadRepository.update( + workspaceId, + { id: threadId }, + { + totalInputTokens: () => + `"totalInputTokens" + ${streamUsage.inputTokens}`, + totalOutputTokens: () => + `"totalOutputTokens" + ${streamUsage.outputTokens}`, + totalInputCredits: () => + `"totalInputCredits" + ${streamUsage.inputCredits}`, + totalOutputCredits: () => + `"totalOutputCredits" + ${streamUsage.outputCredits}`, + totalCacheReadTokens: () => + `"totalCacheReadTokens" + ${streamUsage.cacheReadTokens}`, + totalCacheCreationTokens: () => + `"totalCacheCreationTokens" + ${totalCacheCreationTokens}`, + contextWindowTokens: modelConfig.contextWindowTokens, + conversationSize: lastStepConversationSize, + }, + ); await this.agentChatService.notifyThreadUsageUpdated({ threadId, userWorkspaceId, + workspaceId, }); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts index f42cfa5a7d..77221edc7f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts @@ -1,10 +1,8 @@ import { UseGuards, UseInterceptors } from '@nestjs/common'; import { Args, Subscription } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; import { PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -22,15 +20,16 @@ import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @MetadataResolver() @UseGuards(WorkspaceAuthGuard, UserAuthGuard) @UseInterceptors(AiGraphqlApiExceptionInterceptor) export class AgentChatSubscriptionResolver { constructor( private readonly subscriptionService: SubscriptionService, - @InjectRepository(AgentChatThreadEntity) - private readonly threadRepository: Repository, + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, ) {} @Subscription(() => AgentChatEventDTO, { @@ -47,7 +46,7 @@ export class AgentChatSubscriptionResolver { @AuthWorkspace() workspace: WorkspaceEntity, @AuthUserWorkspaceId() userWorkspaceId: string, ) { - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspace.id, { where: { id: threadId, userWorkspaceId }, select: ['id'], }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts index 0abe09cba5..7402900321 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts @@ -8,11 +8,9 @@ import { ResolveField, } from '@nestjs/graphql'; -import { InjectRepository } from '@nestjs/typeorm'; import GraphQLJSON from 'graphql-type-json'; import { PermissionFlagType } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -43,7 +41,8 @@ import { AiExceptionCode, } from 'src/engine/metadata-modules/ai/ai.exception'; import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI)) @UseInterceptors(AiGraphqlApiExceptionInterceptor) @MetadataResolver(() => AgentChatThreadDTO) @@ -56,40 +55,58 @@ export class AgentChatResolver { private readonly billingUsageService: BillingUsageService, private readonly aiModelRegistryService: AiModelRegistryService, private readonly redisClientService: RedisClientService, - @InjectRepository(AgentChatThreadEntity) - private readonly threadRepository: Repository, + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, ) {} @Query(() => [AgentChatThreadDTO]) - async chatThreads(@AuthUserWorkspaceId() userWorkspaceId: string) { - return this.agentChatService.getThreadsForUser(userWorkspaceId); + async chatThreads( + @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ) { + return this.agentChatService.getThreadsForUser({ + userWorkspaceId, + workspaceId, + }); } @Query(() => AgentChatThreadDTO) async chatThread( @Args('id', { type: () => UUIDScalarType }) id: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { - return this.agentChatService.getThreadById(id, userWorkspaceId); + return this.agentChatService.getThreadById({ + threadId: id, + userWorkspaceId, + workspaceId, + }); } @Query(() => [AgentMessageDTO]) async chatMessages( @Args('threadId', { type: () => UUIDScalarType }) threadId: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { - return this.agentChatService.getMessagesForThread( + return this.agentChatService.getMessagesForThread({ threadId, userWorkspaceId, - ); + workspaceId, + }); } @Query(() => ChatStreamCatchupChunksDTO) async chatStreamCatchupChunks( @Args('threadId', { type: () => UUIDScalarType }) threadId: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ) { - await this.agentChatService.getThreadById(threadId, userWorkspaceId); + await this.agentChatService.getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }); return this.eventPublisherService.getAccumulatedChunks(threadId); } @@ -138,7 +155,7 @@ export class AgentChatResolver { await this.billingUsageService.hasAvailableCreditsOrThrow(workspace.id); - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspace.id, { where: { id: threadId, userWorkspaceId }, }); @@ -153,6 +170,7 @@ export class AgentChatResolver { await this.agentChatService.unarchiveThread({ threadId, userWorkspaceId, + workspaceId: workspace.id, }); } @@ -197,8 +215,9 @@ export class AgentChatResolver { async stopAgentChatStream( @Args('threadId', { type: () => UUIDScalarType }) threadId: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspaceId, { where: { id: threadId, userWorkspaceId }, }); @@ -211,6 +230,7 @@ export class AgentChatResolver { await redis.publish(getCancelChannel(threadId), 'cancel'); await this.threadRepository.update( + workspaceId, { id: threadId, userWorkspaceId }, { activeStreamId: null }, ); @@ -223,10 +243,12 @@ export class AgentChatResolver { @Args('id', { type: () => UUIDScalarType }) id: string, @Args('title') title: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { return this.agentChatService.updateThreadTitle({ threadId: id, userWorkspaceId, + workspaceId, title, }); } @@ -235,12 +257,14 @@ export class AgentChatResolver { async archiveChatThread( @Args('id', { type: () => UUIDScalarType }) id: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { - await this.cancelActiveStreamIfAny(id, userWorkspaceId); + await this.cancelActiveStreamIfAny(id, userWorkspaceId, workspaceId); return this.agentChatService.archiveThread({ threadId: id, userWorkspaceId, + workspaceId, }); } @@ -248,10 +272,12 @@ export class AgentChatResolver { async unarchiveChatThread( @Args('id', { type: () => UUIDScalarType }) id: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { return this.agentChatService.unarchiveThread({ threadId: id, userWorkspaceId, + workspaceId, }); } @@ -259,12 +285,14 @@ export class AgentChatResolver { async deleteChatThread( @Args('id', { type: () => UUIDScalarType }) id: string, @AuthUserWorkspaceId() userWorkspaceId: string, + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, ): Promise { - await this.cancelActiveStreamIfAny(id, userWorkspaceId); + await this.cancelActiveStreamIfAny(id, userWorkspaceId, workspaceId); await this.agentChatService.hardDeleteThread({ threadId: id, userWorkspaceId, + workspaceId, }); return true; @@ -273,8 +301,9 @@ export class AgentChatResolver { private async cancelActiveStreamIfAny( threadId: string, userWorkspaceId: string, + workspaceId: string, ): Promise { - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspaceId, { where: { id: threadId, userWorkspaceId }, }); @@ -293,7 +322,10 @@ export class AgentChatResolver { @AuthUserWorkspaceId() userWorkspaceId: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - const message = await this.agentChatService.findQueuedMessage(messageId); + const message = await this.agentChatService.findQueuedMessage({ + messageId, + workspaceId: workspace.id, + }); if (!isDefined(message)) { throw new AiException( @@ -302,7 +334,7 @@ export class AgentChatResolver { ); } - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspace.id, { where: { id: message.threadId, userWorkspaceId }, }); @@ -313,7 +345,10 @@ export class AgentChatResolver { ); } - const deleted = await this.agentChatService.deleteQueuedMessage(messageId); + const deleted = await this.agentChatService.deleteQueuedMessage({ + messageId, + workspaceId: workspace.id, + }); if (deleted) { await this.eventPublisherService.publish({ @@ -357,6 +392,9 @@ export class AgentChatResolver { return thread.lastMessageAt; } - return this.agentChatService.getLastMessageAtForThread(thread.id); + return this.agentChatService.getLastMessageAtForThread({ + threadId: thread.id, + workspaceId: thread.workspaceId, + }); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index 22b5a2b198..8c8f548348 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { generateId } from 'ai'; import { @@ -8,7 +7,7 @@ import { isExtendedFileUIPart, } from 'twenty-shared/ai'; import { FileFolder } from 'twenty-shared/types'; -import { In, Like, type Repository } from 'typeorm'; +import { In, Like } from 'typeorm'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; @@ -32,7 +31,8 @@ import { AiException, AiExceptionCode, } from 'src/engine/metadata-modules/ai/ai.exception'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; type StreamAgentChatOptions = { threadId: string; userWorkspaceId: string; @@ -49,10 +49,10 @@ export class AgentChatStreamingService { private readonly logger = new Logger(AgentChatStreamingService.name); constructor( - @InjectRepository(AgentChatThreadEntity) - private readonly threadRepository: Repository, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, @InjectMessageQueue(MessageQueue.aiStreamQueue) private readonly messageQueueService: MessageQueueService, private readonly agentChatService: AgentChatService, @@ -70,7 +70,7 @@ export class AgentChatStreamingService { messageId, fileAttachments, }: StreamAgentChatOptions): Promise<{ streamId: string; messageId: string }> { - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspace.id, { where: { id: threadId, userWorkspaceId, @@ -104,10 +104,11 @@ export class AgentChatStreamingService { workspaceId: workspace.id, }); - await this.agentChatService.notifyThreadActivityUpdated( + await this.agentChatService.notifyThreadActivityUpdated({ threadId, userWorkspaceId, - ); + workspaceId: workspace.id, + }); const previousMessages = await this.loadMessagesFromDB( threadId, @@ -135,9 +136,11 @@ export class AgentChatStreamingService { }, ); - await this.threadRepository.update(thread.id, { - activeStreamId: streamId, - }); + await this.threadRepository.update( + workspace.id, + { id: thread.id }, + { activeStreamId: streamId }, + ); return { streamId, messageId: savedUserMessage.id }; } @@ -148,7 +151,7 @@ export class AgentChatStreamingService { workspaceId: string, hasTitle: boolean, ): Promise { - const threadStatus = await this.threadRepository.findOne({ + const threadStatus = await this.threadRepository.findOne(workspaceId, { where: { id: threadId }, select: ['id', 'deletedAt'], }); @@ -157,8 +160,10 @@ export class AgentChatStreamingService { return; } - const queuedMessages = - await this.agentChatService.getQueuedMessages(threadId); + const queuedMessages = await this.agentChatService.getQueuedMessages({ + threadId, + workspaceId, + }); const nextQueued = queuedMessages[0]; @@ -181,16 +186,19 @@ export class AgentChatStreamingService { ); if (messageText === '' && fileParts.length === 0) { - await this.agentChatService.deleteQueuedMessage(nextQueued.id); + await this.agentChatService.deleteQueuedMessage({ + messageId: nextQueued.id, + workspaceId, + }); return; } - const turnId = await this.agentChatService.promoteQueuedMessage( - nextQueued.id, + const turnId = await this.agentChatService.promoteQueuedMessage({ + messageId: nextQueued.id, threadId, workspaceId, - ); + }); if (turnId === null) { return; @@ -210,7 +218,9 @@ export class AgentChatStreamingService { const [uiMessages, thread] = await Promise.all([ this.loadMessagesFromDB(threadId, userWorkspaceId, workspaceId), - this.threadRepository.findOneByOrFail({ id: threadId }), + this.threadRepository.findOneOrFail(workspaceId, { + where: { id: threadId }, + }), ]); const streamId = generateId(); @@ -239,9 +249,11 @@ export class AgentChatStreamingService { }, ); - await this.threadRepository.update(threadId, { - activeStreamId: streamId, - }); + await this.threadRepository.update( + workspaceId, + { id: threadId }, + { activeStreamId: streamId }, + ); } private async loadMessagesFromDB( @@ -249,10 +261,11 @@ export class AgentChatStreamingService { userWorkspaceId: string, workspaceId: string, ) { - const allMessages = await this.agentChatService.getMessagesForThread( + const allMessages = await this.agentChatService.getMessagesForThread({ threadId, userWorkspaceId, - ); + workspaceId, + }); const filteredMessages = allMessages.filter( (message) => message.status !== AgentMessageStatus.QUEUED, @@ -295,10 +308,9 @@ export class AgentChatStreamingService { const fileIds = fileAttachments.map((attachment) => attachment.id); - const validFiles = await this.fileRepository.find({ + const validFiles = await this.fileRepository.find(workspaceId, { where: { id: In(fileIds), - workspaceId, path: Like(`${FileFolder.AgentChat}/%`), }, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts index a05ad658c0..3f58dfcf11 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts @@ -1,8 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { ExtendedUIMessage } from 'twenty-shared/ai'; -import { In, IsNull, Not, Repository } from 'typeorm'; +import { In, IsNull, Not } from 'typeorm'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import type { UIDataTypes, UIMessagePart, UITools } from 'ai'; @@ -22,7 +21,8 @@ import { AiExceptionCode, } from 'src/engine/metadata-modules/ai/ai.exception'; import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service'; - +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util'; import { AiChatFileAttachment } from 'src/engine/metadata-modules/ai/ai-chat/types/ai-chat-file-attachment.type'; import { AgentTitleGenerationService } from './agent-title-generation.service'; @@ -53,16 +53,16 @@ export class AgentChatService { private readonly logger = new Logger(AgentChatService.name); constructor( - @InjectRepository(AgentChatThreadEntity) - private readonly threadRepository: Repository, - @InjectRepository(AgentTurnEntity) - private readonly turnRepository: Repository, - @InjectRepository(AgentMessageEntity) - private readonly messageRepository: Repository, - @InjectRepository(AgentMessagePartEntity) - private readonly messagePartRepository: Repository, - @InjectRepository(FileEntity) - private readonly fileRepository: Repository, + @InjectWorkspaceScopedRepository(AgentChatThreadEntity) + private readonly threadRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentTurnEntity) + private readonly turnRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentMessageEntity) + private readonly messageRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentMessagePartEntity) + private readonly messagePartRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, private readonly titleGenerationService: AgentTitleGenerationService, private readonly workspaceEventBroadcaster: WorkspaceEventBroadcaster, ) {} @@ -74,13 +74,10 @@ export class AgentChatService { userWorkspaceId: string; workspaceId: string; }) { - const thread = this.threadRepository.create({ + const savedThread = await this.threadRepository.save(workspaceId, { userWorkspaceId, - workspaceId, }); - const savedThread = await this.threadRepository.save(thread); - await this.workspaceEventBroadcaster.broadcast({ workspaceId, events: [ @@ -99,8 +96,16 @@ export class AgentChatService { return savedThread; } - async getThreadById(threadId: string, userWorkspaceId: string) { - const thread = await this.threadRepository.findOne({ + async getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }: { + threadId: string; + userWorkspaceId: string; + workspaceId: string; + }) { + const thread = await this.threadRepository.findOne(workspaceId, { where: { id: threadId, userWorkspaceId, @@ -117,15 +122,24 @@ export class AgentChatService { return thread; } - async getThreadsForUser( - userWorkspaceId: string, - ): Promise<(AgentChatThreadEntity & { lastMessageAt: Date | null })[]> { + async getThreadsForUser({ + userWorkspaceId, + workspaceId, + }: { + userWorkspaceId: string; + workspaceId: string; + }): Promise<(AgentChatThreadEntity & { lastMessageAt: Date | null })[]> { + // Query builder uses the scoped wrapper's escape hatch; we add the + // workspaceId predicate manually below. const rankedThreads = await this.threadRepository .createQueryBuilder('thread') .select('thread.id', 'id') .addSelect('MAX(message.createdAt)', 'last_message_at') .leftJoin('thread.messages', 'message') - .where('thread.userWorkspaceId = :userWorkspaceId', { userWorkspaceId }) + .where( + 'thread.userWorkspaceId = :userWorkspaceId AND thread.workspaceId = :workspaceId', + { userWorkspaceId, workspaceId }, + ) .groupBy('thread.id') .orderBy('last_message_at', 'DESC', 'NULLS LAST') .addOrderBy('thread.updatedAt', 'DESC') @@ -139,7 +153,7 @@ export class AgentChatService { (rankedThread) => rankedThread.id, ); - const threads = await this.threadRepository.find({ + const threads = await this.threadRepository.find(workspaceId, { where: { id: In(rankedThreadIds), userWorkspaceId }, }); @@ -154,11 +168,20 @@ export class AgentChatService { }); } - async getLastMessageAtForThread(threadId: string): Promise { + async getLastMessageAtForThread({ + threadId, + workspaceId, + }: { + threadId: string; + workspaceId: string; + }): Promise { const result = await this.messageRepository .createQueryBuilder('message') .select('MAX(message.createdAt)', 'last_message_at') - .where('message.threadId = :threadId', { threadId }) + .where( + 'message.threadId = :threadId AND message.workspaceId = :workspaceId', + { threadId, workspaceId }, + ) .getRawOne<{ last_message_at: Date | null }>(); return result?.last_message_at ?? null; @@ -183,10 +206,9 @@ export class AgentChatService { let actualTurnId = turnId; if (!actualTurnId) { - const turnInsertResult = await this.turnRepository.insert({ + const turnInsertResult = await this.turnRepository.insert(workspaceId, { threadId, agentId: agentId ?? null, - workspaceId, }); actualTurnId = turnInsertResult.identifiers[0].id as string; @@ -199,10 +221,12 @@ export class AgentChatService { role: uiMessage.role as AgentMessageRole, agentId: agentId ?? null, processedAt: new Date(), - workspaceId, }; - const insertResult = await this.messageRepository.insert(messageValues); + const insertResult = await this.messageRepository.insert( + workspaceId, + messageValues, + ); const savedMessageId = (id ?? insertResult.identifiers[0].id) as string; @@ -214,6 +238,7 @@ export class AgentChatService { ); await this.messagePartRepository.insert( + workspaceId, dbParts as QueryDeepPartialEntity[], ); } @@ -229,22 +254,20 @@ export class AgentChatService { } as AgentMessageEntity; } - async getMessagesForThread(threadId: string, userWorkspaceId: string) { - const thread = await this.threadRepository.findOne({ - where: { - id: threadId, - userWorkspaceId, - }, - }); + async getMessagesForThread({ + threadId, + userWorkspaceId, + workspaceId, + }: { + threadId: string; + userWorkspaceId: string; + workspaceId: string; + }) { + // getThreadById enforces ownership; messages then scoped by both + // threadId and workspaceId. + await this.getThreadById({ threadId, userWorkspaceId, workspaceId }); - if (!thread) { - throw new AiException( - 'Thread not found', - AiExceptionCode.THREAD_NOT_FOUND, - ); - } - - return this.messageRepository.find({ + return this.messageRepository.find(workspaceId, { where: { threadId }, order: { processedAt: { direction: 'ASC', nulls: 'LAST' } }, relations: ['parts', 'parts.file'], @@ -273,19 +296,20 @@ export class AgentChatService { role: AgentMessageRole.USER, agentId: null, status: AgentMessageStatus.QUEUED, - workspaceId, }; - const insertResult = await this.messageRepository.insert(messageValues); + const insertResult = await this.messageRepository.insert( + workspaceId, + messageValues, + ); const savedMessageId = (id ?? insertResult.identifiers[0].id) as string; const validFiles = fileAttachments && fileAttachments.length > 0 - ? await this.fileRepository.find({ + ? await this.fileRepository.find(workspaceId, { where: { id: In(fileAttachments.map((attachment) => attachment.id)), - workspaceId, }, select: ['id'], }) @@ -299,7 +323,6 @@ export class AgentChatService { orderIndex: 0, type: 'text', textContent: text, - workspaceId, }, ...(fileAttachments ?? []) .filter((attachment) => validFileIds.has(attachment.id)) @@ -309,22 +332,32 @@ export class AgentChatService { type: 'file', fileId: attachment.id, fileFilename: attachment.filename, - workspaceId, })), ]; - await this.messagePartRepository.insert(parts); + await this.messagePartRepository.insert(workspaceId, parts); - await this.notifyThreadActivityUpdated(threadId, userWorkspaceId); + await this.notifyThreadActivityUpdated({ + threadId, + userWorkspaceId, + workspaceId, + }); return { id: savedMessageId, ...messageValues, + workspaceId, } as AgentMessageEntity; } - async getQueuedMessages(threadId: string): Promise { - return this.messageRepository.find({ + async getQueuedMessages({ + threadId, + workspaceId, + }: { + threadId: string; + workspaceId: string; + }): Promise { + return this.messageRepository.find(workspaceId, { where: { threadId, status: AgentMessageStatus.QUEUED, @@ -334,16 +367,26 @@ export class AgentChatService { }); } - async findQueuedMessage( - messageId: string, - ): Promise { - return this.messageRepository.findOne({ + async findQueuedMessage({ + messageId, + workspaceId, + }: { + messageId: string; + workspaceId: string; + }): Promise { + return this.messageRepository.findOne(workspaceId, { where: { id: messageId, status: AgentMessageStatus.QUEUED }, }); } - async deleteQueuedMessage(messageId: string): Promise { - const result = await this.messageRepository.delete({ + async deleteQueuedMessage({ + messageId, + workspaceId, + }: { + messageId: string; + workspaceId: string; + }): Promise { + const result = await this.messageRepository.delete(workspaceId, { id: messageId, status: AgentMessageStatus.QUEUED, }); @@ -351,20 +394,24 @@ export class AgentChatService { return (result.affected ?? 0) > 0; } - async promoteQueuedMessage( - messageId: string, - threadId: string, - workspaceId: string, - ): Promise { - const turnInsertResult = await this.turnRepository.insert({ + async promoteQueuedMessage({ + messageId, + threadId, + workspaceId, + }: { + messageId: string; + threadId: string; + workspaceId: string; + }): Promise { + const turnInsertResult = await this.turnRepository.insert(workspaceId, { threadId, agentId: null, - workspaceId, }); const savedTurnId = turnInsertResult.identifiers[0].id as string; const result = await this.messageRepository.update( + workspaceId, { id: messageId, threadId, status: AgentMessageStatus.QUEUED }, { status: AgentMessageStatus.SENT, @@ -374,7 +421,7 @@ export class AgentChatService { ); if ((result.affected ?? 0) === 0) { - await this.turnRepository.delete(savedTurnId); + await this.turnRepository.delete(workspaceId, { id: savedTurnId }); return null; } @@ -385,10 +432,12 @@ export class AgentChatService { async updateThreadTitle({ threadId, userWorkspaceId, + workspaceId, title, }: { threadId: string; userWorkspaceId: string; + workspaceId: string; title: string; }): Promise { const trimmed = title.trim(); @@ -401,6 +450,7 @@ export class AgentChatService { } const result = await this.threadRepository.update( + workspaceId, { id: threadId, userWorkspaceId }, { title: trimmed }, ); @@ -412,7 +462,11 @@ export class AgentChatService { ); } - const updated = await this.getThreadById(threadId, userWorkspaceId); + const updated = await this.getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }); await this.broadcastThreadUpdated(updated, ['title'], userWorkspaceId); @@ -422,11 +476,17 @@ export class AgentChatService { async archiveThread({ threadId, userWorkspaceId, + workspaceId, }: { threadId: string; userWorkspaceId: string; + workspaceId: string; }): Promise { - const thread = await this.getThreadById(threadId, userWorkspaceId); + const thread = await this.getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }); if (thread.deletedAt) { return thread; @@ -435,6 +495,7 @@ export class AgentChatService { const deletedAt = new Date(); const result = await this.threadRepository.update( + workspaceId, { id: threadId, userWorkspaceId, deletedAt: IsNull() }, { deletedAt, activeStreamId: null }, ); @@ -454,17 +515,24 @@ export class AgentChatService { async unarchiveThread({ threadId, userWorkspaceId, + workspaceId, }: { threadId: string; userWorkspaceId: string; + workspaceId: string; }): Promise { - const thread = await this.getThreadById(threadId, userWorkspaceId); + const thread = await this.getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }); if (!thread.deletedAt) { return thread; } const result = await this.threadRepository.update( + workspaceId, { id: threadId, userWorkspaceId, deletedAt: Not(IsNull()) }, { deletedAt: null }, ); @@ -483,11 +551,13 @@ export class AgentChatService { async hardDeleteThread({ threadId, userWorkspaceId, + workspaceId, }: { threadId: string; userWorkspaceId: string; + workspaceId: string; }): Promise { - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspaceId, { where: { id: threadId, userWorkspaceId }, }); @@ -498,7 +568,7 @@ export class AgentChatService { ); } - const result = await this.threadRepository.delete({ + const result = await this.threadRepository.delete(workspaceId, { id: threadId, userWorkspaceId, }); @@ -527,11 +597,20 @@ export class AgentChatService { }); } - async notifyThreadActivityUpdated( - threadId: string, - userWorkspaceId: string, - ): Promise { - const thread = await this.getThreadById(threadId, userWorkspaceId); + async notifyThreadActivityUpdated({ + threadId, + userWorkspaceId, + workspaceId, + }: { + threadId: string; + userWorkspaceId: string; + workspaceId: string; + }): Promise { + const thread = await this.getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }); await this.broadcastThreadUpdated( thread, @@ -543,11 +622,17 @@ export class AgentChatService { async notifyThreadUsageUpdated({ threadId, userWorkspaceId, + workspaceId, }: { threadId: string; userWorkspaceId: string; + workspaceId: string; }): Promise { - const thread = await this.getThreadById(threadId, userWorkspaceId); + const thread = await this.getThreadById({ + threadId, + userWorkspaceId, + workspaceId, + }); await this.broadcastThreadUpdated( thread, @@ -568,7 +653,10 @@ export class AgentChatService { updatedFields: (keyof AgentChatThreadDTO)[], userWorkspaceId: string, ): Promise { - const lastMessageAt = await this.getLastMessageAtForThread(thread.id); + const lastMessageAt = await this.getLastMessageAtForThread({ + threadId: thread.id, + workspaceId: thread.workspaceId, + }); await this.workspaceEventBroadcaster.broadcast({ workspaceId: thread.workspaceId, @@ -596,7 +684,7 @@ export class AgentChatService { messageContent: string; workspaceId: string; }): Promise { - const thread = await this.threadRepository.findOne({ + const thread = await this.threadRepository.findOne(workspaceId, { where: { id: threadId }, }); @@ -610,7 +698,11 @@ export class AgentChatService { thread.userWorkspaceId, ); - await this.threadRepository.update(threadId, { title }); + await this.threadRepository.update( + workspaceId, + { id: threadId }, + { title }, + ); await this.broadcastThreadUpdated( { ...thread, title }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-agent/flat-agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-agent/flat-agent.module.ts index 67ce9b5bf6..4f60eeb81d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-agent/flat-agent.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-agent/flat-agent.module.ts @@ -8,7 +8,7 @@ import { WorkspaceFlatRoleTargetByAgentIdService } from 'src/engine/metadata-mod import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [ TypeOrmModule.forFeature([ @@ -22,6 +22,7 @@ import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-t providers: [ WorkspaceFlatAgentMapCacheService, WorkspaceFlatRoleTargetByAgentIdService, + provideWorkspaceScopedRepository(AgentEntity), ], exports: [ WorkspaceFlatAgentMapCacheService, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-agent-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-agent-map-cache.service.ts index e8133800fd..83bb4cad08 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-agent-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-agent-map-cache.service.ts @@ -10,6 +10,8 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag import { type FlatAgentMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-agent-maps.type'; import { transformAgentEntityToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util'; import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; @@ -18,8 +20,8 @@ import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/ @WorkspaceCache('flatAgentMaps') export class WorkspaceFlatAgentMapCacheService extends WorkspaceCacheProvider { constructor( - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, @InjectRepository(ApplicationEntity) private readonly applicationRepository: Repository, ) { @@ -28,8 +30,7 @@ export class WorkspaceFlatAgentMapCacheService extends WorkspaceCacheProvider { const [agents, applications] = await Promise.all([ - this.agentRepository.find({ - where: { workspaceId }, + this.agentRepository.find(workspaceId, { withDeleted: true, }), this.applicationRepository.find({ diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts index cc28745b22..975b00d6cd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.module.ts @@ -11,6 +11,7 @@ import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-t import { RoleTargetModule } from 'src/engine/metadata-modules/role-target/role-target.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @Module({ @@ -27,7 +28,11 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceCacheModule, RoleTargetModule, ], - providers: [ApiKeyRoleService, PermissionsService], + providers: [ + ApiKeyRoleService, + PermissionsService, + provideWorkspaceScopedRepository(ApiKeyEntity), + ], exports: [PermissionsService, ApiKeyRoleService], }) export class PermissionsModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts index 2e34584bc4..c2f55d98f8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.module.ts @@ -3,10 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service'; - +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @Module({ imports: [TypeOrmModule.forFeature([FeatureFlagEntity])], - providers: [WorkspaceFeatureFlagsMapCacheService], + providers: [ + WorkspaceFeatureFlagsMapCacheService, + provideWorkspaceScopedRepository(FeatureFlagEntity), + ], exports: [WorkspaceFeatureFlagsMapCacheService], }) export class WorkspaceFeatureFlagsMapCacheModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts index e38351eaf7..c7f0a7d52d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service.ts @@ -1,38 +1,31 @@ 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 FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; @Injectable() @WorkspaceCache('featureFlagsMap') export class WorkspaceFeatureFlagsMapCacheService extends WorkspaceCacheProvider { constructor( - @InjectRepository(FeatureFlagEntity) - private readonly featureFlagRepository: Repository, + @InjectWorkspaceScopedRepository(FeatureFlagEntity) + private readonly featureFlagRepository: WorkspaceScopedRepository, ) { super(); } async computeForCache(workspaceId: string): Promise { - const workspaceFeatureFlags = await this.featureFlagRepository.find({ - where: { workspaceId }, - }); + const workspaceFeatureFlags = + await this.featureFlagRepository.find(workspaceId); - const workspaceFeatureFlagsMap = workspaceFeatureFlags.reduce( - (result, currentFeatureFlag) => { - result[currentFeatureFlag.key] = currentFeatureFlag.value; + return workspaceFeatureFlags.reduce((result, currentFeatureFlag) => { + result[currentFeatureFlag.key] = currentFeatureFlag.value; - return result; - }, - {} as FeatureFlagMap, - ); - - return workspaceFeatureFlagsMap; + return result; + }, {} as FeatureFlagMap); } } diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts new file mode 100644 index 0000000000..0f2a0cec79 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/__tests__/workspace-scoped-repository.spec.ts @@ -0,0 +1,351 @@ +import { type Repository } from 'typeorm'; + +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; +type FakeEntity = { + id: string; + status: string; + workspaceId: string; + [key: string]: unknown; +}; + +const WORKSPACE_ID = 'workspace-1'; +const OTHER_WORKSPACE_ID = 'workspace-2'; + +const createMockRepository = (): jest.Mocked> => + ({ + findOne: jest.fn().mockResolvedValue(null), + findOneOrFail: jest.fn(), + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + update: jest.fn(), + delete: jest.fn(), + softDelete: jest.fn(), + insert: jest.fn(), + upsert: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(), + }) as unknown as jest.Mocked>; + +describe('WorkspaceScopedRepository', () => { + let repository: jest.Mocked>; + let scoped: WorkspaceScopedRepository; + + beforeEach(() => { + repository = createMockRepository(); + scoped = new WorkspaceScopedRepository(repository); + }); + + describe('workspaceId guard', () => { + // TypeORM drops `undefined` values from WHERE/criteria, so a + // missing workspaceId would otherwise produce an unscoped query. + // Each public method must trip before reaching the repository. + it.each([ + ['findOne', () => scoped.findOne(undefined as never, { where: {} })], + [ + 'findOneOrFail', + () => scoped.findOneOrFail(undefined as never, { where: {} }), + ], + ['find', () => scoped.find(undefined as never)], + ['count', () => scoped.count(undefined as never)], + ['update', () => scoped.update(undefined as never, {}, {})], + ['delete', () => scoped.delete(undefined as never, {})], + ['softDelete', () => scoped.softDelete(undefined as never, {})], + ['insert', () => scoped.insert(undefined as never, {})], + ['upsert', () => scoped.upsert(undefined as never, {}, ['id'])], + ['save', () => scoped.save(undefined as never, {})], + ['saveMany', () => scoped.saveMany(undefined as never, [{}])], + ])('%s throws when workspaceId is undefined', (_name, call) => { + expect(call).toThrow(/workspaceId must be a non-empty string/); + }); + + it.each([null, ''])('throws when workspaceId is %p', (badWorkspaceId) => { + expect(() => + scoped.findOne(badWorkspaceId as never, { where: {} }), + ).toThrow(/workspaceId must be a non-empty string/); + }); + }); + + describe('findOne', () => { + it('merges workspaceId into a plain where clause', async () => { + await scoped.findOne(WORKSPACE_ID, { where: { id: 'a' } }); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: 'a', workspaceId: WORKSPACE_ID }, + }); + }); + + it('merges workspaceId into every clause of an OR (array) where', async () => { + await scoped.findOne(WORKSPACE_ID, { + where: [{ id: 'a' }, { status: 'queued' }], + }); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: [ + { id: 'a', workspaceId: WORKSPACE_ID }, + { status: 'queued', workspaceId: WORKSPACE_ID }, + ], + }); + }); + + it('throws if the caller includes workspaceId in the WHERE clause', () => { + expect(() => + scoped.findOne(WORKSPACE_ID, { + where: { id: 'a', workspaceId: OTHER_WORKSPACE_ID } as never, + }), + ).toThrow(/do not include `workspaceId`/); + + expect(repository.findOne).not.toHaveBeenCalled(); + }); + + it('throws if any clause of an array WHERE includes workspaceId', () => { + expect(() => + scoped.findOne(WORKSPACE_ID, { + where: [ + { id: 'a' }, + { id: 'b', workspaceId: OTHER_WORKSPACE_ID } as never, + ], + }), + ).toThrow(/do not include `workspaceId`/); + }); + + it('places workspaceId first in the merged WHERE clause', async () => { + await scoped.findOne(WORKSPACE_ID, { + where: { id: 'a', status: 'queued' }, + }); + + const callArg = repository.findOne.mock.calls[0][0]; + const whereKeys = Object.keys( + (callArg as { where: Record }).where, + ); + + expect(whereKeys[0]).toBe('workspaceId'); + }); + + it('preserves relations and other options', async () => { + await scoped.findOne(WORKSPACE_ID, { + where: { id: 'a' }, + relations: ['messages'], + select: ['id'], + }); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: 'a', workspaceId: WORKSPACE_ID }, + relations: ['messages'], + select: ['id'], + }); + }); + }); + + describe('find', () => { + it('adds workspaceId when no where is provided', async () => { + await scoped.find(WORKSPACE_ID); + + expect(repository.find).toHaveBeenCalledWith({ + where: { workspaceId: WORKSPACE_ID }, + }); + }); + + it('merges workspaceId into provided where', async () => { + await scoped.find(WORKSPACE_ID, { where: { status: 'queued' } }); + + expect(repository.find).toHaveBeenCalledWith({ + where: { status: 'queued', workspaceId: WORKSPACE_ID }, + }); + }); + }); + + describe('update', () => { + it('merges workspaceId into the criteria, not the patch', async () => { + await scoped.update(WORKSPACE_ID, { id: 'a' }, { status: 'completed' }); + + expect(repository.update).toHaveBeenCalledWith( + { id: 'a', workspaceId: WORKSPACE_ID }, + { status: 'completed' }, + ); + }); + + it('throws if the caller includes workspaceId in the criteria', () => { + expect(() => + scoped.update( + WORKSPACE_ID, + { id: 'a', workspaceId: OTHER_WORKSPACE_ID } as never, + { status: 'completed' }, + ), + ).toThrow(/do not include `workspaceId`/); + + expect(repository.update).not.toHaveBeenCalled(); + }); + }); + + describe('delete and softDelete', () => { + it('delete merges workspaceId into criteria', async () => { + await scoped.delete(WORKSPACE_ID, { id: 'a' }); + + expect(repository.delete).toHaveBeenCalledWith({ + id: 'a', + workspaceId: WORKSPACE_ID, + }); + }); + + it('softDelete merges workspaceId into criteria', async () => { + await scoped.softDelete(WORKSPACE_ID, { id: 'a' }); + + expect(repository.softDelete).toHaveBeenCalledWith({ + id: 'a', + workspaceId: WORKSPACE_ID, + }); + }); + }); + + describe('insert', () => { + it('stamps workspaceId on a single entity', async () => { + await scoped.insert(WORKSPACE_ID, { id: 'a', status: 'queued' }); + + expect(repository.insert).toHaveBeenCalledWith({ + id: 'a', + status: 'queued', + workspaceId: WORKSPACE_ID, + }); + }); + + it('stamps workspaceId on each entity in an array', async () => { + await scoped.insert(WORKSPACE_ID, [ + { id: 'a', status: 'queued' }, + { id: 'b', status: 'sent' }, + ]); + + expect(repository.insert).toHaveBeenCalledWith([ + { id: 'a', status: 'queued', workspaceId: WORKSPACE_ID }, + { id: 'b', status: 'sent', workspaceId: WORKSPACE_ID }, + ]); + }); + + it('overrides caller-supplied workspaceId on the entity', async () => { + await scoped.insert(WORKSPACE_ID, { + id: 'a', + workspaceId: OTHER_WORKSPACE_ID, + }); + + expect(repository.insert).toHaveBeenCalledWith({ + id: 'a', + workspaceId: WORKSPACE_ID, + }); + }); + }); + + describe('upsert', () => { + it('stamps workspaceId on a single entity and forwards conflict opts', async () => { + await scoped.upsert(WORKSPACE_ID, { id: 'a', status: 'queued' }, ['id']); + + expect(repository.upsert).toHaveBeenCalledWith( + { id: 'a', status: 'queued', workspaceId: WORKSPACE_ID }, + ['id'], + ); + }); + + it('stamps workspaceId on each entity in an array', async () => { + await scoped.upsert( + WORKSPACE_ID, + [ + { id: 'a', status: 'queued' }, + { id: 'b', status: 'sent' }, + ], + { conflictPaths: ['id'] }, + ); + + expect(repository.upsert).toHaveBeenCalledWith( + [ + { id: 'a', status: 'queued', workspaceId: WORKSPACE_ID }, + { id: 'b', status: 'sent', workspaceId: WORKSPACE_ID }, + ], + { conflictPaths: ['id'] }, + ); + }); + }); + + describe('save', () => { + it('stamps workspaceId on the entity passed to save', async () => { + await scoped.save(WORKSPACE_ID, { id: 'a', status: 'queued' }); + + expect(repository.save).toHaveBeenCalledWith( + { id: 'a', status: 'queued', workspaceId: WORKSPACE_ID }, + undefined, + ); + }); + + it('saveMany stamps workspaceId on each entity', async () => { + await scoped.saveMany(WORKSPACE_ID, [ + { id: 'a', status: 'queued' }, + { id: 'b', status: 'sent' }, + ]); + + expect(repository.save).toHaveBeenCalledWith( + [ + { id: 'a', status: 'queued', workspaceId: WORKSPACE_ID }, + { id: 'b', status: 'sent', workspaceId: WORKSPACE_ID }, + ], + undefined, + ); + }); + + it('overrides caller-supplied workspaceId on the entity', async () => { + await scoped.save(WORKSPACE_ID, { + id: 'a', + workspaceId: OTHER_WORKSPACE_ID, + }); + + expect(repository.save).toHaveBeenCalledWith( + { id: 'a', workspaceId: WORKSPACE_ID }, + undefined, + ); + }); + }); + + describe('count', () => { + it('merges workspaceId into where', async () => { + await scoped.count(WORKSPACE_ID, { where: { status: 'queued' } }); + + expect(repository.count).toHaveBeenCalledWith({ + where: { status: 'queued', workspaceId: WORKSPACE_ID }, + }); + }); + + it('adds workspaceId when no options are provided', async () => { + await scoped.count(WORKSPACE_ID); + + expect(repository.count).toHaveBeenCalledWith({ + where: { workspaceId: WORKSPACE_ID }, + }); + }); + }); + + describe('createQueryBuilder', () => { + it('returns the underlying QueryBuilder unchanged (escape hatch)', () => { + scoped.createQueryBuilder('t'); + + expect(repository.createQueryBuilder).toHaveBeenCalledWith('t'); + }); + }); + + describe('withManager', () => { + it('returns a new wrapper bound to the manager-provided repository', async () => { + const txRepository = createMockRepository(); + const manager = { + getRepository: jest.fn().mockReturnValue(txRepository), + } as unknown as import('typeorm').EntityManager; + (repository as unknown as { target: unknown }).target = 'FakeEntity'; + + const tx = scoped.withManager(manager); + + expect(tx).not.toBe(scoped); + expect(manager.getRepository).toHaveBeenCalledWith('FakeEntity'); + + await tx.findOne(WORKSPACE_ID, { where: { id: 'a' } }); + + expect(txRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'a', workspaceId: WORKSPACE_ID }, + }); + expect(repository.findOne).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util.ts new file mode 100644 index 0000000000..3fc77ab666 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util.ts @@ -0,0 +1,13 @@ +import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +const getEntityName = (entity: EntityClassOrSchema): string => { + if (typeof entity === 'function') { + return entity.name; + } + + return entity.options?.name ?? entity.constructor.name; +}; + +export const getWorkspaceScopedRepositoryToken = ( + entity: EntityClassOrSchema, +): string => `WorkspaceScopedRepository<${getEntityName(entity)}>`; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator.ts new file mode 100644 index 0000000000..a501352725 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator.ts @@ -0,0 +1,9 @@ +import { Inject } from '@nestjs/common'; + +import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; + +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; + +export const InjectWorkspaceScopedRepository = ( + entity: EntityClassOrSchema, +): ParameterDecorator => Inject(getWorkspaceScopedRepositoryToken(entity)); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository.ts new file mode 100644 index 0000000000..caec29f377 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository.ts @@ -0,0 +1,19 @@ +import { type Provider } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type'; +import { type Repository } from 'typeorm'; + +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; +import { type WorkspaceScopedEntity } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; + +// Requires TypeOrmModule.forFeature([entity]) in the same module. +export const provideWorkspaceScopedRepository = ( + entity: EntityClassOrSchema, +): Provider => ({ + provide: getWorkspaceScopedRepositoryToken(entity), + useFactory: (repository: Repository) => + new WorkspaceScopedRepository(repository), + inject: [getRepositoryToken(entity)], +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts new file mode 100644 index 0000000000..c6b6dafd07 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts @@ -0,0 +1,3 @@ +import { type ObjectLiteral } from 'typeorm'; + +export type WorkspaceScopedEntity = ObjectLiteral & { workspaceId: string }; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts new file mode 100644 index 0000000000..5569ae3d05 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository.ts @@ -0,0 +1,217 @@ +import { + type DeepPartial, + type DeleteResult, + type EntityManager, + type FindManyOptions, + type FindOneOptions, + type FindOptionsWhere, + type InsertResult, + type Repository, + type SaveOptions, + type SelectQueryBuilder, + type UpdateResult, +} from 'typeorm'; +import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { type UpsertOptions } from 'typeorm/repository/UpsertOptions'; + +import { type WorkspaceScopedEntity } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type'; + +// Wraps a TypeORM Repository to scope every operation by workspaceId. +// For workspace-data entities, use WorkspaceRepository instead. +export class WorkspaceScopedRepository { + constructor(private readonly repository: Repository) {} + + findOne(workspaceId: string, options: FindOneOptions): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.findOne({ + ...options, + where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where), + }); + } + + findOneOrFail(workspaceId: string, options: FindOneOptions): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.findOneOrFail({ + ...options, + where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where), + }); + } + + find(workspaceId: string, options?: FindManyOptions): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.find({ + ...options, + where: this.mergeWorkspaceIdIntoWhere(workspaceId, options?.where), + }); + } + + count(workspaceId: string, options?: FindManyOptions): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.count({ + ...options, + where: this.mergeWorkspaceIdIntoWhere(workspaceId, options?.where), + }); + } + + update( + workspaceId: string, + criteria: FindOptionsWhere, + partialEntity: QueryDeepPartialEntity, + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.update( + this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria), + partialEntity, + ); + } + + delete( + workspaceId: string, + criteria: FindOptionsWhere, + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.delete( + this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria), + ); + } + + softDelete( + workspaceId: string, + criteria: FindOptionsWhere, + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.softDelete( + this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria), + ); + } + + insert( + workspaceId: string, + entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.insert( + this.stampWorkspaceIdOnEntities(workspaceId, entity), + ); + } + + upsert( + workspaceId: string, + entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], + conflictPathsOrOptions: string[] | UpsertOptions, + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.upsert( + this.stampWorkspaceIdOnEntities(workspaceId, entity), + conflictPathsOrOptions, + ); + } + + save>( + workspaceId: string, + entity: E, + options?: SaveOptions, + ): Promise { + this.assertWorkspaceId(workspaceId); + + return this.repository.save({ ...entity, workspaceId } as E, options); + } + + saveMany>( + workspaceId: string, + entities: E[], + options?: SaveOptions, + ): Promise<(E & T)[]> { + this.assertWorkspaceId(workspaceId); + + return this.repository.save( + entities.map((entity) => ({ ...entity, workspaceId }) as E), + options, + ); + } + + // Escape hatch. Caller MUST add the workspaceId predicate themselves. + createQueryBuilder(alias?: string): SelectQueryBuilder { + return this.repository.createQueryBuilder(alias); + } + + // Returns a wrapper bound to the given EntityManager (transactions). + withManager(manager: EntityManager): WorkspaceScopedRepository { + return new WorkspaceScopedRepository( + manager.getRepository(this.repository.target), + ); + } + + // TypeORM drops `undefined` values from WHERE, which would emit an + // unscoped query. Reject falsy workspaceId at the boundary. + private assertWorkspaceId(workspaceId: string): void { + if ( + workspaceId === undefined || + workspaceId === null || + workspaceId === '' + ) { + throw new Error( + 'WorkspaceScopedRepository: workspaceId must be a non-empty string.', + ); + } + } + + private mergeWorkspaceIdIntoWhere( + workspaceId: string, + where: FindOneOptions['where'] | undefined, + ): FindOptionsWhere | FindOptionsWhere[] { + if (where === undefined) { + return { workspaceId } as FindOptionsWhere; + } + + if (Array.isArray(where)) { + return where.map((clause) => + this.prependWorkspaceId(workspaceId, clause), + ); + } + + return this.prependWorkspaceId(workspaceId, where as FindOptionsWhere); + } + + private mergeWorkspaceIdIntoCriteria( + workspaceId: string, + criteria: FindOptionsWhere, + ): FindOptionsWhere { + return this.prependWorkspaceId(workspaceId, criteria); + } + + private prependWorkspaceId( + workspaceId: string, + clause: FindOptionsWhere, + ): FindOptionsWhere { + if ('workspaceId' in clause) { + throw new Error( + 'WorkspaceScopedRepository: do not include `workspaceId` in the WHERE clause — it is provided as the first argument and merged automatically.', + ); + } + + return { workspaceId, ...clause } as FindOptionsWhere; + } + + private stampWorkspaceIdOnEntities( + workspaceId: string, + entity: QueryDeepPartialEntity | QueryDeepPartialEntity[], + ): QueryDeepPartialEntity | QueryDeepPartialEntity[] { + if (Array.isArray(entity)) { + return entity.map( + (item) => ({ ...item, workspaceId }) as QueryDeepPartialEntity, + ); + } + + return { ...entity, workspaceId } as QueryDeepPartialEntity; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts index 661a3ec1bf..2ceaf07269 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts @@ -25,6 +25,8 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service' import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service'; import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { USER_WORKSPACE_DELETION_WARNING_SENT_KEY } from 'src/engine/workspace-manager/workspace-cleaner/constants/user-workspace-deletion-warning-sent-key.constant'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; @@ -58,8 +60,8 @@ export class CleanerWorkspaceService { private readonly emailService: EmailService, @InjectRepository(WorkspaceEntity) private readonly workspaceRepository: Repository, - @InjectRepository(BillingSubscriptionEntity) - private readonly billingSubscriptionRepository: Repository, + @InjectWorkspaceScopedRepository(BillingSubscriptionEntity) + private readonly billingSubscriptionRepository: WorkspaceScopedRepository, private readonly billingSubscriptionService: BillingSubscriptionService, @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, @@ -505,9 +507,8 @@ export class CleanerWorkspaceService { if (this.twentyConfigService.get('IS_BILLING_ENABLED')) { const activeBillingSubscription = - await this.billingSubscriptionRepository.findOne({ + await this.billingSubscriptionRepository.findOne(workspace.id, { where: { - workspaceId: workspace.id, status: In([ SubscriptionStatus.Active, SubscriptionStatus.Trialing, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts index 2e441025a7..bec06c151f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/workspace-cleaner.module.ts @@ -10,6 +10,7 @@ import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars import { UserModule } from 'src/engine/core-modules/user/user.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { CleanOnboardingWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.command'; import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command'; import { CleanSuspendedWorkspacesCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-suspended-workspaces.command'; @@ -38,6 +39,7 @@ import { CleanerWorkspaceService } from 'src/engine/workspace-manager/workspace- CleanOnboardingWorkspacesCommand, CleanOnboardingWorkspacesCronCommand, CleanerWorkspaceService, + provideWorkspaceScopedRepository(BillingSubscriptionEntity), ], exports: [ CleanerWorkspaceService, diff --git a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts index 6192af96d1..226617896a 100644 --- a/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts +++ b/packages/twenty-server/src/modules/calendar/calendar-event-participant-manager/calendar-event-participant-manager.module.ts @@ -10,6 +10,7 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { GetDataFromCacheWithRecomputeService } from 'src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -46,6 +47,7 @@ import { MatchParticipantModule } from 'src/modules/match-participant/match-part WorkspaceFeatureFlagsMapCacheService, WorkspaceCacheStorageService, GetDataFromCacheWithRecomputeService, + provideWorkspaceScopedRepository(FeatureFlagEntity), ], exports: [CalendarEventParticipantService], }) diff --git a/packages/twenty-server/src/modules/messaging/message-participant-manager/message-participant-manager.module.ts b/packages/twenty-server/src/modules/messaging/message-participant-manager/message-participant-manager.module.ts index d54092c0e2..de28d9efec 100644 --- a/packages/twenty-server/src/modules/messaging/message-participant-manager/message-participant-manager.module.ts +++ b/packages/twenty-server/src/modules/messaging/message-participant-manager/message-participant-manager.module.ts @@ -10,6 +10,7 @@ import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-t import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service'; import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { GetDataFromCacheWithRecomputeService } from 'src/engine/workspace-cache-storage/services/get-data-from-cache-with-recompute.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -53,6 +54,7 @@ import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-o WorkspaceFeatureFlagsMapCacheService, WorkspaceCacheStorageService, GetDataFromCacheWithRecomputeService, + provideWorkspaceScopedRepository(FeatureFlagEntity), ], exports: [MessagingMessageParticipantService], }) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts index a1380c921f..0940660941 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts @@ -6,6 +6,7 @@ import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.module'; @@ -22,7 +23,11 @@ import { AiAgentWorkflowAction } from './ai-agent.workflow-action'; UserRoleModule, RoleModule, ], - providers: [WorkflowExecutionContextService, AiAgentWorkflowAction], + providers: [ + WorkflowExecutionContextService, + AiAgentWorkflowAction, + provideWorkspaceScopedRepository(AgentEntity), + ], exports: [AiAgentWorkflowAction], }) export class AiAgentActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index 33f0e3879f..7a19f3877b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -1,14 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { resolveInput } from 'twenty-shared/utils'; -import { type Repository } from 'typeorm'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -25,8 +25,8 @@ export class AiAgentWorkflowAction implements WorkflowAction { constructor( private readonly aiAgentExecutionService: AgentAsyncExecutorService, private readonly workflowExecutionContextService: WorkflowExecutionContextService, - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, ) {} async execute({ @@ -53,11 +53,8 @@ export class AiAgentWorkflowAction implements WorkflowAction { let agent: AgentEntity | null = null; if (agentId) { - agent = await this.agentRepository.findOne({ - where: { - id: agentId, - workspaceId, - }, + agent = await this.agentRepository.findOne(workspaceId, { + where: { id: agentId }, }); }