diff --git a/src/lib/features/export-import-toggles/export-import-service.ts b/src/lib/features/export-import-toggles/export-import-service.ts index 33e73f3217..349bf9596e 100644 --- a/src/lib/features/export-import-toggles/export-import-service.ts +++ b/src/lib/features/export-import-toggles/export-import-service.ts @@ -41,7 +41,10 @@ import { TagTypeService, } from '../../services'; import { isValidField } from './import-context-validation'; -import { IImportTogglesStore } from './import-toggles-store-type'; +import { + IImportTogglesStore, + ProjectFeaturesLimit, +} from './import-toggles-store-type'; import { ImportPermissionsService, Mode } from './import-permissions-service'; import { ImportValidationMessages } from './import-validation-messages'; import { findDuplicates } from '../../util/findDuplicates'; @@ -158,6 +161,7 @@ export default class ExportImportService { missingPermissions, duplicateFeatures, featureNameCheckResult, + featureLimitResult, ] = await Promise.all([ this.getUnsupportedStrategies(dto), this.getUsedCustomStrategies(dto), @@ -172,6 +176,7 @@ export default class ExportImportService { ), this.getDuplicateFeatures(dto), this.getInvalidFeatureNames(dto), + this.getFeatureLimit(dto), ]); const errors = ImportValidationMessages.compileErrors({ @@ -181,6 +186,7 @@ export default class ExportImportService { otherProjectFeatures, duplicateFeatures, featureNameCheckResult, + featureLimitResult, }); const warnings = ImportValidationMessages.compileWarnings({ archivedFeatures, @@ -511,6 +517,16 @@ export default class ExportImportService { ); } + private async getFeatureLimit({ + project, + data, + }: ImportTogglesSchema): Promise { + return this.importTogglesStore.getProjectFeaturesLimit( + [...new Set(data.features.map((f) => f.name))], + project, + ); + } + private async getUnsupportedStrategies( dto: ImportTogglesSchema, ): Promise { diff --git a/src/lib/features/export-import-toggles/export-import.e2e.test.ts b/src/lib/features/export-import-toggles/export-import.e2e.test.ts index 0613af453a..dd8c0e44b0 100644 --- a/src/lib/features/export-import-toggles/export-import.e2e.test.ts +++ b/src/lib/features/export-import-toggles/export-import.e2e.test.ts @@ -844,7 +844,8 @@ test('reject import with duplicate features', async () => { }); test('validate import data', async () => { - await createProjects(); + const featureLimit = 1; + await createProjects([DEFAULT_PROJECT], featureLimit); const contextField: IContextFieldDto = { name: 'validate_context_field', @@ -864,7 +865,11 @@ test('validate import data', async () => { ...defaultImportPayload, data: { ...defaultImportPayload.data, - features: [exportedFeature, exportedFeature], + features: [ + exportedFeature, + exportedFeature, + anotherExportedFeature, + ], featureStrategies: [{ name: 'customStrategy' }], segments: [{ id: 1, name: 'customSegment' }], contextFields: [ @@ -909,7 +914,15 @@ test('validate import data', async () => { { message: expect.stringMatching(/\btestpattern.+\b/), - affectedItems: [defaultFeatureName], + affectedItems: [ + defaultFeatureName, + anotherExportedFeature.name, + ], + }, + { + message: + 'We detected you want to create 2 new features to a project that already has 0 existing features, exceeding the maximum limit of 1.', + affectedItems: [], }, ], warnings: [ diff --git a/src/lib/features/export-import-toggles/import-toggles-store-type.ts b/src/lib/features/export-import-toggles/import-toggles-store-type.ts index d8c1aabd2a..60cd0afaf3 100644 --- a/src/lib/features/export-import-toggles/import-toggles-store-type.ts +++ b/src/lib/features/export-import-toggles/import-toggles-store-type.ts @@ -1,3 +1,9 @@ +export interface ProjectFeaturesLimit { + limit: number; + newFeaturesCount: number; + currentFeaturesCount: number; +} + export interface IImportTogglesStore { deleteStrategiesForFeatures( featureNames: string[], @@ -16,6 +22,11 @@ export interface IImportTogglesStore { project: string, ): Promise; + getProjectFeaturesLimit( + featureNames: string[], + project: string, + ): Promise; + deleteTagsForFeatures(tags: string[]): Promise; strategiesExistForFeatures( diff --git a/src/lib/features/export-import-toggles/import-toggles-store.ts b/src/lib/features/export-import-toggles/import-toggles-store.ts index 0daf877622..59a217a568 100644 --- a/src/lib/features/export-import-toggles/import-toggles-store.ts +++ b/src/lib/features/export-import-toggles/import-toggles-store.ts @@ -1,10 +1,14 @@ -import { IImportTogglesStore } from './import-toggles-store-type'; +import { + IImportTogglesStore, + ProjectFeaturesLimit, +} from './import-toggles-store-type'; import { Db } from '../../db/db'; const T = { featureStrategies: 'feature_strategies', features: 'features', featureTag: 'feature_tag', + projectSettings: 'project_settings', }; export class ImportTogglesStore implements IImportTogglesStore { private db: Db; @@ -86,6 +90,38 @@ export class ImportTogglesStore implements IImportTogglesStore { return rows.map((row) => row.name); } + async getProjectFeaturesLimit( + featureNames: string[], + project: string, + ): Promise { + const row = await this.db(T.projectSettings) + .select(['feature_limit']) + .where('project', project) + .first(); + const limit: number = row?.feature_limit ?? Number.MAX_SAFE_INTEGER; + + const existingFeaturesCount = await this.db(T.features) + .whereIn('name', featureNames) + .andWhere('project', project) + .where('archived_at', null) + .count() + .then((res) => Number(res[0].count)); + + const newFeaturesCount = featureNames.length - existingFeaturesCount; + + const currentFeaturesCount = await this.db(T.features) + .where('project', project) + .count() + .where('archived_at', null) + .then((res) => Number(res[0].count)); + + return { + limit, + newFeaturesCount, + currentFeaturesCount, + }; + } + async deleteTagsForFeatures(features: string[]): Promise { return this.db(T.featureTag).whereIn('feature_name', features).del(); } diff --git a/src/lib/features/export-import-toggles/import-validation-messages.ts b/src/lib/features/export-import-toggles/import-validation-messages.ts index e590a349e4..7345ef76d7 100644 --- a/src/lib/features/export-import-toggles/import-validation-messages.ts +++ b/src/lib/features/export-import-toggles/import-validation-messages.ts @@ -4,6 +4,7 @@ import { ImportTogglesValidateItemSchema, } from '../../openapi'; import { IContextFieldDto } from '../../types/stores/context-field-store'; +import { ProjectFeaturesLimit } from './import-toggles-store-type'; export interface IErrorsParams { projectName: string; @@ -12,6 +13,7 @@ export interface IErrorsParams { otherProjectFeatures: string[]; duplicateFeatures: string[]; featureNameCheckResult: FeatureNameCheckResult; + featureLimitResult: ProjectFeaturesLimit; } export interface IWarningParams { @@ -43,6 +45,7 @@ export class ImportValidationMessages { otherProjectFeatures, duplicateFeatures, featureNameCheckResult, + featureLimitResult, }: IErrorsParams): ImportTogglesValidateItemSchema[] { const errors: ImportTogglesValidateItemSchema[] = []; @@ -92,6 +95,16 @@ export class ImportValidationMessages { affectedItems: [...featureNameCheckResult.invalidNames].sort(), }); } + if ( + featureLimitResult.currentFeaturesCount + + featureLimitResult.newFeaturesCount > + featureLimitResult.limit + ) { + errors.push({ + message: `We detected you want to create ${featureLimitResult.newFeaturesCount} new features to a project that already has ${featureLimitResult.currentFeaturesCount} existing features, exceeding the maximum limit of ${featureLimitResult.limit}.`, + affectedItems: [], + }); + } return errors; }