feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
This commit is contained in:
@@ -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,
|
||||
|
||||
+5
-4
@@ -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<string> = new Set(
|
||||
@@ -19,8 +20,8 @@ const KNOWN_PLAN_KEYS: ReadonlySet<string> = new Set(
|
||||
@Injectable()
|
||||
export class AdminPanelBillingService {
|
||||
constructor(
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
@InjectRepository(BillingPriceEntity)
|
||||
private readonly billingPriceRepository: Repository<BillingPriceEntity>,
|
||||
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,
|
||||
}),
|
||||
|
||||
+16
-9
@@ -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<WorkspaceEntity>,
|
||||
// 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<AgentChatThreadEntity>,
|
||||
@InjectRepository(AgentMessageEntity)
|
||||
private readonly agentMessageRepository: Repository<AgentMessageEntity>,
|
||||
@InjectWorkspaceScopedRepository(AgentMessageEntity)
|
||||
private readonly agentMessageRepository: WorkspaceScopedRepository<AgentMessageEntity>,
|
||||
) {}
|
||||
|
||||
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: {
|
||||
|
||||
+5
-6
@@ -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<WorkspaceEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(FeatureFlagEntity)
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
@InjectWorkspaceScopedRepository(FeatureFlagEntity)
|
||||
private readonly featureFlagRepository: WorkspaceScopedRepository<FeatureFlagEntity>,
|
||||
) {}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+19
-24
@@ -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);
|
||||
});
|
||||
|
||||
+8
-10
@@ -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<RoleEntity>,
|
||||
|
||||
@InjectRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
|
||||
@InjectWorkspaceScopedRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: WorkspaceScopedRepository<ApiKeyEntity>,
|
||||
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;
|
||||
|
||||
+19
-27
@@ -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<ApiKeyEntity>,
|
||||
@InjectWorkspaceScopedRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: WorkspaceScopedRepository<ApiKeyEntity>,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly roleTargetService: RoleTargetService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
apiKeyData: Partial<ApiKeyEntity> & { roleId: string },
|
||||
apiKeyData: Partial<ApiKeyEntity> & { roleId: string; workspaceId: string },
|
||||
): Promise<ApiKeyEntity> {
|
||||
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<ApiKeyEntity | null> {
|
||||
return await this.apiKeyRepository.findOne({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
return this.apiKeyRepository.findOne(workspaceId, {
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async findByWorkspaceId(workspaceId: string): Promise<ApiKeyEntity[]> {
|
||||
return await this.apiKeyRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
return this.apiKeyRepository.find(workspaceId);
|
||||
}
|
||||
|
||||
async findActiveByWorkspaceId(workspaceId: string): Promise<ApiKeyEntity[]> {
|
||||
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<ApiKeyEntity | null> {
|
||||
return await this.update(id, workspaceId, {
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
return this.update(id, workspaceId, { revokedAt: new Date() });
|
||||
}
|
||||
|
||||
async validateApiKey(id: string, workspaceId: string): Promise<ApiKeyEntity> {
|
||||
|
||||
+5
-8
@@ -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<string, FlatApiKey>
|
||||
> {
|
||||
constructor(
|
||||
@InjectRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: Repository<ApiKeyEntity>,
|
||||
@InjectWorkspaceScopedRepository(ApiKeyEntity)
|
||||
private readonly apiKeyRepository: WorkspaceScopedRepository<ApiKeyEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -25,9 +24,7 @@ export class WorkspaceApiKeyMapCacheService extends WorkspaceCacheProvider<
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<Record<string, FlatApiKey>> {
|
||||
const apiKeys = await this.apiKeyRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
const apiKeys = await this.apiKeyRepository.find(workspaceId);
|
||||
|
||||
return apiKeys.reduce(
|
||||
(map, apiKey) => {
|
||||
|
||||
+2
@@ -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<FileEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<WorkspaceEntity>,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
@InjectRepository(AgentEntity)
|
||||
private readonly agentRepository: Repository<AgentEntity>,
|
||||
@InjectWorkspaceScopedRepository(AgentEntity)
|
||||
private readonly agentRepository: WorkspaceScopedRepository<AgentEntity>,
|
||||
@InjectRepository(FrontComponentEntity)
|
||||
private readonly frontComponentRepository: Repository<FrontComponentEntity>,
|
||||
@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 },
|
||||
|
||||
+6
-2
@@ -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 {}
|
||||
|
||||
+25
-26
@@ -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<ApprovedAccessDomainEntity>,
|
||||
// Cross-workspace lookups for token validation and SSO discovery.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(ApprovedAccessDomainEntity)
|
||||
private readonly approvedAccessDomainRepository: Repository<ApprovedAccessDomainEntity>,
|
||||
private readonly approvedAccessDomainRepositoryUnscoped: Repository<ApprovedAccessDomainEntity>,
|
||||
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',
|
||||
|
||||
+55
-44
@@ -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<ApprovedAccessDomainEntity>;
|
||||
let approvedAccessDomainRepository: WorkspaceScopedRepository<ApprovedAccessDomainEntity>;
|
||||
let approvedAccessDomainRepositoryUnscoped: Repository<ApprovedAccessDomainEntity>;
|
||||
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>(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,
|
||||
|
||||
+3
@@ -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 {}
|
||||
|
||||
+6
-10
@@ -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<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {}
|
||||
|
||||
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,
|
||||
|
||||
+14
-7
@@ -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<BillingCustomerEntity>,
|
||||
@InjectRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: Repository<BillingEntitlementEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: WorkspaceScopedRepository<BillingEntitlementEntity>,
|
||||
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) =>
|
||||
|
||||
+3
@@ -33,6 +33,8 @@ export class BillingWebhookInvoiceService {
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
// Stripe webhook: workspace discovered from BillingCustomer by stripeCustomerId.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
@@ -120,6 +122,7 @@ export class BillingWebhookInvoiceService {
|
||||
): Promise<void> {
|
||||
const params =
|
||||
await this.resourceCreditService.getResourceCreditRolloverParameters(
|
||||
subscription.workspaceId,
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
|
||||
+2
@@ -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<BillingSubscriptionEntity>,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
|
||||
+7
-2
@@ -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<BillingSubscriptionEntity>,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
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'],
|
||||
|
||||
@@ -22,6 +22,8 @@ export class BillingGaugeService implements OnModuleInit {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
// Observability gauges count subscriptions across every workspace.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-18
@@ -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<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
protected readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
@@ -30,11 +28,10 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspaceCo
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
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'] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
-6
@@ -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<BillingSubscriptionEntity>,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||
) {
|
||||
|
||||
+8
-4
@@ -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 },
|
||||
);
|
||||
|
||||
+17
-16
@@ -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<BillingSubscriptionEntity>
|
||||
WorkspaceScopedRepository<BillingSubscriptionEntity>
|
||||
>;
|
||||
let billingPriceRepository: jest.Mocked<Repository<BillingPriceEntity>>;
|
||||
let billingProductService: jest.Mocked<BillingProductService>;
|
||||
@@ -136,7 +137,7 @@ describe('BillingSubscriptionUpdateService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingSubscriptionEntity),
|
||||
provide: getWorkspaceScopedRepositoryToken(BillingSubscriptionEntity),
|
||||
useValue: repoMock<BillingSubscriptionEntity>(),
|
||||
},
|
||||
{
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+15
-9
@@ -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<any>;
|
||||
@@ -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>(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();
|
||||
});
|
||||
|
||||
+2
-2
@@ -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 = <T extends ObjectLiteral>() =>
|
||||
@@ -81,7 +81,7 @@ export const buildDefaultMeteredTiers = (
|
||||
|
||||
export const arrangeBillingSubscriptionRepositoryFindOneOrFail = (
|
||||
billingSubscriptionRepository: jest.Mocked<
|
||||
Repository<BillingSubscriptionEntity>
|
||||
WorkspaceScopedRepository<BillingSubscriptionEntity>
|
||||
>,
|
||||
params: {
|
||||
planKey?: BillingPlanKey;
|
||||
|
||||
+5
-6
@@ -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<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {}
|
||||
|
||||
async processRolloverOnPeriodTransition({
|
||||
@@ -37,6 +35,7 @@ export class BillingCreditRolloverService {
|
||||
const rolloverAmount = Math.min(unusedCredits, tierQuantity);
|
||||
|
||||
await this.billingCustomerRepository.update(
|
||||
workspaceId,
|
||||
{ stripeCustomerId },
|
||||
{ creditBalanceMicro: rolloverAmount },
|
||||
);
|
||||
|
||||
+19
-15
@@ -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<BillingSubscriptionEntity>,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
) {}
|
||||
@@ -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');
|
||||
|
||||
+21
-10
@@ -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<BillingPriceEntity>,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
@InjectRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
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<void> {
|
||||
const subscription = await this.billingSubscriptionRepository.findOneOrFail(
|
||||
workspaceId,
|
||||
{
|
||||
where: { id: subscriptionId },
|
||||
relations: [
|
||||
|
||||
+37
-27
@@ -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<BillingEntitlementEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingEntitlementEntity)
|
||||
private readonly billingEntitlementRepository: WorkspaceScopedRepository<BillingEntitlementEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
// 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<BillingSubscriptionEntity>,
|
||||
private readonly billingSubscriptionRepositoryUnscoped: Repository<BillingSubscriptionEntity>,
|
||||
private readonly stripeCustomerService: StripeCustomerService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(BillingSubscriptionItemEntity)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItemEntity>,
|
||||
private readonly stripeSubscriptionScheduleService: StripeSubscriptionScheduleService,
|
||||
@InjectRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
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<BillingSubscriptionEntity | undefined> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
+23
-19
@@ -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<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
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<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
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<number> {
|
||||
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(
|
||||
|
||||
@@ -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<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+19
-14
@@ -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<BillingSubscriptionEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingSubscriptionEntity)
|
||||
private readonly billingSubscriptionRepository: WorkspaceScopedRepository<BillingSubscriptionEntity>,
|
||||
) {}
|
||||
|
||||
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;
|
||||
|
||||
+5
-8
@@ -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<BillingCustomerEntity>,
|
||||
@InjectWorkspaceScopedRepository(BillingCustomerEntity)
|
||||
private readonly billingCustomerRepository: WorkspaceScopedRepository<BillingCustomerEntity>,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -24,6 +24,8 @@ export class CustomDomainManagerService {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
// 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<PublicDomainEntity>,
|
||||
private readonly billingService: BillingService,
|
||||
|
||||
+2
@@ -20,6 +20,8 @@ export class WorkspaceDomainsService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
// Request routing resolves workspace via the public domain registry.
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(PublicDomainEntity)
|
||||
private readonly publicDomainRepository: Repository<PublicDomainEntity>,
|
||||
) {}
|
||||
|
||||
+2
-1
@@ -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 {}
|
||||
|
||||
+28
-42
@@ -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<EmailingDomainEntity>,
|
||||
@InjectWorkspaceScopedRepository(EmailingDomainEntity)
|
||||
private readonly emailingDomainRepository: WorkspaceScopedRepository<EmailingDomainEntity>,
|
||||
private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory,
|
||||
) {}
|
||||
|
||||
@@ -24,10 +22,12 @@ export class EmailingDomainService {
|
||||
driver: EmailingDomainDriver,
|
||||
workspace: WorkspaceEntity,
|
||||
): Promise<EmailingDomainEntity> {
|
||||
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<void> {
|
||||
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<EmailingDomainEntity[]> {
|
||||
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<EmailingDomainEntity | null> {
|
||||
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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
+16
-21
@@ -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<FeatureFlagEntity>,
|
||||
@InjectWorkspaceScopedRepository(FeatureFlagEntity)
|
||||
private readonly featureFlagRepository: WorkspaceScopedRepository<FeatureFlagEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@@ -64,7 +64,8 @@ export class FeatureFlagService {
|
||||
): Promise<void> {
|
||||
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',
|
||||
|
||||
+10
-7
@@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-15
@@ -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<FileEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
@@ -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<void> {
|
||||
const file = await this.fileRepository.findOneOrFail({
|
||||
const file = await this.fileRepository.findOneOrFail(workspaceId, {
|
||||
where: {
|
||||
id: fileId,
|
||||
workspaceId,
|
||||
path: Like(`${fileFolder}/%`),
|
||||
},
|
||||
});
|
||||
|
||||
+6
-2
@@ -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 {}
|
||||
|
||||
+13
-10
@@ -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<WorkspaceEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly secureHttpClientService: SecureHttpClientService,
|
||||
) {}
|
||||
@@ -175,11 +177,10 @@ export class FileCorePictureService {
|
||||
fileId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
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<FileWithSignedUrlDTO> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<FileEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
@@ -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<FileResponse | null> {
|
||||
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}/%`),
|
||||
},
|
||||
});
|
||||
|
||||
+2
@@ -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<PublicDomainEntity>,
|
||||
private readonly publicDomainService: PublicDomainService,
|
||||
|
||||
+2
-1
@@ -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 {}
|
||||
|
||||
+9
-10
@@ -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<PublicDomainEntity>,
|
||||
@InjectWorkspaceScopedRepository(PublicDomainEntity)
|
||||
private readonly publicDomainRepository: WorkspaceScopedRepository<PublicDomainEntity>,
|
||||
private readonly publicDomainService: PublicDomainService,
|
||||
private readonly dnsManagerService: DnsManagerService,
|
||||
) {}
|
||||
@@ -49,9 +49,7 @@ export class PublicDomainResolver {
|
||||
async findManyPublicDomains(
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
): Promise<PublicDomainDTO[]> {
|
||||
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<DomainValidRecords | undefined> {
|
||||
const publicDomain = await this.publicDomainRepository.findOne({
|
||||
where: { workspaceId: workspace.id, domain },
|
||||
});
|
||||
const publicDomain = await this.publicDomainRepository.findOne(
|
||||
workspace.id,
|
||||
{ where: { domain } },
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
publicDomain,
|
||||
|
||||
+22
-18
@@ -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<PublicDomainEntity>,
|
||||
// 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<PublicDomainEntity>,
|
||||
private readonly publicDomainRepositoryUnscoped: Repository<PublicDomainEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@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<PublicDomainEntity, 'workspace' | 'application'>
|
||||
>,
|
||||
workspace.id,
|
||||
publicDomain as QueryDeepPartialEntity<PublicDomainEntity>,
|
||||
);
|
||||
} 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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
-4
@@ -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<ConnectedAccountEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
@InjectWorkspaceScopedRepository(FileEntity)
|
||||
private readonly fileRepository: WorkspaceScopedRepository<FileEntity>,
|
||||
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(
|
||||
|
||||
+2
-1
@@ -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],
|
||||
})
|
||||
|
||||
+10
-36
@@ -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', () => {
|
||||
|
||||
+9
-12
@@ -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<TwoFactorAuthenticationMethodEntity>,
|
||||
@InjectWorkspaceScopedRepository(TwoFactorAuthenticationMethodEntity)
|
||||
private readonly twoFactorAuthenticationMethodRepository: WorkspaceScopedRepository<TwoFactorAuthenticationMethodEntity>,
|
||||
) {}
|
||||
|
||||
@Mutation(() => InitiateTwoFactorAuthenticationProvisioningDTO)
|
||||
@@ -132,7 +132,7 @@ export class TwoFactorAuthenticationResolver {
|
||||
@AuthUser() user: AuthContextUser,
|
||||
): Promise<DeleteTwoFactorAuthenticationMethodDTO> {
|
||||
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 };
|
||||
}
|
||||
|
||||
+12
-6
@@ -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>(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,
|
||||
}),
|
||||
|
||||
+8
-9
@@ -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<TwoFactorAuthenticationMethodEntity>,
|
||||
@InjectWorkspaceScopedRepository(TwoFactorAuthenticationMethodEntity)
|
||||
private readonly twoFactorAuthenticationMethodRepository: WorkspaceScopedRepository<TwoFactorAuthenticationMethodEntity>,
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user