Skip to content

Commit

Permalink
feat: add product feature interceptor and hook (#5327)
Browse files Browse the repository at this point in the history
* feat: add product feature interceptor and hook

* refactor: use product feature hook

* fix: after pr comments

* refactor: interceptor

* test: add tests for product feature

* refactor: usage of interceptor

* fix: after pr comment

* fix: lint error
  • Loading branch information
davidsoderberg authored Apr 2, 2024
1 parent 8d37ed2 commit 8466e06
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 1 deletion.
5 changes: 5 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { IdempotencyInterceptor } from './app/shared/framework/idempotency.inter
import { WorkflowOverridesModule } from './app/workflow-overrides/workflow-overrides.module';
import { ApiRateLimitInterceptor } from './app/rate-limiting/guards';
import { RateLimitingModule } from './app/rate-limiting/rate-limiting.module';
import { ProductFeatureInterceptor } from './app/shared/interceptors/product-feature.interceptor';

const enterpriseImports = (): Array<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> => {
const modules: Array<Type | DynamicModule | Promise<DynamicModule> | ForwardReference> = [];
Expand Down Expand Up @@ -99,6 +100,10 @@ const enterpriseModules = enterpriseImports();
const modules = baseModules.concat(enterpriseModules);

const providers: Provider[] = [
{
provide: APP_INTERCEPTOR,
useClass: ProductFeatureInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: ApiRateLimitInterceptor,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Reflector } from '@nestjs/core';
import { ProductFeatureKeyEnum } from '@novu/shared';

// eslint-disable-next-line @typescript-eslint/naming-convention
export const ProductFeature = Reflector.createDecorator<ProductFeatureKeyEnum>();
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { OrganizationRepository } from '@novu/dal';
import {
ApiServiceLevelEnum,
IJwtPayload,
productFeatureEnabledForServiceLevel,
ProductFeatureKeyEnum,
} from '@novu/shared';
import { Observable } from 'rxjs';
import { ProductFeature } from '../decorators/product-feature.decorator';

@Injectable()
export class ProductFeatureInterceptor implements NestInterceptor {
constructor(private reflector: Reflector, private organizationRepository: OrganizationRepository) {}

async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
try {
const handler = context.getHandler();
const classRef = context.getClass();
const requestedFeature: ProductFeatureKeyEnum | undefined = this.reflector.getAllAndOverride(ProductFeature, [
handler,
classRef,
]);

if (requestedFeature === undefined) {
return next.handle();
}

const user = this.getReqUser(context);

if (!user) {
throw new UnauthorizedException();
}

const { organizationId } = user;

const organization = await this.organizationRepository.findById(organizationId);

const enabled = productFeatureEnabledForServiceLevel[requestedFeature].includes(
organization?.apiServiceLevel as ApiServiceLevelEnum
);

if (!enabled) {
throw new HttpException('Payment Required', 402);
}

return next.handle();
} catch (error) {
throw error;
}
}

private getReqUser(context: ExecutionContext): IJwtPayload {
const req = context.switchToHttp().getRequest();

return req.user;
}
}
32 changes: 32 additions & 0 deletions apps/api/src/app/testing/product-feature.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { OrganizationRepository } from '@novu/dal';
import { ApiServiceLevelEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';

describe.only('Product feature Test', async () => {
let session: UserSession;
const path = '/v1/testing/product-feature';
let organizationRepository: OrganizationRepository;

beforeEach(async () => {
session = new UserSession();
await session.initialize();
organizationRepository = new OrganizationRepository();
});

it('should return a number as response when required api service level exists on organization for feature', async () => {
await organizationRepository.update(
{ _id: session.organization._id },
{
apiServiceLevel: ApiServiceLevelEnum.BUSINESS,
}
);
const { body } = await session.testAgent.get(path).set('authorization', `ApiKey ${session.apiKey}`).expect(200);
expect(typeof body.data.number === 'number').to.be.true;
});

it('should return a 402 response when required api service level does not exists on organization for feature', async () => {
const { body } = await session.testAgent.get(path).set('authorization', `ApiKey ${session.apiKey}`).expect(402);
expect(body).to.deep.equal({ statusCode: 402, message: 'Payment Required' });
});
});
13 changes: 12 additions & 1 deletion apps/api/src/app/testing/testing.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Body, Controller, Get, HttpException, NotFoundException, Post, UseGuards } from '@nestjs/common';
import { DalService } from '@novu/dal';
import { IUserEntity } from '@novu/shared';
import { IUserEntity, ProductFeatureKeyEnum } from '@novu/shared';
import { ISeedDataResponseDto, SeedDataBodyDto } from './dtos/seed-data.dto';
import { IdempotencyBodyDto } from './dtos/idempotency.dto';

Expand All @@ -11,6 +11,7 @@ import { CreateSessionCommand } from './usecases/create-session/create-session.c
import { ApiExcludeController } from '@nestjs/swagger';
import { UserAuthGuard } from '../auth/framework/user.auth.guard';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ProductFeature } from '../shared/decorators/product-feature.decorator';

@Controller('/testing')
@ApiExcludeController()
Expand Down Expand Up @@ -75,4 +76,14 @@ export class TestingController {

return { number: Math.random() };
}

@ExternalApiAccessible()
@UseGuards(UserAuthGuard)
@Get('/product-feature')
@ProductFeature(ProductFeatureKeyEnum.TRANSLATIONS)
async productFeatureGet(): Promise<{ number: number }> {
if (process.env.NODE_ENV !== 'test') throw new NotFoundException();

return { number: Math.random() };
}
}
1 change: 1 addition & 0 deletions libs/shared-web/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './useDataRef';
export * from './useKeyDown';
export * from './useEnvController';
export * from './useFeatureFlags';
export * from './useProductFeature';
16 changes: 16 additions & 0 deletions libs/shared-web/src/hooks/useProductFeature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ApiServiceLevelEnum, productFeatureEnabledForServiceLevel, ProductFeatureKeyEnum } from '@novu/shared';
import { useEffect, useState } from 'react';
import { useAuthController } from './useAuthController';

export const useProductFeature = (feature: ProductFeatureKeyEnum) => {
const { organization } = useAuthController();
const [enabled, setEnabled] = useState(false);

useEffect(() => {
setEnabled(
productFeatureEnabledForServiceLevel[feature].includes(organization?.apiServiceLevel as ApiServiceLevelEnum)
);
}, [feature, organization?.apiServiceLevel]);

return enabled;
};
1 change: 1 addition & 0 deletions libs/shared/src/consts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './password-helper';
export * from './filters';
export * from './template-store';
export * from './rate-limiting';
export * from './productFeatureEnabledForServiceLevel';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ApiServiceLevelEnum, ProductFeatureKeyEnum } from '../types';

export const productFeatureEnabledForServiceLevel: Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]> = Object.freeze(
{
[ProductFeatureKeyEnum.TRANSLATIONS]: [ApiServiceLevelEnum.BUSINESS, ApiServiceLevelEnum.ENTERPRISE],
}
);
1 change: 1 addition & 0 deletions libs/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export * from './rate-limiting';
export * from './auth';
export * from './timezones';
export * from './cron';
export * from './product-features';
3 changes: 3 additions & 0 deletions libs/shared/src/types/product-features/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum ProductFeatureKeyEnum {
TRANSLATIONS,
}

0 comments on commit 8466e06

Please sign in to comment.