Skip to content

Commit

Permalink
fix(dashboard): added FF to FE
Browse files Browse the repository at this point in the history
  • Loading branch information
tatarco committed Feb 13, 2025
1 parent c2d4505 commit ea985f6
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 64 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { RegenerateApiKeys } from './usecases/regenerate-api-keys/regenerate-api
import { UpdateEnvironmentCommand } from './usecases/update-environment/update-environment.command';
import { UpdateEnvironment } from './usecases/update-environment/update-environment.usecase';
import { RolesGuard } from '../auth/framework/roles.guard';

/**
* @deprecated use EnvironmentsControllerV2
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@ import { expect } from 'chai';

import { EnvironmentRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared';
import { ApiServiceLevelEnum, NOVU_ENCRYPTION_SUB_MASK } from '@novu/shared';

async function createEnv(name: string, session) {
const demoEnvironment = {
name,
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment);

return { demoEnvironment, body };
}

describe('Create Environment - /environments (POST)', async () => {
let session: UserSession;
Expand All @@ -12,11 +22,13 @@ describe('Create Environment - /environments (POST)', async () => {
await session.initialize({
noEnvironment: true,
});
session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS);
});

it('should create environment entity correctly', async () => {
const demoEnvironment = {
name: 'Hello App',
color: '#3A7F5C',
};
const { body } = await session.testAgent.post('/v1/environments').send(demoEnvironment).expect(201);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import { CreateNovuIntegrations } from '../../../integrations/usecases/create-no
import { CreateDefaultLayout, CreateDefaultLayoutCommand } from '../../../layouts/usecases';
import { GenerateUniqueApiKey } from '../generate-unique-api-key/generate-unique-api-key.usecase';
import { CreateEnvironmentCommand } from './create-environment.command';
import { ValidateTiersUseCase } from './validate-tiers-use.case';
import { TierValidationTypeEnum } from './tier-validation-type.enum';

@Injectable()
export class CreateEnvironment {
Expand All @@ -22,19 +20,13 @@ export class CreateEnvironment {
private notificationGroupRepository: NotificationGroupRepository,
private generateUniqueApiKey: GenerateUniqueApiKey,
private createDefaultLayoutUsecase: CreateDefaultLayout,
private createNovuIntegrationsUsecase: CreateNovuIntegrations,
private tierValidator: ValidateTiersUseCase
private createNovuIntegrationsUsecase: CreateNovuIntegrations
) {}

async execute(command: CreateEnvironmentCommand) {
const environmentCount = await this.environmentRepository.count({
_organizationId: command.organizationId,
});
this.tierValidator.execute({
organizationId: command.organizationId,
validationType: TierValidationTypeEnum.ENVIRONMENT_COUNT,
valueToValidate: environmentCount,
});

const normalizedName = command.name.trim();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { TierValidationTypeEnum } from './tier-validation-type.enum';

export class TierExceededException extends Error {
constructor(
public tierValidationEnum: TierValidationTypeEnum,
message: string
) {
super(message);
this.name = 'TierExceededException';
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export enum TierValidationTypeEnum {
ENVIRONMENT_COUNT = 'ENVIRONMENT_COUNT',
CUSTOM_ENVIROMENTS = 'CUSTOM_ENVIROMENTS',

Check warning on line 2 in apps/api/src/app/environments-v1/usecases/create-environment/tier-validation-type.enum.ts

View workflow job for this annotation

GitHub Actions / Spell check

Misspelled word (ENVIROMENTS) Suggestions: (environments*)

Check warning on line 2 in apps/api/src/app/environments-v1/usecases/create-environment/tier-validation-type.enum.ts

View workflow job for this annotation

GitHub Actions / Spell check

Misspelled word (ENVIROMENTS) Suggestions: (environments*)
WORKFLOW_COUNT = 'WORKFLOW_COUNT',
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import {
FeatureFlags,
FeatureFlagsKeysEnum,
FeatureNameEnum,
getFeatureForTierAsBoolean,
getFeatureForTierAsNumber,
} from '@novu/shared';
import { OrganizationRepository } from '@novu/dal';
import { Injectable } from '@nestjs/common';
import { GetFeatureFlag, GetFeatureFlagCommand, TierRestrictionsValidateCommand } from '@novu/application-generic';
import { ValidateTiersCommand } from './validate-tiers.command';
import { TierValidationTypeEnum } from './tier-validation-type.enum';
import { TierExceededException } from './tier-exceeded.exception';

@Injectable()
export class ValidateTiersUseCase {
Expand All @@ -21,14 +21,12 @@ export class ValidateTiersUseCase {
async execute(validateTiersCommand: ValidateTiersCommand): Promise<void> {
const organization = await this.organizationRepository.findById(validateTiersCommand.organizationId);
const featureFlags = await this.getFeatureFlags(validateTiersCommand);
if (!organization || !organization.apiServiceLevel) {
if (!organization) {
throw new Error(`Organization not found ${JSON.stringify(organization)}`);
}
if (validateTiersCommand.validationType === TierValidationTypeEnum.ENVIRONMENT_COUNT) {
this.validateEnvironmentCount(organization.apiServiceLevel, featureFlags);
}
const apiServiceLevel = organization.apiServiceLevel || ApiServiceLevelEnum.FREE;
if (validateTiersCommand.validationType === TierValidationTypeEnum.WORKFLOW_COUNT) {
this.validateWorkflowCount(validateTiersCommand, organization.apiServiceLevel, featureFlags);
this.validateWorkflowCount(validateTiersCommand, apiServiceLevel, featureFlags);
}
}
private async getFeatureFlags(command: TierRestrictionsValidateCommand): Promise<Partial<FeatureFlags>> {
Expand Down Expand Up @@ -59,17 +57,6 @@ export class ValidateTiersUseCase {
return { key, value: status };
}

private validateEnvironmentCount(apiServiceLevel: ApiServiceLevelEnum, featureFlags: Partial<FeatureFlags>) {
const allowedToAdd = getFeatureForTierAsBoolean(
FeatureNameEnum.CUSTOM_ENVIRONMENTS_BOOLEAN,
apiServiceLevel,
featureFlags
);
if (!allowedToAdd) {
throw new Error(`You have exceeded the maximum number of environments allowed for the [${apiServiceLevel}] tier`);
}
}

private validateWorkflowCount(
validateTiersCommand: ValidateTiersCommand,
apiServiceLevel: ApiServiceLevelEnum,
Expand All @@ -86,7 +73,10 @@ export class ValidateTiersUseCase {
return;
}
if (numberOfWorkflows >= maxWorkflows) {
throw new Error(`You have exceeded the maximum number of workflows allowed for the [${apiServiceLevel}] tier`);
throw new TierExceededException(
TierValidationTypeEnum.WORKFLOW_COUNT,
`You have exceeded the maximum number of workflows allowed for the [${apiServiceLevel}] tier`
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class UpsertWorkflowUseCase {
const existingWorkflowCount = await this.countWorkflows(command);
await this.validateTiersUseCase.execute({
organizationId: command.user.organizationId,
validationType: TierValidationTypeEnum.ENVIRONMENT_COUNT,
validationType: TierValidationTypeEnum.WORKFLOW_COUNT,
valueToValidate: existingWorkflowCount,
});
}
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/app/workflows-v2/workflow.controller.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ import { expect } from 'chai';
import { UserSession } from '@novu/testing';
import { randomBytes } from 'crypto';
import {
ApiServiceLevelEnum,
createWorkflowClient,
CreateWorkflowDto,
DEFAULT_WORKFLOW_PREFERENCES,
FeatureFlagsKeysEnum,
FeatureNameEnum,
getFeatureForTierAsNumber,
isStepUpdateBody,
JSONSchemaDefinition,
JSONSchemaDto,
Expand Down Expand Up @@ -311,6 +315,26 @@ describe('Workflow Controller E2E API Testing #novu-v2', () => {
expect(payloadProperties).to.be.ok;
expect(payloadProperties.properties?.name).to.be.ok;
});

it('should not allow to create more than 20 workflows for a free organization', async () => {
// @ts-ignore
process.env.IS_2025_Q1_TIERING_ENABLED = 'true';
session.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE);
const featureFlags = { [FeatureFlagsKeysEnum.IS_2025_Q1_TIERING_ENABLED]: true };
getFeatureForTierAsNumber(FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, ApiServiceLevelEnum.FREE, featureFlags, false);
for (let i = 0; i < 20; i += 1) {
const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto(new Date().toISOString() + i);
const res = await workflowsClient.createWorkflow(createWorkflowDto);
}

const createWorkflowDto: CreateWorkflowDto = buildCreateWorkflowDto(new Date().toISOString() + 30);
const res = await workflowsClient.createWorkflow(createWorkflowDto);
if (res.isSuccessResult()) {
throw new Error('should fail');
}
const { error } = res;
expect(error?.status).eq(402);
});
});

describe('Update Workflow Permutations', () => {
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/exception-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { captureException } from '@sentry/node';
import { ZodError } from 'zod';
import { InternalServerErrorException } from '@nestjs/common/exceptions/internal-server-error.exception';
import { ErrorDto, ValidationErrorDto } from './error-dto';
import { TierExceededException } from './app/environments-v1/usecases/create-environment/tier-exceeded.exception';

const ERROR_MSG_500 = `Internal server error, contact support and provide them with the errorId`;
export class AllExceptionsFilter implements ExceptionFilter {
Expand Down Expand Up @@ -54,6 +55,9 @@ export class AllExceptionsFilter implements ExceptionFilter {
if (exception instanceof ZodError) {
return this.handleZod(exception, request);
}
if (exception instanceof TierExceededException) {
return this.handleTierException(exception, request);
}
if (exception instanceof CommandValidationException) {
return this.handleCommandValidation(exception, request);
}
Expand Down Expand Up @@ -126,6 +130,14 @@ export class AllExceptionsFilter implements ExceptionFilter {

return this.buildErrorDto(request, HttpStatus.BAD_REQUEST, 'Zod Validation Failed', ctx);
}

private handleTierException(exception: TierExceededException, request: Request) {
const ctx = {
tierExceeded: exception.tierValidationEnum,
};

return this.buildErrorDto(request, HttpStatus.PAYMENT_REQUIRED, 'Tier Exceeded', ctx);
}
}

class ResponseMetadata {
Expand Down
55 changes: 30 additions & 25 deletions apps/dashboard/src/components/billing/features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ enum SupportedPlansEnum {
ENTERPRISE = 'ENTERPRISE',
}

const SupportedPlansEnumToServiceLevelRecord: Record<SupportedPlansEnum, ApiServiceLevelEnum> = {
const supportedPlansEnumToServiceLevelRecord: Record<SupportedPlansEnum, ApiServiceLevelEnum> = {
FREE: ApiServiceLevelEnum.FREE,
PRO: ApiServiceLevelEnum.PRO,
TEAM: ApiServiceLevelEnum.TEAM,
Expand All @@ -29,7 +29,7 @@ type FeatureValue = {
type Feature = {
label: string;
isTitle?: boolean;
values: Record<SupportedPlansEnum, FeatureValue>;
values: Partial<Record<SupportedPlansEnum, FeatureValue>>;
};

interface BuildValuesParams {
Expand All @@ -39,7 +39,7 @@ interface BuildValuesParams {
suffix?: string | React.ReactNode;
}

const features: (activeFlags: FeatureFlags) => Feature[] = (activeFlags: FeatureFlags) => {
const features: (activeFlags: FeatureFlags) => Feature[] = (featureFlags: FeatureFlags) => {
return [
{
label: 'Platform',
Expand Down Expand Up @@ -182,28 +182,16 @@ const features: (activeFlags: FeatureFlags) => Feature[] = (activeFlags: Feature
function buildEmptyRow() {
return buildTableRowRecord({});
}
function buildTableRowRecord(params: BuildValuesParams): Record<SupportedPlansEnum, FeatureValue> {
return Object.values(SupportedPlansEnum).reduce(
(acc, plan) => {
const apiServiceLevel = SupportedPlansEnumToServiceLevelRecord[plan];
if (params.isBoolean) {
const bool = params.featureName ? getFeatureForTierAsBoolean(params.featureName, apiServiceLevel) : '';
acc[plan] = {
value: bool ? <Check className="h-4 w-4" /> : '-',
};
return acc;
}
const text = params.featureName
? getFeatureForTierAsText(params.featureName, apiServiceLevel, activeFlags)
: '';
const value = `${params.prefix || ''}${text}${params.suffix || ''}`;
acc[plan] = {
value,
};
return acc;
},
{} as Record<SupportedPlansEnum, FeatureValue>
);
function buildTableRowRecord(params: BuildValuesParams): Partial<Record<SupportedPlansEnum, FeatureValue>> {
const result: Partial<Record<SupportedPlansEnum, FeatureValue>> = {};

for (const plan of Object.values(SupportedPlansEnum)) {
result[plan] = {
value: getValue(params, supportedPlansEnumToServiceLevelRecord[plan], featureFlags),
};
}

return result;
}
};

Expand Down Expand Up @@ -246,3 +234,20 @@ export function Features() {
</div>
);
}
function getBooleanValue(params: BuildValuesParams, apiServiceLevel: ApiServiceLevelEnum, featureFlags: FeatureFlags) {
const bool = params.featureName ? getFeatureForTierAsBoolean(params.featureName, apiServiceLevel, featureFlags) : '';
return bool ? <Check className="h-4 w-4" /> : '-';
}

function getTextValue(params: BuildValuesParams, apiServiceLevel: ApiServiceLevelEnum, featureFlags: FeatureFlags) {
const text = params.featureName ? getFeatureForTierAsText(params.featureName, apiServiceLevel, featureFlags) : '';
return `${params.prefix || ''}${text}${params.suffix || ''}`;
}

function getValue(params: BuildValuesParams, apiServiceLevel: ApiServiceLevelEnum, featureFlags: FeatureFlags) {
if (params.isBoolean) {
return getBooleanValue(params, apiServiceLevel, featureFlags);
} else {
return getTextValue(params, apiServiceLevel, featureFlags);
}
}
15 changes: 12 additions & 3 deletions packages/shared/src/consts/feature-tiers-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ApiServiceLevelEnum, FeatureFlags, FeatureFlagsKeysEnum } from '../type

export enum FeatureNameEnum {
// Platform Features
AUTO_TRANSLATIONS = 'autoTranslations',
PLATFORM_TERMS_OF_SERVICE = 'platformTermsOfService',
PLATFORM_PLAN_LABEL = 'platformPlanLabel',
PAYMENT_METHOD = 'platformPaymentMethod',
Expand Down Expand Up @@ -264,6 +265,14 @@ const novuServiceTiers: Record<FeatureNameEnum, Record<ApiServiceLevelEnum, Feat
[ApiServiceLevelEnum.ENTERPRISE]: true,
[ApiServiceLevelEnum.UNLIMITED]: true,
},
[FeatureNameEnum.AUTO_TRANSLATIONS]: {
[ApiServiceLevelEnum.FREE]: false,
[ApiServiceLevelEnum.PRO]: false,
[ApiServiceLevelEnum.TEAM]: true,
[ApiServiceLevelEnum.BUSINESS]: true,
[ApiServiceLevelEnum.ENTERPRISE]: true,
[ApiServiceLevelEnum.UNLIMITED]: true,
},
[FeatureNameEnum.PLATFORM_MULTI_ORG_MULTI_TENANCY]: {
[ApiServiceLevelEnum.FREE]: { label: 'No', value: 0 },
[ApiServiceLevelEnum.PRO]: { label: 'No', value: 0 },
Expand Down Expand Up @@ -547,10 +556,10 @@ function getOriginalFeatureOrAugments(
): FeatureValue {
const originalFeature = novuServiceTiers[featureName][tier];

for (const featureFlagKey of Object.keys(featureFlags)) {
const featureFlagGetter = inActiveFeatureFlagRecordGetters[featureFlagKey];
for (const inActiveFunctionFF of Object.keys(inActiveFeatureFlagRecordGetters)) {
const featureFlagGetter = inActiveFeatureFlagRecordGetters[inActiveFunctionFF];

if (featureFlagGetter && !featureFlags[featureFlagKey]) {
if (featureFlagGetter && !featureFlags[inActiveFunctionFF]) {
const potentiallyAugmentsFeatureValue = featureFlagGetter(featureName, originalFeature, tier);
if (!isEqual(potentiallyAugmentsFeatureValue, originalFeature)) {
return potentiallyAugmentsFeatureValue;
Expand Down
31 changes: 25 additions & 6 deletions packages/shared/src/consts/productFeatureEnabledForServiceLevel.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
import { ApiServiceLevelEnum, ProductFeatureKeyEnum } from '../types';
import { FeatureNameEnum, getFeatureForTierAsBoolean } from './feature-tiers-constants';

export const productFeatureEnabledForServiceLevel: Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]> = Object.freeze(
{
[ProductFeatureKeyEnum.TRANSLATIONS]: [ApiServiceLevelEnum.BUSINESS, ApiServiceLevelEnum.ENTERPRISE],
[ProductFeatureKeyEnum.MANAGE_ENVIRONMENTS]: [ApiServiceLevelEnum.BUSINESS, ApiServiceLevelEnum.ENTERPRISE],
}
);
const featureAccessAtoFeatureNameMapping = {
[ProductFeatureKeyEnum.TRANSLATIONS]: FeatureNameEnum.AUTO_TRANSLATIONS,
[ProductFeatureKeyEnum.MANAGE_ENVIRONMENTS]: FeatureNameEnum.CUSTOM_ENVIRONMENTS_BOOLEAN,
};
function createProductFeatureMap(): Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]> {
const productFeatures: Partial<Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]>> = {};
Object.values(ApiServiceLevelEnum).forEach((apiServiceLevel) => {
Object.entries(featureAccessAtoFeatureNameMapping).forEach(([productFeatureKey, featureName]) => {
const isFeatureEnabled = getFeatureForTierAsBoolean(featureName, apiServiceLevel, {});

if (isFeatureEnabled) {
if (!productFeatures[productFeatureKey]) {
productFeatures[productFeatureKey] = [];
}
productFeatures[productFeatureKey]!.push(apiServiceLevel);
}
});
});

return Object.freeze(productFeatures as Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]>);
}

export const productFeatureEnabledForServiceLevel: Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]> =
createProductFeatureMap();

0 comments on commit ea985f6

Please sign in to comment.