@ -11,6 +11,10 @@ import {
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
FeatureFlagException,
|
||||
FeatureFlagExceptionCode,
|
||||
} from 'src/engine/core-modules/feature-flag/feature-flag.exception';
|
||||
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
@ -123,9 +127,9 @@ export class AdminPanelService {
|
||||
) {
|
||||
featureFlagValidator.assertIsFeatureFlagKey(
|
||||
featureFlag,
|
||||
new AuthException(
|
||||
new FeatureFlagException(
|
||||
'Invalid feature flag key',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { TrialPeriodDTO } from 'src/engine/core-modules/billing/dto/trial-period.dto';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
|
||||
|
||||
registerEnumType(FeatureFlagKey, {
|
||||
name: 'FeatureFlagKey',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
class Billing {
|
||||
@Field(() => Boolean)
|
||||
@ -52,6 +57,27 @@ class ApiConfig {
|
||||
mutationMaximumAffectedRecords: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PublicFeatureFlagMetadata {
|
||||
@Field(() => String)
|
||||
label: string;
|
||||
|
||||
@Field(() => String)
|
||||
description: string;
|
||||
|
||||
@Field(() => String, { nullable: false, defaultValue: '' })
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class PublicFeatureFlag {
|
||||
@Field(() => FeatureFlagKey)
|
||||
key: FeatureFlagKey;
|
||||
|
||||
@Field(() => PublicFeatureFlagMetadata)
|
||||
metadata: PublicFeatureFlagMetadata;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ClientConfig {
|
||||
@Field(() => AuthProviders, { nullable: false })
|
||||
@ -98,4 +124,7 @@ export class ClientConfig {
|
||||
|
||||
@Field(() => Boolean)
|
||||
canManageFeatureFlags: boolean;
|
||||
|
||||
@Field(() => [PublicFeatureFlag])
|
||||
publicFeatureFlags: PublicFeatureFlag[];
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
|
||||
import { ClientConfig } from './client-config.entity';
|
||||
|
||||
@ -75,6 +76,7 @@ export class ClientConfigResolver {
|
||||
canManageFeatureFlags:
|
||||
this.environmentService.get('DEBUG_MODE') ||
|
||||
this.environmentService.get('IS_BILLING_ENABLED'),
|
||||
publicFeatureFlags: PUBLIC_FEATURE_FLAGS,
|
||||
};
|
||||
|
||||
return Promise.resolve(clientConfig);
|
||||
|
||||
@ -22,6 +22,7 @@ import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-sto
|
||||
import { fileStorageModuleFactory } from 'src/engine/core-modules/file-storage/file-storage.module-factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { HealthModule } from 'src/engine/core-modules/health/health.module';
|
||||
import { LabModule } from 'src/engine/core-modules/lab/lab.module';
|
||||
import { LLMChatModelModule } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module';
|
||||
import { llmChatModelModuleFactory } from 'src/engine/core-modules/llm-chat-model/llm-chat-model.module-factory';
|
||||
import { LLMTracingModule } from 'src/engine/core-modules/llm-tracing/llm-tracing.module';
|
||||
@ -72,6 +73,7 @@ import { FileModule } from './file/file.module';
|
||||
ActorModule,
|
||||
TelemetryModule,
|
||||
AdminPanelModule,
|
||||
LabModule,
|
||||
EnvironmentModule.forRoot({}),
|
||||
RedisClientModule,
|
||||
FileStorageModule.forRootAsync({
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
type FeatureFlagMetadata = {
|
||||
label: string;
|
||||
description: string;
|
||||
imagePath: string;
|
||||
};
|
||||
|
||||
export type PublicFeatureFlag = {
|
||||
key: Extract<FeatureFlagKey, never>;
|
||||
metadata: FeatureFlagMetadata;
|
||||
};
|
||||
|
||||
export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [];
|
||||
@ -0,0 +1,14 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class FeatureFlagException extends CustomException {
|
||||
code: FeatureFlagExceptionCode;
|
||||
constructor(message: string, code: FeatureFlagExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum FeatureFlagExceptionCode {
|
||||
INVALID_FEATURE_FLAG_KEY = 'INVALID_FEATURE_FLAG_KEY',
|
||||
FEATURE_FLAG_IS_NOT_PUBLIC = 'FEATURE_FLAG_IS_NOT_PUBLIC',
|
||||
FEATURE_FLAG_NOT_FOUND = 'FEATURE_FLAG_NOT_FOUND',
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
|
||||
const assertIsFeatureFlagKey = (
|
||||
|
||||
@ -0,0 +1,109 @@
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { PublicFeatureFlag } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { publicFeatureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/is-public-feature-flag.validate';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
|
||||
() => ({
|
||||
FeatureFlagKey: {
|
||||
mockKey1: 'MOCK_KEY_1',
|
||||
mockKey2: 'MOCK_KEY_2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const mockPublicFeatureFlag = {
|
||||
key: 'MOCK_KEY_1',
|
||||
metadata: {
|
||||
label: 'Mock Label 1',
|
||||
description: 'Mock Description 1',
|
||||
imagePath: 'mock/path/1',
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/lab/utils/is-public-feature-flag.util',
|
||||
() => ({
|
||||
isPublicFeatureFlag: (
|
||||
key: FeatureFlagKey,
|
||||
): key is PublicFeatureFlag['key'] => {
|
||||
if (!key) return false;
|
||||
|
||||
return key === mockPublicFeatureFlag.key;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Note: We're using a single public flag for testing as it's sufficient to verify
|
||||
// the validator's behavior. The validator's role is to check if a flag exists in
|
||||
// the PUBLIC_FEATURE_FLAGS array, so testing with one flag adequately covers this
|
||||
// functionality. Adding more flags wouldn't increase the test coverage meaningfully.
|
||||
|
||||
describe('publicFeatureFlagValidator', () => {
|
||||
describe('assertIsPublicFeatureFlag', () => {
|
||||
const mockException = new AuthException(
|
||||
'Not a public feature flag',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
|
||||
it('should not throw for public feature flags', () => {
|
||||
const publicFlag = mockPublicFeatureFlag.key as FeatureFlagKey;
|
||||
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
publicFlag,
|
||||
mockException,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw the provided exception for non-public feature flags', () => {
|
||||
const nonPublicFlag = 'MOCK_KEY_2' as FeatureFlagKey;
|
||||
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
nonPublicFlag,
|
||||
mockException,
|
||||
);
|
||||
}).toThrow(mockException);
|
||||
});
|
||||
|
||||
it('should throw the provided exception for undefined key', () => {
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
undefined as unknown as FeatureFlagKey,
|
||||
mockException,
|
||||
);
|
||||
}).toThrow(mockException);
|
||||
});
|
||||
|
||||
it('should throw the provided exception for null key', () => {
|
||||
expect(() => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
null as unknown as FeatureFlagKey,
|
||||
mockException,
|
||||
);
|
||||
}).toThrow(mockException);
|
||||
});
|
||||
|
||||
it('should maintain type assertion after validation', () => {
|
||||
const publicFlag = mockPublicFeatureFlag;
|
||||
|
||||
const testTypeAssertion = (flag: FeatureFlagKey) => {
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
flag,
|
||||
mockException,
|
||||
);
|
||||
const _test: PublicFeatureFlag['key'] = flag;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
expect(testTypeAssertion(publicFlag.key as FeatureFlagKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { PublicFeatureFlag } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { isPublicFeatureFlag } from 'src/engine/core-modules/lab/utils/is-public-feature-flag.util';
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
const assertIsPublicFeatureFlag = (
|
||||
key: FeatureFlagKey,
|
||||
exceptionToThrow: CustomException,
|
||||
): asserts key is PublicFeatureFlag['key'] => {
|
||||
if (!isPublicFeatureFlag(key)) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
export const publicFeatureFlagValidator: {
|
||||
assertIsPublicFeatureFlag: typeof assertIsPublicFeatureFlag;
|
||||
} = {
|
||||
assertIsPublicFeatureFlag: assertIsPublicFeatureFlag,
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
@InputType()
|
||||
export class UpdateLabPublicFeatureFlagInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
publicFeatureFlag: FeatureFlagKey;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@IsBoolean()
|
||||
value: boolean;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { LabResolver } from './lab.resolver';
|
||||
|
||||
import { LabService } from './services/lab.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FeatureFlagEntity, Workspace], 'core')],
|
||||
providers: [LabService, LabResolver],
|
||||
exports: [LabService],
|
||||
})
|
||||
export class LabModule {}
|
||||
@ -0,0 +1,26 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input';
|
||||
import { LabService } from 'src/engine/core-modules/lab/services/lab.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
export class LabResolver {
|
||||
constructor(private labService: LabService) {}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Mutation(() => Boolean)
|
||||
async updateLabPublicFeatureFlag(
|
||||
@Args('input') input: UpdateLabPublicFeatureFlagInput,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<boolean> {
|
||||
await this.labService.updateLabPublicFeatureFlag(workspace.id, input);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
FeatureFlagException,
|
||||
FeatureFlagExceptionCode,
|
||||
} 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 { UpdateLabPublicFeatureFlagInput } from 'src/engine/core-modules/lab/dtos/update-lab-public-feature-flag.input';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
|
||||
@Injectable()
|
||||
export class LabService {
|
||||
constructor(
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
) {}
|
||||
|
||||
async updateLabPublicFeatureFlag(
|
||||
workspaceId: string,
|
||||
payload: UpdateLabPublicFeatureFlagInput,
|
||||
): Promise<void> {
|
||||
featureFlagValidator.assertIsFeatureFlagKey(
|
||||
payload.publicFeatureFlag,
|
||||
new FeatureFlagException(
|
||||
'Invalid feature flag key',
|
||||
FeatureFlagExceptionCode.INVALID_FEATURE_FLAG_KEY,
|
||||
),
|
||||
);
|
||||
|
||||
publicFeatureFlagValidator.assertIsPublicFeatureFlag(
|
||||
FeatureFlagKey[payload.publicFeatureFlag],
|
||||
new FeatureFlagException(
|
||||
'Feature flag is not public',
|
||||
FeatureFlagExceptionCode.FEATURE_FLAG_IS_NOT_PUBLIC,
|
||||
),
|
||||
);
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: { id: workspaceId },
|
||||
relations: ['featureFlags'],
|
||||
});
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
const existingFlag = workspace.featureFlags?.find(
|
||||
(flag) => flag.key === FeatureFlagKey[payload.publicFeatureFlag],
|
||||
);
|
||||
|
||||
if (!existingFlag) {
|
||||
throw new FeatureFlagException(
|
||||
'Public feature flag not found',
|
||||
FeatureFlagExceptionCode.FEATURE_FLAG_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.featureFlagRepository.update(existingFlag.id, {
|
||||
value: payload.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
import { isPublicFeatureFlag } from './is-public-feature-flag.util';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum',
|
||||
() => ({
|
||||
FeatureFlagKey: {
|
||||
mockKey1: 'MOCK_KEY_1',
|
||||
mockKey2: 'MOCK_KEY_2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'src/engine/core-modules/feature-flag/constants/public-feature-flag.const',
|
||||
() => ({
|
||||
PUBLIC_FEATURE_FLAGS: [
|
||||
{
|
||||
key: 'MOCK_KEY_1',
|
||||
metadata: {
|
||||
label: 'Mock Label 1',
|
||||
description: 'Mock Description 1',
|
||||
imagePath: 'mock/path/1',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
describe('isPublicFeatureFlag', () => {
|
||||
it('should return true for public flags', () => {
|
||||
const publicFlag = 'MOCK_KEY_1';
|
||||
|
||||
expect(isPublicFeatureFlag(publicFlag as FeatureFlagKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-public flags', () => {
|
||||
const nonPublicFlag = 'MOCK_KEY_2';
|
||||
|
||||
expect(isPublicFeatureFlag(nonPublicFlag as FeatureFlagKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined/null', () => {
|
||||
expect(isPublicFeatureFlag(undefined as any)).toBe(false);
|
||||
expect(isPublicFeatureFlag(null as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
import {
|
||||
PUBLIC_FEATURE_FLAGS,
|
||||
PublicFeatureFlag,
|
||||
} from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
|
||||
export const isPublicFeatureFlag = (
|
||||
key: FeatureFlagKey,
|
||||
): key is PublicFeatureFlag['key'] => {
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PUBLIC_FEATURE_FLAGS.some((flag) => flag.key === key);
|
||||
};
|
||||
Reference in New Issue
Block a user