From d796c78b580036cc46ddc2bf6e7720b84ee75d48 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Fri, 2 Feb 2024 01:38:51 -0400 Subject: [PATCH 01/22] feat:add-feature-limit-guard --- .../20240128230532-create-tiers-table.js | 38 +++++++ .../20240128230553-create-limits-table.js | 42 ++++++++ ...0240128230608-create-tiers-limits-table.js | 40 +++++++ .../20240128230626-add-tier-id-to-users.js | 17 +++ .../decorators/apply-limit.decorator.ts | 17 +++ .../feature-limit/feature-limit.module.ts | 28 +++++ .../feature-limit/feature-limit.repository.ts | 44 ++++++++ .../feature-limit/feature-limits.guard.ts | 84 +++++++++++++++ .../feature-limit/limit-check.service.ts | 101 ++++++++++++++++++ src/modules/feature-limit/limit.domain.ts | 23 ++++ .../feature-limit/limits.attributes.ts | 34 ++++++ src/modules/feature-limit/limits.enum.ts | 9 ++ .../feature-limit/models/limit.model.ts | 50 +++++++++ .../feature-limit/models/tier-limits.model.ts | 35 ++++++ .../feature-limit/models/tier.model.ts | 41 +++++++ src/modules/sharing/sharing.controller.ts | 8 ++ src/modules/sharing/sharing.module.ts | 4 +- src/modules/sharing/sharing.repository.ts | 10 ++ 18 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 migrations/20240128230532-create-tiers-table.js create mode 100644 migrations/20240128230553-create-limits-table.js create mode 100644 migrations/20240128230608-create-tiers-limits-table.js create mode 100644 migrations/20240128230626-add-tier-id-to-users.js create mode 100644 src/modules/feature-limit/decorators/apply-limit.decorator.ts create mode 100644 src/modules/feature-limit/feature-limit.module.ts create mode 100644 src/modules/feature-limit/feature-limit.repository.ts create mode 100644 src/modules/feature-limit/feature-limits.guard.ts create mode 100644 src/modules/feature-limit/limit-check.service.ts create mode 100644 src/modules/feature-limit/limit.domain.ts create mode 100644 src/modules/feature-limit/limits.attributes.ts create mode 100644 src/modules/feature-limit/limits.enum.ts create mode 100644 src/modules/feature-limit/models/limit.model.ts create mode 100644 src/modules/feature-limit/models/tier-limits.model.ts create mode 100644 src/modules/feature-limit/models/tier.model.ts diff --git a/migrations/20240128230532-create-tiers-table.js b/migrations/20240128230532-create-tiers-table.js new file mode 100644 index 000000000..57da4f4e2 --- /dev/null +++ b/migrations/20240128230532-create-tiers-table.js @@ -0,0 +1,38 @@ +'use strict'; + +const tableName = 'tiers'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + label: { + type: Sequelize.STRING, + allowNull: false, + }, + context: { + type: Sequelize.STRING, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable(tableName); + }, +}; diff --git a/migrations/20240128230553-create-limits-table.js b/migrations/20240128230553-create-limits-table.js new file mode 100644 index 000000000..3f9d63d0a --- /dev/null +++ b/migrations/20240128230553-create-limits-table.js @@ -0,0 +1,42 @@ +'use strict'; + +const tableName = 'limits'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + label: { + type: Sequelize.STRING, + allowNull: false, + }, + type: { + type: Sequelize.ENUM('counter', 'boolean'), + allowNull: false, + }, + value: { + type: Sequelize.INTEGER, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable(tableName); + }, +}; diff --git a/migrations/20240128230608-create-tiers-limits-table.js b/migrations/20240128230608-create-tiers-limits-table.js new file mode 100644 index 000000000..28738cf29 --- /dev/null +++ b/migrations/20240128230608-create-tiers-limits-table.js @@ -0,0 +1,40 @@ +'use strict'; + +const tableName = 'tiers_limits'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + tier_id: { + type: Sequelize.UUID, + allowNull: false, + references: { model: 'tiers', key: 'id' }, + }, + limit_id: { + type: Sequelize.UUID, + allowNull: false, + references: { model: 'limits', key: 'id' }, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable(tableName); + }, +}; diff --git a/migrations/20240128230626-add-tier-id-to-users.js b/migrations/20240128230626-add-tier-id-to-users.js new file mode 100644 index 000000000..8c2275461 --- /dev/null +++ b/migrations/20240128230626-add-tier-id-to-users.js @@ -0,0 +1,17 @@ +'use strict'; + +const tableName = 'users'; +const newColumn = 'tier_id'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(tableName, newColumn, { + type: Sequelize.UUID, + references: { model: 'tiers', key: 'id' }, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(tableName, newColumn); + }, +}; diff --git a/src/modules/feature-limit/decorators/apply-limit.decorator.ts b/src/modules/feature-limit/decorators/apply-limit.decorator.ts new file mode 100644 index 000000000..5f86607d6 --- /dev/null +++ b/src/modules/feature-limit/decorators/apply-limit.decorator.ts @@ -0,0 +1,17 @@ +import { SetMetadata } from '@nestjs/common'; +import { LimitLabels } from '../limits.enum'; + +interface DataSource { + sourceKey: 'body' | 'params' | 'query' | 'headers'; + fieldName: string; +} + +export interface ApplyLimitMetadata { + limitLabel: LimitLabels; + dataSources?: DataSource[]; +} + +export const FEATURE_LIMIT_KEY = 'feature-limit'; + +export const ApplyLimit = (metadata: ApplyLimitMetadata) => + SetMetadata(FEATURE_LIMIT_KEY, metadata); diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts new file mode 100644 index 000000000..71f6e51bb --- /dev/null +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -0,0 +1,28 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { TierModel } from './models/tier.model'; +import { Limitmodel } from './models/limit.model'; +import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; +import { LimitCheckService } from './limit-check.service'; +import { FeatureLimit } from './feature-limits.guard'; +import { TierLimitsModel } from './models/tier-limits.model'; +import { SharingModule } from '../sharing/sharing.module'; + +@Module({ + imports: [ + SequelizeModule.forFeature([ + TierModel, + Limitmodel, + TierLimitsModel, + TierLimitsModel, + ]), + forwardRef(() => SharingModule), + ], + providers: [ + SequelizeFeatureLimitsRepository, + LimitCheckService, + FeatureLimit, + ], + exports: [FeatureLimit, LimitCheckService], +}) +export class FeatureLimitModule {} diff --git a/src/modules/feature-limit/feature-limit.repository.ts b/src/modules/feature-limit/feature-limit.repository.ts new file mode 100644 index 000000000..213a73bfc --- /dev/null +++ b/src/modules/feature-limit/feature-limit.repository.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { TierModel } from './models/tier.model'; +import { Limitmodel } from './models/limit.model'; +import { TierLimitsModel } from './models/tier-limits.model'; +import { Limit } from './limit.domain'; + +@Injectable() +export class SequelizeFeatureLimitsRepository { + constructor( + @InjectModel(TierModel) + private tierModel: typeof TierModel, + @InjectModel(Limitmodel) + private limitModel: typeof Limitmodel, + @InjectModel(TierLimitsModel) + private tierLimitmodel: typeof TierLimitsModel, + ) {} + + async findById(id: string) { + const tier = await this.tierModel.findByPk(id); + return tier; + } + + async findLimitByLabelAndTier( + tierId: string, + label: string, + ): Promise { + const limit = await this.limitModel.findOne({ + where: { + label, + }, + include: [ + { + model: TierModel, + where: { + id: tierId, + }, + }, + ], + }); + + return limit ? Limit.build(limit) : null; + } +} diff --git a/src/modules/feature-limit/feature-limits.guard.ts b/src/modules/feature-limit/feature-limits.guard.ts new file mode 100644 index 000000000..98c6e39cb --- /dev/null +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -0,0 +1,84 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + Injectable, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { LimitLabels } from './limits.enum'; +import { LimitCheckService } from './limit-check.service'; +import { LimitTypeMapping } from './limits.attributes'; +import { + ApplyLimitMetadata, + FEATURE_LIMIT_KEY, +} from './decorators/apply-limit.decorator'; + +@Injectable() +export class FeatureLimit implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly limitsCheckService: LimitCheckService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const handler = context.getHandler(); + const request = context.switchToHttp().getRequest(); + const user = request.user; + const metadata = this.reflector.get( + FEATURE_LIMIT_KEY, + handler, + ); + + if (!metadata) { + new Logger().error( + `Missing metada for feature limit guard! url: ${request.url} handler: ${handler.name}`, + ); + return false; + } + + const { limitLabel, dataSources } = metadata; + + if (!limitLabel) { + return true; + } + + const extractedData = {} as LimitTypeMapping[typeof limitLabel]; + if (dataSources) { + for (const { sourceKey, fieldName } of dataSources) { + const value = request[sourceKey][fieldName]; + if (value === undefined || value === null) { + new Logger().error( + `[FEATURE_LIMIT]: Missing required field! url: ${request.url} handler: ${handler.name} field: ${fieldName}`, + ); + throw new BadRequestException(`Missing required field: ${fieldName}`); + } + extractedData[fieldName] = value; + } + } + + const limit = await this.limitsCheckService.getLimitByLabelAndTier( + limitLabel, + // TODO: Replace with uÏser.tier_id + 'dfd536ca-7284-47ff-800f-957a80d98084', + ); + + if (!limit) { + new Logger().error(`Limit configuration not found for ${limitLabel}`); + return false; + } + + if (limit.isLimitBooleanAndEnabled()) { + return true; + } + + const isLimitExceeded = + await this.limitsCheckService.checkLimit( + user, + limit, + extractedData, + ); + + return !isLimitExceeded; + } +} diff --git a/src/modules/feature-limit/limit-check.service.ts b/src/modules/feature-limit/limit-check.service.ts new file mode 100644 index 000000000..32b3e3b90 --- /dev/null +++ b/src/modules/feature-limit/limit-check.service.ts @@ -0,0 +1,101 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { LimitLabels } from './limits.enum'; +import { User } from '../user/user.domain'; +import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; +import { SequelizeSharingRepository } from '../sharing/sharing.repository'; +import { SharingType } from '../sharing/sharing.domain'; +import { Limit } from './limit.domain'; +import { + LimitTypeMapping, + MaxInviteesPerItemAttribute, +} from './limits.attributes'; + +@Injectable() +export class LimitCheckService { + constructor( + private readonly limitsRepository: SequelizeFeatureLimitsRepository, + private readonly sharingRepository: SequelizeSharingRepository, + ) {} + + private checkFunctions: { + [K in LimitLabels]: (params: { + limit: Limit; + data: LimitTypeMapping[K]; + user: User; + }) => Promise; + } = { + [LimitLabels.MaxSharedItems]: this.isMaxSharedItemsLimitExceeded.bind(this), + [LimitLabels.MaxSharedItemInvites]: + this.isMaxInviteesPerItemsLimitExceeded.bind(this), + }; + + checkLimit( + user: User, + limit: Limit, + data: LimitTypeMapping[T], + ) { + const checkFunction = this.checkFunctions[limit.label as LimitLabels]; + if (!checkFunction) { + new Logger().error( + `Check function not defined for label: ${limit.label}.`, + ); + return false; + } + return checkFunction({ limit, data, user }); + } + + async isMaxSharedItemsLimitExceeded({ + limit, + user, + }: { + limit: Limit; + user: User; + }) { + const sharingsNumber = + await this.sharingRepository.getSharedItemsNumberByUser(user.uuid); + const limitExceeded = sharingsNumber >= limit.value; + if (limitExceeded) { + throw new BadRequestException('You reached the limit of shared items'); + } + return false; + } + + async isMaxInviteesPerItemsLimitExceeded({ + limit, + data, + }: { + limit: Limit; + data: MaxInviteesPerItemAttribute; + }) { + const { itemId, itemType } = data; + const [sharingsCountForThisItem, invitesCountForThisItem] = + await Promise.all([ + this.sharingRepository.getSharingsCountBy({ + itemId, + itemType, + type: SharingType.Private, + }), + this.sharingRepository.getInvitesCountBy({ + itemId, + itemType, + }), + ]); + + const count = sharingsCountForThisItem + invitesCountForThisItem; + + const limitExceeded = count >= limit.value; + if (limitExceeded) { + throw new BadRequestException( + 'You reached the limit of invitations for this item', + ); + } + return false; + } + + async getLimitByLabelAndTier(label: string, tierId: string) { + return this.limitsRepository.findLimitByLabelAndTier( + 'dfd536ca-7284-47ff-800f-957a80d98084', + label, + ); + } +} diff --git a/src/modules/feature-limit/limit.domain.ts b/src/modules/feature-limit/limit.domain.ts new file mode 100644 index 000000000..bcb0e0506 --- /dev/null +++ b/src/modules/feature-limit/limit.domain.ts @@ -0,0 +1,23 @@ +import { LimitAttributes } from './limits.attributes'; +import { LimitTypes, LimitLabels } from './limits.enum'; + +export class Limit { + id: string; + label: LimitLabels; + type: string; + value: number; + constructor({ id, label, type, value }: LimitAttributes) { + this.id = id; + this.label = label; + this.type = type; + this.value = value; + } + + static build(limit: LimitAttributes): Limit { + return new Limit(limit); + } + + isLimitBooleanAndEnabled() { + return this.type === LimitTypes.Boolean && this.value !== 0; + } +} diff --git a/src/modules/feature-limit/limits.attributes.ts b/src/modules/feature-limit/limits.attributes.ts new file mode 100644 index 000000000..945906f1d --- /dev/null +++ b/src/modules/feature-limit/limits.attributes.ts @@ -0,0 +1,34 @@ +import { User } from '../user/user.domain'; +import { LimitLabels, LimitTypes } from './limits.enum'; + +export interface LimitAttributes { + id: string; + label: LimitLabels; + type: LimitTypes; + value: number; +} + +export interface MaxInviteesPerItemAttribute { + itemId: string; + itemType: 'folder' | 'file'; +} + +export interface MaxSharedItemsAttribute { + user: User; +} + +export interface LimitTypeMapping { + [LimitLabels.MaxSharedItemInvites]: MaxInviteesPerItemAttribute; + [LimitLabels.MaxSharedItems]: MaxSharedItemsAttribute; + [key: string]: any; +} + +type ExtractDataType = T extends keyof LimitTypeMapping + ? LimitTypeMapping[T] + : never; + +export type CheckFunction = ( + value: number, + user: User, + data: ExtractDataType, +) => Promise; diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts new file mode 100644 index 000000000..a2267fead --- /dev/null +++ b/src/modules/feature-limit/limits.enum.ts @@ -0,0 +1,9 @@ +export enum LimitLabels { + MaxSharedItems = 'max-shared-items', + MaxSharedItemInvites = 'max-shared-invites', +} + +export enum LimitTypes { + Boolean = 'boolean', + counter = 'counter', +} diff --git a/src/modules/feature-limit/models/limit.model.ts b/src/modules/feature-limit/models/limit.model.ts new file mode 100644 index 000000000..fdda16170 --- /dev/null +++ b/src/modules/feature-limit/models/limit.model.ts @@ -0,0 +1,50 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + AllowNull, + BelongsToMany, +} from 'sequelize-typescript'; +import { LimitTypes, LimitLabels } from '../limits.enum'; +import { TierModel } from './tier.model'; +import { TierLimitsModel } from './tier-limits.model'; +import { LimitAttributes } from '../limits.attributes'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'limits', +}) +export class Limitmodel extends Model implements LimitAttributes { + @PrimaryKey + @Column(DataType.UUIDV4) + id: string; + + @AllowNull(false) + @Column(DataType.STRING) + label: LimitLabels; + + @AllowNull(false) + @Column({ + type: DataType.ENUM, + values: Object.values(LimitTypes), + }) + type: LimitTypes; + + @AllowNull(false) + @Column(DataType.NUMBER) + value: number; + + @BelongsToMany(() => TierModel, { + through: () => TierLimitsModel, + }) + tiers: TierModel[]; + + @Column + createdAt: Date; + + @Column + updatedAt: Date; +} diff --git a/src/modules/feature-limit/models/tier-limits.model.ts b/src/modules/feature-limit/models/tier-limits.model.ts new file mode 100644 index 000000000..5f0b0d840 --- /dev/null +++ b/src/modules/feature-limit/models/tier-limits.model.ts @@ -0,0 +1,35 @@ +import { + Column, + DataType, + ForeignKey, + Model, + PrimaryKey, + Table, +} from 'sequelize-typescript'; +import { TierModel } from './tier.model'; +import { Limitmodel } from './limit.model'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'tiers_limits', +}) +export class TierLimitsModel extends Model { + @PrimaryKey + @Column({ type: DataType.UUID, defaultValue: DataType.UUIDV4 }) + id: string; + + @ForeignKey(() => TierModel) + @Column(DataType.UUIDV4) + tierId: string; + + @ForeignKey(() => Limitmodel) + @Column(DataType.UUIDV4) + limitId: string; + + @Column + createdAt: Date; + + @Column + updatedAt: Date; +} diff --git a/src/modules/feature-limit/models/tier.model.ts b/src/modules/feature-limit/models/tier.model.ts new file mode 100644 index 000000000..ec05dae0b --- /dev/null +++ b/src/modules/feature-limit/models/tier.model.ts @@ -0,0 +1,41 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + AllowNull, +} from 'sequelize-typescript'; + +export interface TierAttributes { + id: string; + label: string; + context: string; + createdAt: Date; + updatedAt: Date; +} + +@Table({ + underscored: true, + timestamps: true, + tableName: 'tiers', +}) +export class TierModel extends Model implements TierAttributes { + @PrimaryKey + @Column(DataType.UUIDV4) + id: string; + + @AllowNull(false) + @Column(DataType.STRING) + label: string; + + @AllowNull(false) + @Column(DataType.STRING) + context: string; + + @Column + createdAt: Date; + + @Column + updatedAt: Date; +} diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 222b43a87..39c8e1c7c 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -58,6 +58,9 @@ import { ThrottlerGuard } from '../../guards/throttler.guard'; import { SetSharingPasswordDto } from './dto/set-sharing-password.dto'; import { UuidDto } from '../../common/uuid.dto'; import { HttpExceptionFilter } from '../../lib/http/http-exception.filter'; +import { ApplyLimit } from '../feature-limit/decorators/apply-limit.decorator'; +import { LimitLabels } from '../feature-limit/limits.enum'; +import { FeatureLimit } from '../feature-limit/feature-limits.guard'; @ApiTags('Sharing') @Controller('sharings') @@ -248,6 +251,11 @@ export class SharingController { } @Post('/invites/send') + @ApplyLimit({ + limitLabel: LimitLabels.MaxSharedItemInvites, + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }) + @UseGuards(FeatureLimit) createInvite( @UserDecorator() user: User, @Body() createInviteDto: CreateInviteDto, diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index 67846176d..077207ac8 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -20,6 +20,7 @@ import { import { BridgeModule } from '../../externals/bridge/bridge.module'; import { PaymentsService } from '../../externals/payments/payments.service'; import { AppSumoModule } from '../app-sumo/app-sumo.module'; +import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; @Module({ imports: [ @@ -36,6 +37,7 @@ import { AppSumoModule } from '../app-sumo/app-sumo.module'; BridgeModule, AppSumoModule, forwardRef(() => UserModule), + forwardRef(() => FeatureLimitModule), ], controllers: [SharingController], providers: [ @@ -44,6 +46,6 @@ import { AppSumoModule } from '../app-sumo/app-sumo.module'; SequelizeUserReferralsRepository, PaymentsService, ], - exports: [SharingService, SequelizeSharingRepository], + exports: [SharingService, SequelizeSharingRepository, SequelizeModule], }) export class SharingModule {} diff --git a/src/modules/sharing/sharing.repository.ts b/src/modules/sharing/sharing.repository.ts index a977cec1d..d6861c3e2 100644 --- a/src/modules/sharing/sharing.repository.ts +++ b/src/modules/sharing/sharing.repository.ts @@ -550,6 +550,16 @@ export class SequelizeSharingRepository implements SharingRepository { return SharingInvite.build(raw); } + async getSharedItemsNumberByUser(userUuid: string): Promise { + const sharingsNumber = await this.sharings.count({ + where: { ownerId: userUuid }, + distinct: true, + col: 'itemId', + }); + + return sharingsNumber; + } + async createSharing(sharing: Omit): Promise { const raw = await this.sharings.create(sharing); From cfbe8d9ea58769d9bdd05dc59946948d531f30d0 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Fri, 2 Feb 2024 02:09:31 -0400 Subject: [PATCH 02/22] fix: checkFunction should be valid even if all the limits are not in it --- src/modules/feature-limit/limit-check.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/feature-limit/limit-check.service.ts b/src/modules/feature-limit/limit-check.service.ts index 32b3e3b90..353a99494 100644 --- a/src/modules/feature-limit/limit-check.service.ts +++ b/src/modules/feature-limit/limit-check.service.ts @@ -18,7 +18,7 @@ export class LimitCheckService { ) {} private checkFunctions: { - [K in LimitLabels]: (params: { + [K in LimitLabels]?: (params: { limit: Limit; data: LimitTypeMapping[K]; user: User; From 04e5410e1b29b2c7e76e9f45e7880397883af535 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Fri, 2 Feb 2024 02:54:50 -0400 Subject: [PATCH 03/22] fix: fix check if limit is boolean --- src/modules/feature-limit/feature-limits.guard.ts | 4 ++-- src/modules/feature-limit/limit.domain.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/modules/feature-limit/feature-limits.guard.ts b/src/modules/feature-limit/feature-limits.guard.ts index 98c6e39cb..a5adcc43f 100644 --- a/src/modules/feature-limit/feature-limits.guard.ts +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -68,8 +68,8 @@ export class FeatureLimit implements CanActivate { return false; } - if (limit.isLimitBooleanAndEnabled()) { - return true; + if (limit.isLimitBoolean()) { + return limit.isFeatureEnabled(); } const isLimitExceeded = diff --git a/src/modules/feature-limit/limit.domain.ts b/src/modules/feature-limit/limit.domain.ts index bcb0e0506..90011581f 100644 --- a/src/modules/feature-limit/limit.domain.ts +++ b/src/modules/feature-limit/limit.domain.ts @@ -17,7 +17,11 @@ export class Limit { return new Limit(limit); } - isLimitBooleanAndEnabled() { - return this.type === LimitTypes.Boolean && this.value !== 0; + isLimitBoolean() { + return this.type === LimitTypes.Boolean; + } + + isFeatureEnabled() { + return this.isLimitBoolean() && this.value === 1; } } From 347a5a62c5fe8b1aec864baa7539ab37e4aede2e Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 7 Feb 2024 10:45:52 -0400 Subject: [PATCH 04/22] feat: update http code and max shared items case --- .../20240128230532-create-tiers-table.js | 1 - .../20240128230553-create-limits-table.js | 2 +- .../decorators/apply-limit.decorator.ts | 1 + .../exceptions/payment-required.exception.ts | 10 ++++++ .../feature-limit/feature-limit.module.ts | 6 ++-- .../feature-limit/feature-limit.repository.ts | 5 --- ...ck.service.ts => feature-limit.usecase.ts} | 36 ++++++++++++------- .../feature-limit/feature-limits.guard.ts | 19 +++++----- src/modules/feature-limit/limit.domain.ts | 10 ++++-- .../feature-limit/limits.attributes.ts | 4 ++- src/modules/feature-limit/limits.enum.ts | 1 + .../feature-limit/models/limit.model.ts | 4 +-- src/modules/sharing/sharing.controller.ts | 5 +++ src/modules/user/user.attributes.ts | 1 + src/modules/user/user.domain.ts | 3 ++ src/modules/user/user.model.ts | 4 +++ 16 files changed, 76 insertions(+), 36 deletions(-) create mode 100644 src/modules/feature-limit/exceptions/payment-required.exception.ts rename src/modules/feature-limit/{limit-check.service.ts => feature-limit.usecase.ts} (73%) diff --git a/migrations/20240128230532-create-tiers-table.js b/migrations/20240128230532-create-tiers-table.js index 57da4f4e2..26a2f081c 100644 --- a/migrations/20240128230532-create-tiers-table.js +++ b/migrations/20240128230532-create-tiers-table.js @@ -17,7 +17,6 @@ module.exports = { }, context: { type: Sequelize.STRING, - allowNull: false, }, created_at: { type: Sequelize.DATE, diff --git a/migrations/20240128230553-create-limits-table.js b/migrations/20240128230553-create-limits-table.js index 3f9d63d0a..9ed07ea2b 100644 --- a/migrations/20240128230553-create-limits-table.js +++ b/migrations/20240128230553-create-limits-table.js @@ -20,7 +20,7 @@ module.exports = { allowNull: false, }, value: { - type: Sequelize.INTEGER, + type: Sequelize.STRING, allowNull: false, }, created_at: { diff --git a/src/modules/feature-limit/decorators/apply-limit.decorator.ts b/src/modules/feature-limit/decorators/apply-limit.decorator.ts index 5f86607d6..b07fcd620 100644 --- a/src/modules/feature-limit/decorators/apply-limit.decorator.ts +++ b/src/modules/feature-limit/decorators/apply-limit.decorator.ts @@ -9,6 +9,7 @@ interface DataSource { export interface ApplyLimitMetadata { limitLabel: LimitLabels; dataSources?: DataSource[]; + context?: object; } export const FEATURE_LIMIT_KEY = 'feature-limit'; diff --git a/src/modules/feature-limit/exceptions/payment-required.exception.ts b/src/modules/feature-limit/exceptions/payment-required.exception.ts new file mode 100644 index 000000000..9e9e1e693 --- /dev/null +++ b/src/modules/feature-limit/exceptions/payment-required.exception.ts @@ -0,0 +1,10 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class PaymentRequiredException extends HttpException { + constructor(message?: string) { + super( + message ?? 'It seems you reached the limit for your current plan tier', + HttpStatus.PAYMENT_REQUIRED, + ); + } +} diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts index 71f6e51bb..1acd9e607 100644 --- a/src/modules/feature-limit/feature-limit.module.ts +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -3,7 +3,7 @@ import { SequelizeModule } from '@nestjs/sequelize'; import { TierModel } from './models/tier.model'; import { Limitmodel } from './models/limit.model'; import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; -import { LimitCheckService } from './limit-check.service'; +import { FeatureLimitUsecases } from './feature-limit.usecase'; import { FeatureLimit } from './feature-limits.guard'; import { TierLimitsModel } from './models/tier-limits.model'; import { SharingModule } from '../sharing/sharing.module'; @@ -20,9 +20,9 @@ import { SharingModule } from '../sharing/sharing.module'; ], providers: [ SequelizeFeatureLimitsRepository, - LimitCheckService, + FeatureLimitUsecases, FeatureLimit, ], - exports: [FeatureLimit, LimitCheckService], + exports: [FeatureLimit, FeatureLimitUsecases], }) export class FeatureLimitModule {} diff --git a/src/modules/feature-limit/feature-limit.repository.ts b/src/modules/feature-limit/feature-limit.repository.ts index 213a73bfc..c0dd92857 100644 --- a/src/modules/feature-limit/feature-limit.repository.ts +++ b/src/modules/feature-limit/feature-limit.repository.ts @@ -16,11 +16,6 @@ export class SequelizeFeatureLimitsRepository { private tierLimitmodel: typeof TierLimitsModel, ) {} - async findById(id: string) { - const tier = await this.tierModel.findByPk(id); - return tier; - } - async findLimitByLabelAndTier( tierId: string, label: string, diff --git a/src/modules/feature-limit/limit-check.service.ts b/src/modules/feature-limit/feature-limit.usecase.ts similarity index 73% rename from src/modules/feature-limit/limit-check.service.ts rename to src/modules/feature-limit/feature-limit.usecase.ts index 353a99494..afba1cd05 100644 --- a/src/modules/feature-limit/limit-check.service.ts +++ b/src/modules/feature-limit/feature-limit.usecase.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { LimitLabels } from './limits.enum'; import { User } from '../user/user.domain'; import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; @@ -8,10 +8,12 @@ import { Limit } from './limit.domain'; import { LimitTypeMapping, MaxInviteesPerItemAttribute, + MaxSharedItemsAttribute, } from './limits.attributes'; +import { PaymentRequiredException } from './exceptions/payment-required.exception'; @Injectable() -export class LimitCheckService { +export class FeatureLimitUsecases { constructor( private readonly limitsRepository: SequelizeFeatureLimitsRepository, private readonly sharingRepository: SequelizeSharingRepository, @@ -47,15 +49,28 @@ export class LimitCheckService { async isMaxSharedItemsLimitExceeded({ limit, user, + data, }: { limit: Limit; user: User; + data: MaxSharedItemsAttribute; }) { - const sharingsNumber = + const alreadySharedItem = await this.sharingRepository.findOneSharingBy({ + itemId: data.itemId, + }); + + if (alreadySharedItem) { + return false; + } + + const sharingsCount = await this.sharingRepository.getSharedItemsNumberByUser(user.uuid); - const limitExceeded = sharingsNumber >= limit.value; + const limitExceeded = limit.isLimitExceeded(sharingsCount); + if (limitExceeded) { - throw new BadRequestException('You reached the limit of shared items'); + throw new PaymentRequiredException( + 'You have reached the limit of shared items', + ); } return false; } @@ -83,19 +98,16 @@ export class LimitCheckService { const count = sharingsCountForThisItem + invitesCountForThisItem; - const limitExceeded = count >= limit.value; + const limitExceeded = limit.isLimitExceeded(count); if (limitExceeded) { - throw new BadRequestException( - 'You reached the limit of invitations for this item', + throw new PaymentRequiredException( + 'You have reached the limit of invitations for this item', ); } return false; } async getLimitByLabelAndTier(label: string, tierId: string) { - return this.limitsRepository.findLimitByLabelAndTier( - 'dfd536ca-7284-47ff-800f-957a80d98084', - label, - ); + return this.limitsRepository.findLimitByLabelAndTier(tierId, label); } } diff --git a/src/modules/feature-limit/feature-limits.guard.ts b/src/modules/feature-limit/feature-limits.guard.ts index a5adcc43f..19669cc44 100644 --- a/src/modules/feature-limit/feature-limits.guard.ts +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { LimitLabels } from './limits.enum'; -import { LimitCheckService } from './limit-check.service'; +import { FeatureLimitUsecases } from './feature-limit.usecase'; import { LimitTypeMapping } from './limits.attributes'; import { ApplyLimitMetadata, @@ -18,7 +18,7 @@ import { export class FeatureLimit implements CanActivate { constructor( private readonly reflector: Reflector, - private readonly limitsCheckService: LimitCheckService, + private readonly featureLimitsUseCases: FeatureLimitUsecases, ) {} async canActivate(context: ExecutionContext): Promise { @@ -32,7 +32,7 @@ export class FeatureLimit implements CanActivate { if (!metadata) { new Logger().error( - `Missing metada for feature limit guard! url: ${request.url} handler: ${handler.name}`, + `Missing metadata for feature limit guard! url: ${request.url} handler: ${handler.name}`, ); return false; } @@ -57,15 +57,16 @@ export class FeatureLimit implements CanActivate { } } - const limit = await this.limitsCheckService.getLimitByLabelAndTier( + const limit = await this.featureLimitsUseCases.getLimitByLabelAndTier( limitLabel, - // TODO: Replace with uÏser.tier_id - 'dfd536ca-7284-47ff-800f-957a80d98084', + user.tierId, ); if (!limit) { - new Logger().error(`Limit configuration not found for ${limitLabel}`); - return false; + new Logger().error( + `[FEATURE_LIMIT]: Limit configuration not found for limit: ${limitLabel} tier: ${user.tierId}`, + ); + return true; } if (limit.isLimitBoolean()) { @@ -73,7 +74,7 @@ export class FeatureLimit implements CanActivate { } const isLimitExceeded = - await this.limitsCheckService.checkLimit( + await this.featureLimitsUseCases.checkLimit( user, limit, extractedData, diff --git a/src/modules/feature-limit/limit.domain.ts b/src/modules/feature-limit/limit.domain.ts index 90011581f..6effe2d77 100644 --- a/src/modules/feature-limit/limit.domain.ts +++ b/src/modules/feature-limit/limit.domain.ts @@ -5,7 +5,7 @@ export class Limit { id: string; label: LimitLabels; type: string; - value: number; + value: string; constructor({ id, label, type, value }: LimitAttributes) { this.id = id; this.label = label; @@ -22,6 +22,12 @@ export class Limit { } isFeatureEnabled() { - return this.isLimitBoolean() && this.value === 1; + return this.isLimitBoolean() && Boolean(this.value); + } + + isLimitExceeded(currentCount: number) { + return ( + this.type === LimitTypes.counter && currentCount >= Number(this.value) + ); } } diff --git a/src/modules/feature-limit/limits.attributes.ts b/src/modules/feature-limit/limits.attributes.ts index 945906f1d..7285135ad 100644 --- a/src/modules/feature-limit/limits.attributes.ts +++ b/src/modules/feature-limit/limits.attributes.ts @@ -5,7 +5,7 @@ export interface LimitAttributes { id: string; label: LimitLabels; type: LimitTypes; - value: number; + value: string; } export interface MaxInviteesPerItemAttribute { @@ -15,6 +15,8 @@ export interface MaxInviteesPerItemAttribute { export interface MaxSharedItemsAttribute { user: User; + itemId: string; + isPublicSharing: boolean; } export interface LimitTypeMapping { diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index a2267fead..4510c8ca9 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -1,6 +1,7 @@ export enum LimitLabels { MaxSharedItems = 'max-shared-items', MaxSharedItemInvites = 'max-shared-invites', + MaxTest = 'max-shared-test', } export enum LimitTypes { diff --git a/src/modules/feature-limit/models/limit.model.ts b/src/modules/feature-limit/models/limit.model.ts index fdda16170..197e1c875 100644 --- a/src/modules/feature-limit/models/limit.model.ts +++ b/src/modules/feature-limit/models/limit.model.ts @@ -34,8 +34,8 @@ export class Limitmodel extends Model implements LimitAttributes { type: LimitTypes; @AllowNull(false) - @Column(DataType.NUMBER) - value: number; + @Column(DataType.STRING) + value: string; @BelongsToMany(() => TierModel, { through: () => TierLimitsModel, diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 39c8e1c7c..dc11ffcba 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -599,6 +599,11 @@ export class SharingController { } @Post('/') + @ApplyLimit({ + limitLabel: LimitLabels.MaxSharedItems, + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }) + @UseGuards(FeatureLimit) createSharing( @UserDecorator() user, @Body() acceptInviteDto: CreateSharingDto, diff --git a/src/modules/user/user.attributes.ts b/src/modules/user/user.attributes.ts index 3f72dbcc3..ae0b8d4b6 100644 --- a/src/modules/user/user.attributes.ts +++ b/src/modules/user/user.attributes.ts @@ -27,4 +27,5 @@ export interface UserAttributes { tempKey: string; avatar: string; lastPasswordChangedAt?: Date; + tierId?: string; } diff --git a/src/modules/user/user.domain.ts b/src/modules/user/user.domain.ts index ff0c95713..7b197d80d 100644 --- a/src/modules/user/user.domain.ts +++ b/src/modules/user/user.domain.ts @@ -27,6 +27,7 @@ export class User implements UserAttributes { tempKey: string; avatar: string; lastPasswordChangedAt: Date; + tierId: string; constructor({ id, userId, @@ -55,6 +56,7 @@ export class User implements UserAttributes { tempKey, avatar, lastPasswordChangedAt, + tierId, }: UserAttributes) { this.id = id; this.userId = userId; @@ -83,6 +85,7 @@ export class User implements UserAttributes { this.tempKey = tempKey; this.avatar = avatar; this.lastPasswordChangedAt = lastPasswordChangedAt; + this.tierId = tierId; } static build(user: UserAttributes): User { diff --git a/src/modules/user/user.model.ts b/src/modules/user/user.model.ts index 76fe7be28..d3c61e5a0 100644 --- a/src/modules/user/user.model.ts +++ b/src/modules/user/user.model.ts @@ -123,4 +123,8 @@ export class UserModel extends Model implements UserAttributes { @AllowNull @Column lastPasswordChangedAt: Date; + + @AllowNull + @Column + tierId: string; } From cedb1fcb24acbb22d5ceefb24e848c843ea55b9f Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Mon, 12 Feb 2024 05:07:13 -0400 Subject: [PATCH 05/22] feat: allow domain to check if limit has been surprassed --- .../feature-limit/feature-limit.usecase.ts | 89 +++++++++++++------ .../feature-limit/feature-limits.guard.ts | 68 +++++++------- src/modules/feature-limit/limit.domain.ts | 43 ++++++--- .../feature-limit/limits.attributes.ts | 5 ++ src/modules/feature-limit/limits.enum.ts | 2 +- .../feature-limit/models/limit.model.ts | 2 +- 6 files changed, 133 insertions(+), 76 deletions(-) diff --git a/src/modules/feature-limit/feature-limit.usecase.ts b/src/modules/feature-limit/feature-limit.usecase.ts index afba1cd05..be2448b2f 100644 --- a/src/modules/feature-limit/feature-limit.usecase.ts +++ b/src/modules/feature-limit/feature-limit.usecase.ts @@ -1,4 +1,8 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; import { LimitLabels } from './limits.enum'; import { User } from '../user/user.domain'; import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; @@ -19,34 +23,68 @@ export class FeatureLimitUsecases { private readonly sharingRepository: SequelizeSharingRepository, ) {} - private checkFunctions: { + private limitCheckFunctions: { [K in LimitLabels]?: (params: { limit: Limit; data: LimitTypeMapping[K]; user: User; }) => Promise; } = { - [LimitLabels.MaxSharedItems]: this.isMaxSharedItemsLimitExceeded.bind(this), + [LimitLabels.MaxSharedItems]: this.checkMaxSharedItemsLimit.bind(this), [LimitLabels.MaxSharedItemInvites]: - this.isMaxInviteesPerItemsLimitExceeded.bind(this), + this.checkMaxInviteesPerItemLimit.bind(this), }; - checkLimit( + async enforceLimit( + limitLabel: LimitLabels, + user: User, + data: LimitTypeMapping[T], + ): Promise { + const limit = await this.limitsRepository.findLimitByLabelAndTier( + user.tierId, + limitLabel, + ); + + if (!limit) { + new Logger().error( + `[FEATURE_LIMIT]: Limit not found for label: ${limitLabel}, tierId: ${user.tierId} user: ${user.email}`, + ); + throw new InternalServerErrorException(); + } + + if (limit.isBooleanLimit()) { + if (limit.shouldLimitBeEnforced()) { + throw new PaymentRequiredException( + `Feature not available for ${limitLabel} `, + ); + } + return false; + } + + const isLimitSurprassed = await this.checkCounterLimit(user, limit, data); + if (isLimitSurprassed) { + throw new PaymentRequiredException(`Limit exceeded for ${limitLabel} `); + } + return false; + } + + async checkCounterLimit( user: User, limit: Limit, data: LimitTypeMapping[T], ) { - const checkFunction = this.checkFunctions[limit.label as LimitLabels]; + const checkFunction = this.limitCheckFunctions[limit.label as LimitLabels]; + if (!checkFunction) { new Logger().error( - `Check function not defined for label: ${limit.label}.`, + `[FEATURE-LIMIT] Check counter function not defined for label: ${limit.label}.`, ); - return false; + throw new InternalServerErrorException(); } return checkFunction({ limit, data, user }); } - async isMaxSharedItemsLimitExceeded({ + async checkMaxSharedItemsLimit({ limit, user, data, @@ -55,34 +93,32 @@ export class FeatureLimitUsecases { user: User; data: MaxSharedItemsAttribute; }) { + const limitContext = { bypassLimit: false, currentCount: 0 }; const alreadySharedItem = await this.sharingRepository.findOneSharingBy({ itemId: data.itemId, }); if (alreadySharedItem) { - return false; + limitContext.bypassLimit = true; + } else { + const sharingsCount = + await this.sharingRepository.getSharedItemsNumberByUser(user.uuid); + limitContext.currentCount = sharingsCount; } - const sharingsCount = - await this.sharingRepository.getSharedItemsNumberByUser(user.uuid); - const limitExceeded = limit.isLimitExceeded(sharingsCount); - - if (limitExceeded) { - throw new PaymentRequiredException( - 'You have reached the limit of shared items', - ); - } - return false; + return limit.shouldLimitBeEnforced(limitContext); } - async isMaxInviteesPerItemsLimitExceeded({ + async checkMaxInviteesPerItemLimit({ limit, data, }: { limit: Limit; data: MaxInviteesPerItemAttribute; }) { + const limitContext = { currentCount: 0 }; const { itemId, itemType } = data; + const [sharingsCountForThisItem, invitesCountForThisItem] = await Promise.all([ this.sharingRepository.getSharingsCountBy({ @@ -96,15 +132,10 @@ export class FeatureLimitUsecases { }), ]); - const count = sharingsCountForThisItem + invitesCountForThisItem; + limitContext.currentCount = + sharingsCountForThisItem + invitesCountForThisItem; - const limitExceeded = limit.isLimitExceeded(count); - if (limitExceeded) { - throw new PaymentRequiredException( - 'You have reached the limit of invitations for this item', - ); - } - return false; + return limit.shouldLimitBeEnforced(limitContext); } async getLimitByLabelAndTier(label: string, tierId: string) { diff --git a/src/modules/feature-limit/feature-limits.guard.ts b/src/modules/feature-limit/feature-limits.guard.ts index 19669cc44..da55116d4 100644 --- a/src/modules/feature-limit/feature-limits.guard.ts +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -32,9 +32,9 @@ export class FeatureLimit implements CanActivate { if (!metadata) { new Logger().error( - `Missing metadata for feature limit guard! url: ${request.url} handler: ${handler.name}`, + `[FEATURE_LIMIT]: Missing metadata for feature limit guard! url: ${request.url} handler: ${handler.name}`, ); - return false; + throw new BadRequestException(`Missing Metadata`); } const { limitLabel, dataSources } = metadata; @@ -43,43 +43,45 @@ export class FeatureLimit implements CanActivate { return true; } - const extractedData = {} as LimitTypeMapping[typeof limitLabel]; - if (dataSources) { - for (const { sourceKey, fieldName } of dataSources) { - const value = request[sourceKey][fieldName]; - if (value === undefined || value === null) { - new Logger().error( - `[FEATURE_LIMIT]: Missing required field! url: ${request.url} handler: ${handler.name} field: ${fieldName}`, - ); - throw new BadRequestException(`Missing required field: ${fieldName}`); - } - extractedData[fieldName] = value; - } - } - - const limit = await this.featureLimitsUseCases.getLimitByLabelAndTier( + const extractedData = this.extractDataFromRequest( + request, + dataSources, limitLabel, - user.tierId, ); - if (!limit) { - new Logger().error( - `[FEATURE_LIMIT]: Limit configuration not found for limit: ${limitLabel} tier: ${user.tierId}`, - ); - return true; - } - - if (limit.isLimitBoolean()) { - return limit.isFeatureEnabled(); - } - - const isLimitExceeded = - await this.featureLimitsUseCases.checkLimit( + const enforceLimit = + await this.featureLimitsUseCases.enforceLimit( + limitLabel, user, - limit, extractedData, ); - return !isLimitExceeded; + const shouldActionBeAllowed = !enforceLimit; + + return shouldActionBeAllowed; + } + + extractDataFromRequest( + request: any, + dataSources: ApplyLimitMetadata['dataSources'], + limitLabel: LimitLabels, + ) { + const extractedData = {} as LimitTypeMapping[typeof limitLabel]; + + for (const { sourceKey, fieldName } of dataSources) { + const value = request[sourceKey][fieldName]; + const isValueUndefined = value === undefined || value === null; + + if (isValueUndefined) { + new Logger().error( + `[FEATURE_LIMIT]: Missing required field for feature limit! limit: ${limitLabel} field: ${fieldName}`, + ); + throw new BadRequestException(`Missing required field: ${fieldName}`); + } + + extractedData[fieldName] = value; + } + + return extractedData; } } diff --git a/src/modules/feature-limit/limit.domain.ts b/src/modules/feature-limit/limit.domain.ts index 6effe2d77..319bbef3f 100644 --- a/src/modules/feature-limit/limit.domain.ts +++ b/src/modules/feature-limit/limit.domain.ts @@ -1,11 +1,16 @@ -import { LimitAttributes } from './limits.attributes'; +import { + LimitAttributes, + ShouldLimitBeEnforcedContext, +} from './limits.attributes'; import { LimitTypes, LimitLabels } from './limits.enum'; export class Limit { - id: string; - label: LimitLabels; - type: string; - value: string; + readonly id: string; + readonly label: LimitLabels; + readonly type: string; + readonly value: string; + readonly isBypassable: boolean; + constructor({ id, label, type, value }: LimitAttributes) { this.id = id; this.label = label; @@ -13,21 +18,35 @@ export class Limit { this.value = value; } - static build(limit: LimitAttributes): Limit { - return new Limit(limit); + static build(limitAttributes: LimitAttributes): Limit { + return new Limit(limitAttributes); } - isLimitBoolean() { + isBooleanLimit() { return this.type === LimitTypes.Boolean; } - isFeatureEnabled() { - return this.isLimitBoolean() && Boolean(this.value); + private isFeatureEnabled() { + return this.isBooleanLimit() && Boolean(this.value); } - isLimitExceeded(currentCount: number) { + private isCounterLimitExceeded(currentCount: number) { return ( - this.type === LimitTypes.counter && currentCount >= Number(this.value) + this.type === LimitTypes.Counter && currentCount >= Number(this.value) ); } + + shouldLimitBeEnforced(context: ShouldLimitBeEnforcedContext = {}): boolean { + const { bypassLimit, currentCount } = context; + + if (bypassLimit) { + return false; + } + + if (this.isBooleanLimit()) { + return !this.isFeatureEnabled(); + } + + return this.isCounterLimitExceeded(currentCount); + } } diff --git a/src/modules/feature-limit/limits.attributes.ts b/src/modules/feature-limit/limits.attributes.ts index 7285135ad..cbb4ab250 100644 --- a/src/modules/feature-limit/limits.attributes.ts +++ b/src/modules/feature-limit/limits.attributes.ts @@ -8,6 +8,11 @@ export interface LimitAttributes { value: string; } +export interface ShouldLimitBeEnforcedContext { + bypassLimit?: boolean; + currentCount?: number; +} + export interface MaxInviteesPerItemAttribute { itemId: string; itemType: 'folder' | 'file'; diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index 4510c8ca9..01dc633de 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -6,5 +6,5 @@ export enum LimitLabels { export enum LimitTypes { Boolean = 'boolean', - counter = 'counter', + Counter = 'counter', } diff --git a/src/modules/feature-limit/models/limit.model.ts b/src/modules/feature-limit/models/limit.model.ts index 197e1c875..2705d7b0d 100644 --- a/src/modules/feature-limit/models/limit.model.ts +++ b/src/modules/feature-limit/models/limit.model.ts @@ -4,8 +4,8 @@ import { Table, PrimaryKey, DataType, - AllowNull, BelongsToMany, + AllowNull, } from 'sequelize-typescript'; import { LimitTypes, LimitLabels } from '../limits.enum'; import { TierModel } from './tier.model'; From 54328fd0fe0567971e4ae5e54f442ac3a1676c6b Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Mon, 12 Feb 2024 06:17:15 -0400 Subject: [PATCH 06/22] feat: allow multiple limit checks for one endpoint --- .../decorators/apply-limit.decorator.ts | 2 +- .../exceptions/payment-required.exception.ts | 3 +- .../feature-limit/feature-limit.usecase.ts | 3 +- .../feature-limit/feature-limits.guard.ts | 39 ++++++++++--------- .../feature-limit/limits.attributes.ts | 10 ----- src/modules/feature-limit/limits.enum.ts | 1 - src/modules/sharing/sharing.controller.ts | 9 +++-- src/modules/sharing/sharing.repository.ts | 4 +- 8 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/modules/feature-limit/decorators/apply-limit.decorator.ts b/src/modules/feature-limit/decorators/apply-limit.decorator.ts index b07fcd620..884236a6e 100644 --- a/src/modules/feature-limit/decorators/apply-limit.decorator.ts +++ b/src/modules/feature-limit/decorators/apply-limit.decorator.ts @@ -7,7 +7,7 @@ interface DataSource { } export interface ApplyLimitMetadata { - limitLabel: LimitLabels; + limitLabels: LimitLabels[]; dataSources?: DataSource[]; context?: object; } diff --git a/src/modules/feature-limit/exceptions/payment-required.exception.ts b/src/modules/feature-limit/exceptions/payment-required.exception.ts index 9e9e1e693..4cae9c2de 100644 --- a/src/modules/feature-limit/exceptions/payment-required.exception.ts +++ b/src/modules/feature-limit/exceptions/payment-required.exception.ts @@ -3,7 +3,8 @@ import { HttpException, HttpStatus } from '@nestjs/common'; export class PaymentRequiredException extends HttpException { constructor(message?: string) { super( - message ?? 'It seems you reached the limit for your current plan tier', + message ?? + 'It seems you reached the limit or feature is not available for your current plan tier', HttpStatus.PAYMENT_REQUIRED, ); } diff --git a/src/modules/feature-limit/feature-limit.usecase.ts b/src/modules/feature-limit/feature-limit.usecase.ts index be2448b2f..76a178b9c 100644 --- a/src/modules/feature-limit/feature-limit.usecase.ts +++ b/src/modules/feature-limit/feature-limit.usecase.ts @@ -132,8 +132,9 @@ export class FeatureLimitUsecases { }), ]); + // Add 1 to include owner in the limit count. limitContext.currentCount = - sharingsCountForThisItem + invitesCountForThisItem; + sharingsCountForThisItem + invitesCountForThisItem + 1; return limit.shouldLimitBeEnforced(limitContext); } diff --git a/src/modules/feature-limit/feature-limits.guard.ts b/src/modules/feature-limit/feature-limits.guard.ts index da55116d4..d869353d8 100644 --- a/src/modules/feature-limit/feature-limits.guard.ts +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -8,11 +8,11 @@ import { import { Reflector } from '@nestjs/core'; import { LimitLabels } from './limits.enum'; import { FeatureLimitUsecases } from './feature-limit.usecase'; -import { LimitTypeMapping } from './limits.attributes'; import { ApplyLimitMetadata, FEATURE_LIMIT_KEY, } from './decorators/apply-limit.decorator'; +import { PaymentRequiredException } from './exceptions/payment-required.exception'; @Injectable() export class FeatureLimit implements CanActivate { @@ -37,36 +37,37 @@ export class FeatureLimit implements CanActivate { throw new BadRequestException(`Missing Metadata`); } - const { limitLabel, dataSources } = metadata; + const { limitLabels, dataSources } = metadata; - if (!limitLabel) { + if (!limitLabels) { return true; } - const extractedData = this.extractDataFromRequest( - request, - dataSources, - limitLabel, - ); + const extractedData = this.extractDataFromRequest(request, dataSources); - const enforceLimit = - await this.featureLimitsUseCases.enforceLimit( - limitLabel, - user, - extractedData, - ); + await Promise.all( + limitLabels.map(async (limitLabel) => { + const shouldLimitBeEnforced = + await this.featureLimitsUseCases.enforceLimit( + limitLabel, + user, + extractedData, + ); - const shouldActionBeAllowed = !enforceLimit; + if (shouldLimitBeEnforced) { + throw new PaymentRequiredException(); + } + }), + ); - return shouldActionBeAllowed; + return true; } extractDataFromRequest( request: any, dataSources: ApplyLimitMetadata['dataSources'], - limitLabel: LimitLabels, ) { - const extractedData = {} as LimitTypeMapping[typeof limitLabel]; + const extractedData = {}; for (const { sourceKey, fieldName } of dataSources) { const value = request[sourceKey][fieldName]; @@ -74,7 +75,7 @@ export class FeatureLimit implements CanActivate { if (isValueUndefined) { new Logger().error( - `[FEATURE_LIMIT]: Missing required field for feature limit! limit: ${limitLabel} field: ${fieldName}`, + `[FEATURE_LIMIT]: Missing required field for feature limit! field: ${fieldName}`, ); throw new BadRequestException(`Missing required field: ${fieldName}`); } diff --git a/src/modules/feature-limit/limits.attributes.ts b/src/modules/feature-limit/limits.attributes.ts index cbb4ab250..97c7be986 100644 --- a/src/modules/feature-limit/limits.attributes.ts +++ b/src/modules/feature-limit/limits.attributes.ts @@ -29,13 +29,3 @@ export interface LimitTypeMapping { [LimitLabels.MaxSharedItems]: MaxSharedItemsAttribute; [key: string]: any; } - -type ExtractDataType = T extends keyof LimitTypeMapping - ? LimitTypeMapping[T] - : never; - -export type CheckFunction = ( - value: number, - user: User, - data: ExtractDataType, -) => Promise; diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index 01dc633de..66ec5f6b9 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -1,7 +1,6 @@ export enum LimitLabels { MaxSharedItems = 'max-shared-items', MaxSharedItemInvites = 'max-shared-invites', - MaxTest = 'max-shared-test', } export enum LimitTypes { diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index dc11ffcba..b848128ee 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -252,8 +252,11 @@ export class SharingController { @Post('/invites/send') @ApplyLimit({ - limitLabel: LimitLabels.MaxSharedItemInvites, - dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + limitLabels: [LimitLabels.MaxSharedItemInvites, LimitLabels.MaxSharedItems], + dataSources: [ + { sourceKey: 'body', fieldName: 'itemId' }, + { sourceKey: 'body', fieldName: 'itemType' }, + ], }) @UseGuards(FeatureLimit) createInvite( @@ -600,7 +603,7 @@ export class SharingController { @Post('/') @ApplyLimit({ - limitLabel: LimitLabels.MaxSharedItems, + limitLabels: [LimitLabels.MaxSharedItems], dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], }) @UseGuards(FeatureLimit) diff --git a/src/modules/sharing/sharing.repository.ts b/src/modules/sharing/sharing.repository.ts index d6861c3e2..35be43e4b 100644 --- a/src/modules/sharing/sharing.repository.ts +++ b/src/modules/sharing/sharing.repository.ts @@ -551,13 +551,13 @@ export class SequelizeSharingRepository implements SharingRepository { } async getSharedItemsNumberByUser(userUuid: string): Promise { - const sharingsNumber = await this.sharings.count({ + const sharingsCount = await this.sharings.count({ where: { ownerId: userUuid }, distinct: true, col: 'itemId', }); - return sharingsNumber; + return sharingsCount; } async createSharing(sharing: Omit): Promise { From cf79d24c3f0f6dee853fbe494a37af633d4542fb Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 14 Feb 2024 12:22:48 -0400 Subject: [PATCH 07/22] fix: types --- src/modules/feature-limit/feature-limits.guard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/feature-limit/feature-limits.guard.ts b/src/modules/feature-limit/feature-limits.guard.ts index d869353d8..9e71bf0f5 100644 --- a/src/modules/feature-limit/feature-limits.guard.ts +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -13,6 +13,7 @@ import { FEATURE_LIMIT_KEY, } from './decorators/apply-limit.decorator'; import { PaymentRequiredException } from './exceptions/payment-required.exception'; +import { LimitTypeMapping } from './limits.attributes'; @Injectable() export class FeatureLimit implements CanActivate { @@ -51,7 +52,7 @@ export class FeatureLimit implements CanActivate { await this.featureLimitsUseCases.enforceLimit( limitLabel, user, - extractedData, + extractedData as LimitTypeMapping[typeof limitLabel], ); if (shouldLimitBeEnforced) { From 579e7ae88d3cff4f1e8f69bce9240bf6fe58ba3a Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 15:03:20 -0400 Subject: [PATCH 08/22] feat(limits-script): added script to asign tiers to all users according to payments --- .env.template | 4 +- .../20240220163114-create-paid-plans-tiers.js | 43 +++++ src/config/configuration.ts | 3 + .../feature-limit-migration.service.ts | 148 ++++++++++++++++++ .../feature-limit/feature-limit.module.ts | 12 ++ .../feature-limit/feature-limit.repository.ts | 7 + .../feature-limit/models/paid-plans.model.ts | 34 ++++ src/modules/user/user.repository.ts | 9 ++ 8 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 migrations/20240220163114-create-paid-plans-tiers.js create mode 100644 src/modules/feature-limit/feature-limit-migration.service.ts create mode 100644 src/modules/feature-limit/models/paid-plans.model.ts diff --git a/.env.template b/.env.template index 1bd25f0b1..855359e12 100644 --- a/.env.template +++ b/.env.template @@ -39,4 +39,6 @@ STRIPE_SK=sk_x STRIPE_SK_TEST=sk_test_y GATEWAY_USER=user -GATEWAY_PASS=gatewaypass \ No newline at end of file +GATEWAY_PASS=gatewaypass + +PAYMENTS_API_URL=http://host.docker.internal:8003 \ No newline at end of file diff --git a/migrations/20240220163114-create-paid-plans-tiers.js b/migrations/20240220163114-create-paid-plans-tiers.js new file mode 100644 index 000000000..2ee738e1c --- /dev/null +++ b/migrations/20240220163114-create-paid-plans-tiers.js @@ -0,0 +1,43 @@ +'use strict'; + +const tableName = 'paid_plans'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + allowNull: false, + autoIncrement: true, + }, + plan_id: { + type: Sequelize.STRING, + allowNull: false, + }, + tier_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'tiers', + key: 'id', + }, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + }, + + async down(queryInterface) { + await queryInterface.dropTable(tableName); + }, +}; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 16a4ea7cf..1a982a7be 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -63,6 +63,9 @@ export default () => ({ url: process.env.RECAPTCHA_V3_ENDPOINT, threshold: process.env.RECAPTCHA_V3_SCORE_THRESHOLD, }, + payments: { + url: process.env.PAYMENTS_API_URL, + }, }, clients: { drive: { diff --git a/src/modules/feature-limit/feature-limit-migration.service.ts b/src/modules/feature-limit/feature-limit-migration.service.ts new file mode 100644 index 000000000..b40335bee --- /dev/null +++ b/src/modules/feature-limit/feature-limit-migration.service.ts @@ -0,0 +1,148 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SequelizeUserRepository } from '../user/user.repository'; +import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; +import { HttpClient } from 'src/externals/http/http.service'; +import { Sign } from 'src/middlewares/passport'; +import { ConfigService } from '@nestjs/config'; +import { AxiosError } from 'axios'; +import { User } from '../user/user.domain'; + +const FREE_TIER_ID = 'free'; + +@Injectable() +export class FeatureLimitsMigrationService { + private readonly delayBetweenUsers = 10; + private readonly maxRetries = 3; + private readonly paymentsAPIThrottlingDelay = 60000; + + private planIdTierIdMap: Map; // PlanId/tierId mapping + + constructor( + private userRepository: SequelizeUserRepository, + private tiersRepository: SequelizeFeatureLimitsRepository, + private httpClient: HttpClient, + private readonly configService: ConfigService, + ) {} + + async asignTiersToUsers() { + const limit = 20; + let offset = 0; + let processed = 0; + await this.loadTiers(); + + while (true) { + const users = await this.userRepository.findAllByWithPagination( + { tierId: null }, + limit, + offset, + ); + + if (users.length === 0) { + break; + } + + for (const user of users) { + await this.assignTier(user); + await this.delay(this.delayBetweenUsers); // Delay between requests to prevent Stripe Throttling + } + + processed += users.length; + Logger.log( + `[FEATURE_LIMIT_MIGRATION]: Processed : ${processed}, current offset: ${offset} `, + ); + offset += limit; + } + Logger.log('[FEATURE_LIMIT_MIGRATION]: Tiers applied successfuly.'); + } + + private async assignTier(user: User) { + try { + const subscription = await this.getUserSubscription(user); + let tierId = this.planIdTierIdMap.get(FREE_TIER_ID); + + if (subscription?.priceId) { + if (this.planIdTierIdMap.has(subscription.priceId)) { + tierId = this.planIdTierIdMap.get(subscription.priceId); + } else { + Logger.error( + `[FEATURE_LIMIT_MIGRATION/NOT_MAPPED_TIER]: tier priceId ${subscription?.priceId} has not mapped tier, applying free tier to user userUuid: ${user.uuid} email: ${user.email}`, + ); + } + } + + await this.userRepository.updateBy( + { uuid: user.uuid }, + { + tierId, + }, + ); + } catch (error) { + Logger.error( + `[FEATURE_LIMIT_MIGRATION/ERROR]: error applying applying tier to user userUuid: ${user.uuid} email: ${user.email} error: ${error.message}`, + ); + } + } + + private async getUserSubscription(user: User, retries = 0) { + const jwt = Sign( + { payload: { uuid: user.uuid } }, + this.configService.get('secrets.jwt'), + ); + + const params = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + try { + const res = await this.httpClient.get( + `${this.configService.get('apis.payments.url')}/subscriptions`, + params, + ); + return res.data; + } catch (error) { + if (error instanceof AxiosError) { + if (error.response && error.response.status === 404) { + return; + } else if (error.response && error.response.status === 429) { + if (retries < this.maxRetries) { + Logger.warn( + `[FEATURE_LIMIT_MIGRATION/PAYMENTS_REQUEST]: Throttling detected, waiting for 1 minute for payments API throttling retry number: ${ + retries + 1 + }`, + ); + await this.delay(this.paymentsAPIThrottlingDelay); + return this.getUserSubscription(user, retries + 1); + } + } + } + Logger.error( + `[FEATURE_LIMIT_MIGRATION/PAYMENTS_REQUEST]: error getting user plan userUuid: ${user.uuid} email: ${user.email} error: ${error.message}`, + ); + throw error; + } + } + + private async loadTiers() { + this.planIdTierIdMap = new Map(); + + const tiers = await this.tiersRepository.findAllPlansTiersMap(); + tiers.forEach((tier) => { + this.planIdTierIdMap.set(tier.planId, tier.tierId); + }); + + if (!this.planIdTierIdMap.get(FREE_TIER_ID)) { + Logger.error( + `[FEATURE_LIMIT_MIGRATION/NO_FREE]: No free tier mapped found, please add a tier for free users`, + ); + throw Error( + 'There is no free tier mapped, please add a tier for free users', + ); + } + } + + delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts index 1acd9e607..458eb6953 100644 --- a/src/modules/feature-limit/feature-limit.module.ts +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -7,6 +7,12 @@ import { FeatureLimitUsecases } from './feature-limit.usecase'; import { FeatureLimit } from './feature-limits.guard'; import { TierLimitsModel } from './models/tier-limits.model'; import { SharingModule } from '../sharing/sharing.module'; +import { FeatureLimitsMigrationService } from './feature-limit-migration.service'; +import { UserModule } from '../user/user.module'; +import { FeatureLimitsController } from './feature-limit.controller'; +import { HttpClientModule } from 'src/externals/http/http.module'; +import { ConfigModule } from '@nestjs/config'; +import { PaidPlansModel } from './models/paid-plans.model'; @Module({ imports: [ @@ -15,14 +21,20 @@ import { SharingModule } from '../sharing/sharing.module'; Limitmodel, TierLimitsModel, TierLimitsModel, + PaidPlansModel, ]), + HttpClientModule, forwardRef(() => SharingModule), + forwardRef(() => UserModule), ], providers: [ SequelizeFeatureLimitsRepository, FeatureLimitUsecases, FeatureLimit, + FeatureLimitsMigrationService, + ConfigModule, ], + controllers: [FeatureLimitsController], exports: [FeatureLimit, FeatureLimitUsecases], }) export class FeatureLimitModule {} diff --git a/src/modules/feature-limit/feature-limit.repository.ts b/src/modules/feature-limit/feature-limit.repository.ts index c0dd92857..c56f4f85a 100644 --- a/src/modules/feature-limit/feature-limit.repository.ts +++ b/src/modules/feature-limit/feature-limit.repository.ts @@ -4,6 +4,7 @@ import { TierModel } from './models/tier.model'; import { Limitmodel } from './models/limit.model'; import { TierLimitsModel } from './models/tier-limits.model'; import { Limit } from './limit.domain'; +import { PaidPlansModel } from './models/paid-plans.model'; @Injectable() export class SequelizeFeatureLimitsRepository { @@ -14,6 +15,8 @@ export class SequelizeFeatureLimitsRepository { private limitModel: typeof Limitmodel, @InjectModel(TierLimitsModel) private tierLimitmodel: typeof TierLimitsModel, + @InjectModel(PaidPlansModel) + private paidPlansModel: typeof PaidPlansModel, ) {} async findLimitByLabelAndTier( @@ -36,4 +39,8 @@ export class SequelizeFeatureLimitsRepository { return limit ? Limit.build(limit) : null; } + + async findAllPlansTiersMap(): Promise { + return this.paidPlansModel.findAll(); + } } diff --git a/src/modules/feature-limit/models/paid-plans.model.ts b/src/modules/feature-limit/models/paid-plans.model.ts new file mode 100644 index 000000000..5215f787f --- /dev/null +++ b/src/modules/feature-limit/models/paid-plans.model.ts @@ -0,0 +1,34 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + AllowNull, + AutoIncrement, + ForeignKey, +} from 'sequelize-typescript'; +import { TierModel } from './tier.model'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'paid_plans', +}) +export class PaidPlansModel extends Model { + @PrimaryKey + @AutoIncrement + @Column(DataType.INTEGER) + id: string; + + @AllowNull(false) + @Column(DataType.STRING) + planId: string; + + @ForeignKey(() => TierModel) + @Column(DataType.UUIDV4) + tierId: string; + + @Column + createdAt: Date; +} diff --git a/src/modules/user/user.repository.ts b/src/modules/user/user.repository.ts index 9cd0e2421..d640b48e2 100644 --- a/src/modules/user/user.repository.ts +++ b/src/modules/user/user.repository.ts @@ -93,6 +93,15 @@ export class SequelizeUserRepository implements UserRepository { return users.map((user) => this.toDomain(user)); } + async findAllByWithPagination( + where: any, + limit = 20, + offset = 0, + ): Promise | []> { + const users = await this.modelUser.findAll({ where, limit, offset }); + return users.map((user) => this.toDomain(user)); + } + async findByUsername(username: string): Promise { const user = await this.modelUser.findOne({ where: { From 869743fa006837d5d4fa3a7ea20fe98cc3972a83 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 15:32:54 -0400 Subject: [PATCH 09/22] feat(limit-script): moved get subscription to payments service --- src/externals/payments/payments.service.ts | 25 ++++++++++++++++++- .../feature-limit-migration.service.ts | 25 ++++--------------- .../feature-limit/feature-limit.module.ts | 2 ++ .../feature-limit/models/paid-plans.model.ts | 1 + src/modules/sharing/sharing.module.ts | 2 ++ 5 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/externals/payments/payments.service.ts b/src/externals/payments/payments.service.ts index a25d89f12..c6044498a 100644 --- a/src/externals/payments/payments.service.ts +++ b/src/externals/payments/payments.service.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'; import Stripe from 'stripe'; import { UserAttributes } from '../../modules/user/user.attributes'; +import { HttpClient } from '../http/http.service'; +import { Sign } from 'src/middlewares/passport'; @Injectable() export class PaymentsService { @@ -10,7 +12,8 @@ export class PaymentsService { constructor( @Inject(ConfigService) - configService: ConfigService, + private configService: ConfigService, + private httpClient: HttpClient, ) { const stripeTest = new Stripe(process.env.STRIPE_SK_TEST, { apiVersion: '2023-10-16', @@ -48,4 +51,24 @@ export class PaymentsService { return false; } + + async getCurrentSubscription(uuid: UserAttributes['uuid']) { + const jwt = Sign( + { payload: { uuid } }, + this.configService.get('secrets.jwt'), + ); + + const params = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${jwt}`, + }, + }; + + const res = await this.httpClient.get( + `${this.configService.get('apis.payments.url')}/subscriptions`, + params, + ); + return res.data; + } } diff --git a/src/modules/feature-limit/feature-limit-migration.service.ts b/src/modules/feature-limit/feature-limit-migration.service.ts index b40335bee..5d96a02df 100644 --- a/src/modules/feature-limit/feature-limit-migration.service.ts +++ b/src/modules/feature-limit/feature-limit-migration.service.ts @@ -1,11 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { SequelizeUserRepository } from '../user/user.repository'; import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; -import { HttpClient } from 'src/externals/http/http.service'; -import { Sign } from 'src/middlewares/passport'; -import { ConfigService } from '@nestjs/config'; import { AxiosError } from 'axios'; import { User } from '../user/user.domain'; +import { PaymentsService } from 'src/externals/payments/payments.service'; const FREE_TIER_ID = 'free'; @@ -20,8 +18,7 @@ export class FeatureLimitsMigrationService { constructor( private userRepository: SequelizeUserRepository, private tiersRepository: SequelizeFeatureLimitsRepository, - private httpClient: HttpClient, - private readonly configService: ConfigService, + private paymentsService: PaymentsService, ) {} async asignTiersToUsers() { @@ -84,23 +81,11 @@ export class FeatureLimitsMigrationService { } private async getUserSubscription(user: User, retries = 0) { - const jwt = Sign( - { payload: { uuid: user.uuid } }, - this.configService.get('secrets.jwt'), - ); - - const params = { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${jwt}`, - }, - }; try { - const res = await this.httpClient.get( - `${this.configService.get('apis.payments.url')}/subscriptions`, - params, + const subscription = await this.paymentsService.getCurrentSubscription( + user.uuid, ); - return res.data; + return subscription; } catch (error) { if (error instanceof AxiosError) { if (error.response && error.response.status === 404) { diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts index 458eb6953..59dde8ec6 100644 --- a/src/modules/feature-limit/feature-limit.module.ts +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -13,6 +13,7 @@ import { FeatureLimitsController } from './feature-limit.controller'; import { HttpClientModule } from 'src/externals/http/http.module'; import { ConfigModule } from '@nestjs/config'; import { PaidPlansModel } from './models/paid-plans.model'; +import { PaymentsService } from 'src/externals/payments/payments.service'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { PaidPlansModel } from './models/paid-plans.model'; FeatureLimit, FeatureLimitsMigrationService, ConfigModule, + PaymentsService, ], controllers: [FeatureLimitsController], exports: [FeatureLimit, FeatureLimitUsecases], diff --git a/src/modules/feature-limit/models/paid-plans.model.ts b/src/modules/feature-limit/models/paid-plans.model.ts index 5215f787f..6789ae6f2 100644 --- a/src/modules/feature-limit/models/paid-plans.model.ts +++ b/src/modules/feature-limit/models/paid-plans.model.ts @@ -26,6 +26,7 @@ export class PaidPlansModel extends Model { planId: string; @ForeignKey(() => TierModel) + @AllowNull(false) @Column(DataType.UUIDV4) tierId: string; diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index 077207ac8..fe31c0ac6 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -21,6 +21,7 @@ import { BridgeModule } from '../../externals/bridge/bridge.module'; import { PaymentsService } from '../../externals/payments/payments.service'; import { AppSumoModule } from '../app-sumo/app-sumo.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; +import { HttpClientModule } from 'src/externals/http/http.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; AppSumoModule, forwardRef(() => UserModule), forwardRef(() => FeatureLimitModule), + HttpClientModule, ], controllers: [SharingController], providers: [ From 4c2640ca793c78a1432026002228deb3afb2b028 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 17:56:49 -0400 Subject: [PATCH 10/22] chore(feature-limits): added seeder for feature limits and tiers --- .../20240220203734-seed-tiers-and-limits.js | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 migrations/20240220203734-seed-tiers-and-limits.js diff --git a/migrations/20240220203734-seed-tiers-and-limits.js b/migrations/20240220203734-seed-tiers-and-limits.js new file mode 100644 index 000000000..47b41d553 --- /dev/null +++ b/migrations/20240220203734-seed-tiers-and-limits.js @@ -0,0 +1,138 @@ +'use strict'; + +const { v4: uuidv4 } = require('uuid'); + +module.exports = { + async up(queryInterface) { + const tiersData = [ + { id: uuidv4(), label: '10gb_individual', context: 'Plan 10GB' }, + { id: uuidv4(), label: '200gb_individual', context: 'Plan 200GB' }, + { id: uuidv4(), label: '2tb_individual', context: 'Plan 2TB' }, + { id: uuidv4(), label: '5tb_individual', context: 'Plan 5TB' }, + { id: uuidv4(), label: '10tb_individual', context: 'Plan 10TB' }, + ]; + + await queryInterface.bulkInsert( + 'tiers', + tiersData.map((tier) => ({ + id: tier.id, + label: tier.label, + context: tier.context, + created_at: new Date(), + updated_at: new Date(), + })), + ); + + const limitsData = [ + { + tierLabel: '10gb_individual', + limits: [ + { label: 'max-shared-items', type: 'counter', value: '10' }, + { label: 'max-shared-invites', type: 'counter', value: '5' }, + { label: 'file-versioning', type: 'boolean', value: 'false' }, + { label: 'max-file-upload-size', type: 'counter', value: '1' }, + { label: 'max-trash-storage-days', type: 'counter', value: '7' }, + { label: 'max-back-up-devices', type: 'counter', value: '1' }, + ], + }, + { + tierLabel: '200gb_individual', + limits: [ + { label: 'max-shared-items', type: 'counter', value: '50' }, + { label: 'max-shared-invites', type: 'counter', value: '50' }, + { label: 'file-versioning', type: 'boolean', value: 'false' }, + { label: 'max-file-upload-size', type: 'counter', value: '5' }, + { label: 'max-trash-storage-days', type: 'counter', value: '90' }, + { label: 'max-back-up-devices', type: 'counter', value: '5' }, + ], + }, + { + tierLabel: '2tb_individual', + limits: [ + { label: 'max-shared-items', type: 'counter', value: '50' }, + { label: 'max-shared-invites', type: 'counter', value: '50' }, + { label: 'file-versioning', type: 'boolean', value: 'true' }, + { label: 'max-file-upload-size', type: 'counter', value: '20' }, + { label: 'max-trash-storage-days', type: 'counter', value: '90' }, + { label: 'max-back-up-devices', type: 'counter', value: '10' }, + ], + }, + { + tierLabel: '5tb_individual', + limits: [ + { label: 'max-shared-items', type: 'counter', value: '1000' }, + { label: 'max-shared-invites', type: 'counter', value: '100' }, + { label: 'file-versioning', type: 'boolean', value: 'true' }, + { label: 'max-file-upload-size', type: 'counter', value: '20' }, + { label: 'max-trash-storage-days', type: 'counter', value: '180' }, + { label: 'max-back-up-devices', type: 'counter', value: '20' }, + ], + }, + { + tierLabel: '10tb_individual', + limits: [ + { label: 'max-shared-items', type: 'counter', value: '1000' }, + { label: 'max-shared-invites', type: 'counter', value: '100' }, + { label: 'file-versioning', type: 'boolean', value: 'true' }, + { label: 'max-file-upload-size', type: 'counter', value: '20' }, + { label: 'max-trash-storage-days', type: 'counter', value: '365' }, + { label: 'max-back-up-devices', type: 'counter', value: '20' }, + ], + }, + ]; + + let tierAndLimitsRelations = []; + + const flattenedLimits = limitsData.flatMap((plan) => { + return plan.limits.map((limit) => { + const uuid = uuidv4(); + + tierAndLimitsRelations.push({ + id: uuidv4(), + limit_id: uuid, + tier_id: tiersData.find((tier) => tier.label === plan.tierLabel).id, + created_at: new Date(), + updated_at: new Date(), + }); + + return { + id: uuid, + label: limit.label, + type: limit.type, + value: limit.value, + created_at: new Date(), + updated_at: new Date(), + }; + }); + }); + + await queryInterface.bulkInsert('limits', flattenedLimits); + + await queryInterface.bulkInsert('tiers_limits', tierAndLimitsRelations); + }, + + async down(queryInterface) { + const tierLabels = [ + '10gb_individual', + '200gb_individual', + '2tb_individual', + '5tb_individual', + '10tb_individual', + ]; + + await queryInterface.sequelize.query( + `DELETE FROM tiers_limits WHERE tier_id IN (SELECT id FROM tiers WHERE label IN (:tierLabels))`, + { replacements: { tierLabels } }, + ); + + await queryInterface.sequelize.query( + `DELETE FROM tiers WHERE label IN (:tierLabels)`, + { replacements: { tierLabels } }, + ); + + // Delete any orphaned limits that are no longer associated with any tier + await queryInterface.sequelize.query(` + DELETE FROM limits WHERE id NOT IN (SELECT limit_id FROM tiers_limits) + `); + }, +}; From e0a7610e527ece1359073eb4b20e569fe5245817 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 18:18:24 -0400 Subject: [PATCH 11/22] chore(feature-limits): added migration for paid plans free tier --- .../20240220163114-create-paid-plans-tiers.js | 3 +++ ...5830-seed-free-plan-to-paid-plans-table.js | 25 +++++++++++++++++++ .../feature-limit-migration.service.ts | 4 +-- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 migrations/20240220215830-seed-free-plan-to-paid-plans-table.js diff --git a/migrations/20240220163114-create-paid-plans-tiers.js b/migrations/20240220163114-create-paid-plans-tiers.js index 2ee738e1c..5ca13e3b4 100644 --- a/migrations/20240220163114-create-paid-plans-tiers.js +++ b/migrations/20240220163114-create-paid-plans-tiers.js @@ -24,6 +24,9 @@ module.exports = { key: 'id', }, }, + description: { + type: Sequelize.STRING, + }, created_at: { type: Sequelize.DATE, allowNull: false, diff --git a/migrations/20240220215830-seed-free-plan-to-paid-plans-table.js b/migrations/20240220215830-seed-free-plan-to-paid-plans-table.js new file mode 100644 index 000000000..a19d70c66 --- /dev/null +++ b/migrations/20240220215830-seed-free-plan-to-paid-plans-table.js @@ -0,0 +1,25 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + const tiers = await queryInterface.sequelize.query( + "SELECT id FROM tiers WHERE label = '10gb_individual' LIMIT 1", + { type: Sequelize.QueryTypes.SELECT }, + ); + + if (tiers.length > 0) { + const tierId = tiers[0].id; + + await queryInterface.sequelize.query( + `INSERT INTO paid_plans (plan_id, tier_id, description, created_at, updated_at) + VALUES ('free_000000', '${tierId}', 'Free Tier 10gb' ,NOW(), NOW())`, + ); + } + }, + + async down(queryInterface) { + await queryInterface.sequelize.query( + "DELETE FROM paid_plans WHERE plan_id = 'free_000000'", + ); + }, +}; diff --git a/src/modules/feature-limit/feature-limit-migration.service.ts b/src/modules/feature-limit/feature-limit-migration.service.ts index 5d96a02df..8d53b0cad 100644 --- a/src/modules/feature-limit/feature-limit-migration.service.ts +++ b/src/modules/feature-limit/feature-limit-migration.service.ts @@ -5,7 +5,7 @@ import { AxiosError } from 'axios'; import { User } from '../user/user.domain'; import { PaymentsService } from 'src/externals/payments/payments.service'; -const FREE_TIER_ID = 'free'; +const FREE_TIER_ID = 'free_000000'; @Injectable() export class FeatureLimitsMigrationService { @@ -119,7 +119,7 @@ export class FeatureLimitsMigrationService { if (!this.planIdTierIdMap.get(FREE_TIER_ID)) { Logger.error( - `[FEATURE_LIMIT_MIGRATION/NO_FREE]: No free tier mapped found, please add a tier for free users`, + `[FEATURE_LIMIT_MIGRATION/NO_FREE]: No free tier mapped found, please add a tier for free users to your DB`, ); throw Error( 'There is no free tier mapped, please add a tier for free users', From c1adbba887a0f45d3cca57c16abc0485172c2f2a Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 18:22:04 -0400 Subject: [PATCH 12/22] chore: fix type --- .../feature-limit/feature-limit-migration.service.ts | 7 +++---- src/modules/feature-limit/limits.enum.ts | 2 ++ src/modules/user/user.repository.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/modules/feature-limit/feature-limit-migration.service.ts b/src/modules/feature-limit/feature-limit-migration.service.ts index 8d53b0cad..6a9b0ac7d 100644 --- a/src/modules/feature-limit/feature-limit-migration.service.ts +++ b/src/modules/feature-limit/feature-limit-migration.service.ts @@ -4,8 +4,7 @@ import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; import { AxiosError } from 'axios'; import { User } from '../user/user.domain'; import { PaymentsService } from 'src/externals/payments/payments.service'; - -const FREE_TIER_ID = 'free_000000'; +import { PLAN_FREE_TIER_ID } from './limits.enum'; @Injectable() export class FeatureLimitsMigrationService { @@ -55,7 +54,7 @@ export class FeatureLimitsMigrationService { private async assignTier(user: User) { try { const subscription = await this.getUserSubscription(user); - let tierId = this.planIdTierIdMap.get(FREE_TIER_ID); + let tierId = this.planIdTierIdMap.get(PLAN_FREE_TIER_ID); if (subscription?.priceId) { if (this.planIdTierIdMap.has(subscription.priceId)) { @@ -117,7 +116,7 @@ export class FeatureLimitsMigrationService { this.planIdTierIdMap.set(tier.planId, tier.tierId); }); - if (!this.planIdTierIdMap.get(FREE_TIER_ID)) { + if (!this.planIdTierIdMap.get(PLAN_FREE_TIER_ID)) { Logger.error( `[FEATURE_LIMIT_MIGRATION/NO_FREE]: No free tier mapped found, please add a tier for free users to your DB`, ); diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts index 66ec5f6b9..7b9ac8625 100644 --- a/src/modules/feature-limit/limits.enum.ts +++ b/src/modules/feature-limit/limits.enum.ts @@ -7,3 +7,5 @@ export enum LimitTypes { Boolean = 'boolean', Counter = 'counter', } + +export const PLAN_FREE_TIER_ID = 'free_000000'; diff --git a/src/modules/user/user.repository.ts b/src/modules/user/user.repository.ts index d640b48e2..f6f71f162 100644 --- a/src/modules/user/user.repository.ts +++ b/src/modules/user/user.repository.ts @@ -97,7 +97,7 @@ export class SequelizeUserRepository implements UserRepository { where: any, limit = 20, offset = 0, - ): Promise | []> { + ): Promise { const users = await this.modelUser.findAll({ where, limit, offset }); return users.map((user) => this.toDomain(user)); } From 975ca63ba63f9772cc89e96fd28ca6c0f5ccb473 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 18:38:32 -0400 Subject: [PATCH 13/22] fix: fix module removing controller --- src/modules/feature-limit/feature-limit.module.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts index 59dde8ec6..c2f275869 100644 --- a/src/modules/feature-limit/feature-limit.module.ts +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -9,7 +9,6 @@ import { TierLimitsModel } from './models/tier-limits.model'; import { SharingModule } from '../sharing/sharing.module'; import { FeatureLimitsMigrationService } from './feature-limit-migration.service'; import { UserModule } from '../user/user.module'; -import { FeatureLimitsController } from './feature-limit.controller'; import { HttpClientModule } from 'src/externals/http/http.module'; import { ConfigModule } from '@nestjs/config'; import { PaidPlansModel } from './models/paid-plans.model'; @@ -36,7 +35,6 @@ import { PaymentsService } from 'src/externals/payments/payments.service'; ConfigModule, PaymentsService, ], - controllers: [FeatureLimitsController], exports: [FeatureLimit, FeatureLimitUsecases], }) export class FeatureLimitModule {} From 5eff5538d611663dba1a5ddc31e48ec5d4c9d5f4 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Tue, 20 Feb 2024 19:22:23 -0400 Subject: [PATCH 14/22] feat: add free tier to users on signup --- src/externals/payments/payments.service.ts | 1 + .../feature-limit/feature-limit.module.ts | 6 +++++- .../feature-limit/feature-limit.repository.ts | 20 +++++++++++++----- .../feature-limit/models/paid-plans.model.ts | 8 +++++++ src/modules/feature-limit/tier.domain.ts | 21 +++++++++++++++++++ src/modules/user/user.module.ts | 2 ++ src/modules/user/user.usecase.ts | 5 +++++ 7 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/modules/feature-limit/tier.domain.ts diff --git a/src/externals/payments/payments.service.ts b/src/externals/payments/payments.service.ts index c6044498a..42f0ad0ef 100644 --- a/src/externals/payments/payments.service.ts +++ b/src/externals/payments/payments.service.ts @@ -13,6 +13,7 @@ export class PaymentsService { constructor( @Inject(ConfigService) private configService: ConfigService, + @Inject(HttpClient) private httpClient: HttpClient, ) { const stripeTest = new Stripe(process.env.STRIPE_SK_TEST, { diff --git a/src/modules/feature-limit/feature-limit.module.ts b/src/modules/feature-limit/feature-limit.module.ts index c2f275869..215162962 100644 --- a/src/modules/feature-limit/feature-limit.module.ts +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -35,6 +35,10 @@ import { PaymentsService } from 'src/externals/payments/payments.service'; ConfigModule, PaymentsService, ], - exports: [FeatureLimit, FeatureLimitUsecases], + exports: [ + FeatureLimit, + FeatureLimitUsecases, + SequelizeFeatureLimitsRepository, + ], }) export class FeatureLimitModule {} diff --git a/src/modules/feature-limit/feature-limit.repository.ts b/src/modules/feature-limit/feature-limit.repository.ts index c56f4f85a..62477f63b 100644 --- a/src/modules/feature-limit/feature-limit.repository.ts +++ b/src/modules/feature-limit/feature-limit.repository.ts @@ -2,19 +2,16 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; import { TierModel } from './models/tier.model'; import { Limitmodel } from './models/limit.model'; -import { TierLimitsModel } from './models/tier-limits.model'; import { Limit } from './limit.domain'; import { PaidPlansModel } from './models/paid-plans.model'; +import { Tier } from './tier.domain'; +import { PLAN_FREE_TIER_ID } from './limits.enum'; @Injectable() export class SequelizeFeatureLimitsRepository { constructor( - @InjectModel(TierModel) - private tierModel: typeof TierModel, @InjectModel(Limitmodel) private limitModel: typeof Limitmodel, - @InjectModel(TierLimitsModel) - private tierLimitmodel: typeof TierLimitsModel, @InjectModel(PaidPlansModel) private paidPlansModel: typeof PaidPlansModel, ) {} @@ -43,4 +40,17 @@ export class SequelizeFeatureLimitsRepository { async findAllPlansTiersMap(): Promise { return this.paidPlansModel.findAll(); } + + async getTierByPlanId(planId: string): Promise { + const planTier = await this.paidPlansModel.findOne({ + where: { planId }, + include: [TierModel], + }); + + return planTier?.tier ? Tier.build(planTier?.tier) : null; + } + + async getFreeTier(): Promise { + return this.getTierByPlanId(PLAN_FREE_TIER_ID); + } } diff --git a/src/modules/feature-limit/models/paid-plans.model.ts b/src/modules/feature-limit/models/paid-plans.model.ts index 6789ae6f2..0df44134d 100644 --- a/src/modules/feature-limit/models/paid-plans.model.ts +++ b/src/modules/feature-limit/models/paid-plans.model.ts @@ -7,6 +7,7 @@ import { AllowNull, AutoIncrement, ForeignKey, + BelongsTo, } from 'sequelize-typescript'; import { TierModel } from './tier.model'; @@ -30,6 +31,13 @@ export class PaidPlansModel extends Model { @Column(DataType.UUIDV4) tierId: string; + @BelongsTo(() => TierModel, { + foreignKey: 'tier_id', + targetKey: 'id', + as: 'tier', + }) + tier: TierModel; + @Column createdAt: Date; } diff --git a/src/modules/feature-limit/tier.domain.ts b/src/modules/feature-limit/tier.domain.ts new file mode 100644 index 000000000..581f17b7a --- /dev/null +++ b/src/modules/feature-limit/tier.domain.ts @@ -0,0 +1,21 @@ +export interface TierAttributes { + id: string; + label: string; + context?: string; +} + +export class Tier implements TierAttributes { + id: string; + label: string; + context?: string; + + constructor(attributes: TierAttributes) { + this.id = attributes.id; + this.label = attributes.label; + this.context = attributes.context; + } + + static build(tier: TierAttributes): Tier { + return new Tier(tier); + } +} diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 62bbbc3c8..f9dc168cf 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -43,6 +43,7 @@ import { SequelizeAttemptChangeEmailRepository } from './attempt-change-email.re import { AttemptChangeEmailModel } from './attempt-change-email.model'; import { MailerService } from '../../externals/mailer/mailer.service'; import { SecurityModule } from '../security/security.module'; +import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; @Module({ imports: [ @@ -67,6 +68,7 @@ import { SecurityModule } from '../security/security.module'; PlanModule, forwardRef(() => SharingModule), SecurityModule, + forwardRef(() => FeatureLimitModule), ], controllers: [UserController], providers: [ diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index c3e14b9af..19864e47b 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -66,6 +66,7 @@ import { getTokenDefaultIat } from '../../lib/jwt'; import { MailTypes } from '../security/mail-limit/mailTypes'; import { SequelizeMailLimitRepository } from '../security/mail-limit/mail-limit.repository'; import { Time } from '../../lib/time'; +import { SequelizeFeatureLimitsRepository } from '../feature-limit/feature-limit.repository'; class ReferralsNotAvailableError extends Error { constructor() { @@ -141,6 +142,7 @@ export class UserUseCases { private readonly avatarService: AvatarService, private readonly mailerService: MailerService, private readonly mailLimitRepository: SequelizeMailLimitRepository, + private readonly featureLimitRepository: SequelizeFeatureLimitsRepository, ) {} findByEmail(email: User['email']): Promise { @@ -356,6 +358,8 @@ export class UserUseCases { return false; }); + const freeTier = await this.featureLimitRepository.getFreeTier(); + const user = await this.userRepository.create({ email, name: newUser.name, @@ -372,6 +376,7 @@ export class UserUseCases { username: email, bridgeUser: email, mnemonic: newUser.mnemonic, + tierId: freeTier?.id, }); try { From d30ab98b727c8149fff7d00d0d929553cdd48242 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 21 Feb 2024 09:05:30 -0400 Subject: [PATCH 15/22] chore: comment out filters --- src/modules/sharing/sharing.controller.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index b848128ee..6a42f0cfa 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -251,14 +251,14 @@ export class SharingController { } @Post('/invites/send') - @ApplyLimit({ + /* @ApplyLimit({ limitLabels: [LimitLabels.MaxSharedItemInvites, LimitLabels.MaxSharedItems], dataSources: [ { sourceKey: 'body', fieldName: 'itemId' }, { sourceKey: 'body', fieldName: 'itemType' }, ], }) - @UseGuards(FeatureLimit) + @UseGuards(FeatureLimit) */ createInvite( @UserDecorator() user: User, @Body() createInviteDto: CreateInviteDto, @@ -602,11 +602,11 @@ export class SharingController { } @Post('/') - @ApplyLimit({ + /* @ApplyLimit({ limitLabels: [LimitLabels.MaxSharedItems], dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], }) - @UseGuards(FeatureLimit) + @UseGuards(FeatureLimit) */ createSharing( @UserDecorator() user, @Body() acceptInviteDto: CreateSharingDto, From c5cc79088a58d95998606a15ab63f958b84738f9 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 21 Feb 2024 10:10:09 -0400 Subject: [PATCH 16/22] chore: migratin script small improvements --- .../feature-limit-migration.service.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/modules/feature-limit/feature-limit-migration.service.ts b/src/modules/feature-limit/feature-limit-migration.service.ts index 6a9b0ac7d..39b730e57 100644 --- a/src/modules/feature-limit/feature-limit-migration.service.ts +++ b/src/modules/feature-limit/feature-limit-migration.service.ts @@ -22,20 +22,18 @@ export class FeatureLimitsMigrationService { async asignTiersToUsers() { const limit = 20; - let offset = 0; let processed = 0; + let resultsCount = 0; + await this.loadTiers(); - while (true) { + while (resultsCount % limit === 0) { const users = await this.userRepository.findAllByWithPagination( { tierId: null }, limit, - offset, ); - if (users.length === 0) { - break; - } + resultsCount = users.length; for (const user of users) { await this.assignTier(user); @@ -43,10 +41,7 @@ export class FeatureLimitsMigrationService { } processed += users.length; - Logger.log( - `[FEATURE_LIMIT_MIGRATION]: Processed : ${processed}, current offset: ${offset} `, - ); - offset += limit; + Logger.log(`[FEATURE_LIMIT_MIGRATION]: Processed : ${processed}`); } Logger.log('[FEATURE_LIMIT_MIGRATION]: Tiers applied successfuly.'); } @@ -89,7 +84,9 @@ export class FeatureLimitsMigrationService { if (error instanceof AxiosError) { if (error.response && error.response.status === 404) { return; - } else if (error.response && error.response.status === 429) { + } + + if (error.response && error.response.status === 429) { if (retries < this.maxRetries) { Logger.warn( `[FEATURE_LIMIT_MIGRATION/PAYMENTS_REQUEST]: Throttling detected, waiting for 1 minute for payments API throttling retry number: ${ From 7d24edc84b58efb3257a5ce7f14abf149c7a295e Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 21 Feb 2024 10:13:44 -0400 Subject: [PATCH 17/22] chore: change payments.service Sign route path to relative to not break tests --- src/externals/payments/payments.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/externals/payments/payments.service.ts b/src/externals/payments/payments.service.ts index 42f0ad0ef..592acc2cd 100644 --- a/src/externals/payments/payments.service.ts +++ b/src/externals/payments/payments.service.ts @@ -4,7 +4,7 @@ import Stripe from 'stripe'; import { UserAttributes } from '../../modules/user/user.attributes'; import { HttpClient } from '../http/http.service'; -import { Sign } from 'src/middlewares/passport'; +import { Sign } from '../../middlewares/passport'; @Injectable() export class PaymentsService { From 6b3142dc982072bf47afcefd521f94de212c82f8 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 21 Feb 2024 11:38:46 -0400 Subject: [PATCH 18/22] chore: add tests for limit domain and feature to mock them --- .../feature-limit/limit.domain.spec.ts | 71 +++++++++++++++++++ src/modules/feature-limit/limit.domain.ts | 2 +- test/fixtures.ts | 19 +++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/modules/feature-limit/limit.domain.spec.ts diff --git a/src/modules/feature-limit/limit.domain.spec.ts b/src/modules/feature-limit/limit.domain.spec.ts new file mode 100644 index 000000000..831038765 --- /dev/null +++ b/src/modules/feature-limit/limit.domain.spec.ts @@ -0,0 +1,71 @@ +import { newFeatureLimit } from '../../../test/fixtures'; +import { LimitTypes } from './limits.enum'; + +describe('Limit Domain', () => { + describe('isBooleanLimit()', () => { + it('When limit is boolean type, then it should return true', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Boolean, + value: 'false', + }); + + expect(limit.isBooleanLimit()).toBeTruthy(); + }); + + it('When limit is not boolean type, then it should return false', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + expect(limit.isBooleanLimit()).toBeFalsy(); + }); + }); + + describe('shouldLimitBeEnforced()', () => { + it('When limit is boolean type and value is false, then limit should be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Boolean, + value: 'false', + }); + + expect(limit.shouldLimitBeEnforced()).toBeTruthy(); + }); + + it('When limit is boolean type and value is true, then limit should not be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Boolean, + value: 'true', + }); + + expect(limit.shouldLimitBeEnforced()).toBeFalsy(); + }); + + it('When limit is counter type and current count from context is greater or equal than value, then limit should be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + expect(limit.shouldLimitBeEnforced({ currentCount: 3 })).toBeTruthy(); + }); + + it('When limit is counter type and current count from context is less than value, then limit should be not be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + expect(limit.shouldLimitBeEnforced({ currentCount: 2 })).toBeFalsy(); + }); + + it('When bypassLimit is passed from the context, then limit should not be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + expect(limit.shouldLimitBeEnforced({ bypassLimit: true })).toBeFalsy(); + }); + }); +}); diff --git a/src/modules/feature-limit/limit.domain.ts b/src/modules/feature-limit/limit.domain.ts index 319bbef3f..daf98ea70 100644 --- a/src/modules/feature-limit/limit.domain.ts +++ b/src/modules/feature-limit/limit.domain.ts @@ -27,7 +27,7 @@ export class Limit { } private isFeatureEnabled() { - return this.isBooleanLimit() && Boolean(this.value); + return this.isBooleanLimit() && this.value === 'true'; } private isCounterLimitExceeded(currentCount: number) { diff --git a/test/fixtures.ts b/test/fixtures.ts index 698e2b9a2..1eef798ed 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -11,6 +11,11 @@ import { import { File, FileStatus } from '../src/modules/file/file.domain'; import { MailTypes } from '../src/modules/security/mail-limit/mailTypes'; import { MailLimit } from '../src/modules/security/mail-limit/mail-limit.domain'; +import { + LimitLabels, + LimitTypes, +} from '../src/modules/feature-limit/limits.enum'; +import { Limit } from '../src/modules/feature-limit/limit.domain'; export const constants = { BUCKET_ID_LENGTH: 24, @@ -225,3 +230,17 @@ export const newMailLimit = (bindTo?: { lastMailSent: bindTo?.lastMailSent ?? new Date(), }); }; + +export const newFeatureLimit = (bindTo?: { + id?: string; + type: LimitTypes; + label?: LimitLabels; + value: string; +}): Limit => { + return Limit.build({ + id: bindTo?.id ?? v4(), + type: bindTo.type, + value: bindTo.value, + label: bindTo?.label ?? ('' as LimitLabels), + }); +}; From 73ab96bc6fcf735207e08468b92302d1a60d3cb1 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Wed, 21 Feb 2024 13:17:10 -0400 Subject: [PATCH 19/22] chore: add tests for feature-limit usecase --- .../feature-limit.usecase.spec.ts | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 src/modules/feature-limit/feature-limit.usecase.spec.ts diff --git a/src/modules/feature-limit/feature-limit.usecase.spec.ts b/src/modules/feature-limit/feature-limit.usecase.spec.ts new file mode 100644 index 000000000..5bb905502 --- /dev/null +++ b/src/modules/feature-limit/feature-limit.usecase.spec.ts @@ -0,0 +1,263 @@ +import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; +import { SequelizeSharingRepository } from '../sharing/sharing.repository'; +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { PaymentRequiredException } from './exceptions/payment-required.exception'; +import { FeatureLimitUsecases } from './feature-limit.usecase'; +import { newFeatureLimit, newUser } from '../../../test/fixtures'; +import { LimitLabels, LimitTypes } from './limits.enum'; +import { Sharing } from '../sharing/sharing.domain'; +import { InternalServerErrorException } from '@nestjs/common'; + +describe('FeatureLimitUsecases', () => { + let service: FeatureLimitUsecases; + let limitsRepository: DeepMocked; + let sharingRepository: DeepMocked; + + beforeEach(async () => { + limitsRepository = createMock(); + sharingRepository = createMock(); + service = new FeatureLimitUsecases(limitsRepository, sharingRepository); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('enforceLimit', () => { + const user = newUser(); + + it('When limit is boolean type and it is false, then it should throw to enforce limit', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Boolean, + value: 'false', + }); + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit); + + await expect( + service.enforceLimit('' as LimitLabels, user, {}), + ).rejects.toThrow(PaymentRequiredException); + }); + + it('When limit is boolean type and it is true, then it should not enforce limit', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Boolean, + value: 'true', + }); + + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce(limit); + + const enforceLimit = await service.enforceLimit( + '' as LimitLabels, + user, + {}, + ); + + expect(enforceLimit).toBeFalsy(); + }); + + it('When limit is counter and is surprassed, then limit should be enforced', async () => { + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce( + newFeatureLimit({ + type: LimitTypes.Counter, + value: '4', + }), + ); + jest.spyOn(service, 'checkCounterLimit').mockResolvedValueOnce(true); + + await expect( + service.enforceLimit('' as LimitLabels, user, {}), + ).rejects.toThrow(PaymentRequiredException); + }); + + it('When limit is counter and is not surprassed, then limit should be not be enforced', async () => { + limitsRepository.findLimitByLabelAndTier.mockResolvedValueOnce( + newFeatureLimit({ + type: LimitTypes.Counter, + value: '4', + }), + ); + jest.spyOn(service, 'checkCounterLimit').mockResolvedValueOnce(false); + + const enforceLimit = await service.enforceLimit( + '' as LimitLabels, + user, + {}, + ); + + expect(enforceLimit).toBeFalsy(); + }); + }); + + describe('checkMaxSharedItemsLimit', () => { + const user = newUser(); + + it('When if item is already shared, then should bypassLimit', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + const shouldLimitBeEnforcedSpy = jest.spyOn( + limit, + 'shouldLimitBeEnforced', + ); + sharingRepository.findOneSharingBy.mockResolvedValueOnce({ + id: '', + } as Sharing); + + const enforceMaxSharedItemsLimit = await service.checkMaxSharedItemsLimit( + { + limit, + user, + data: { itemId: '', isPublicSharing: false, user }, + }, + ); + + expect(enforceMaxSharedItemsLimit).toBeFalsy(); + expect(shouldLimitBeEnforcedSpy).toHaveBeenCalledWith({ + bypassLimit: true, + currentCount: 0, + }); + }); + + it('When user has equal or more sharings than limit, limit should be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + sharingRepository.findOneSharingBy.mockResolvedValueOnce(null); + sharingRepository.getSharedItemsNumberByUser.mockResolvedValueOnce(3); + + const enforceMaxSharedItemsLimit = await service.checkMaxSharedItemsLimit( + { + limit, + user, + data: { itemId: '', isPublicSharing: false, user }, + }, + ); + + expect(enforceMaxSharedItemsLimit).toBeTruthy(); + }); + + it('When user has less sharings than limit, limit should not be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '4', + }); + + sharingRepository.findOneSharingBy.mockResolvedValueOnce(null); + sharingRepository.getSharedItemsNumberByUser.mockResolvedValueOnce(3); + + const enforceMaxSharedItemsLimit = await service.checkMaxSharedItemsLimit( + { + limit, + user, + data: { itemId: '', isPublicSharing: false, user }, + }, + ); + + expect(enforceMaxSharedItemsLimit).toBeFalsy(); + }); + }); + + describe('checkMaxInviteesPerItemLimit', () => { + it('When item has more invitations than the limit, limit should be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + sharingRepository.getSharingsCountBy.mockResolvedValueOnce(3); + sharingRepository.getInvitesCountBy.mockResolvedValueOnce(3); + + const enforceMaxInvitesPerItem = + await service.checkMaxInviteesPerItemLimit({ + limit, + data: { itemId: '', itemType: 'file' }, + }); + + expect(enforceMaxInvitesPerItem).toBeTruthy(); + }); + + it('When item has less invitations than the limit, limit should not be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + sharingRepository.getSharingsCountBy.mockResolvedValueOnce(0); + sharingRepository.getInvitesCountBy.mockResolvedValueOnce(1); + + const enforceMaxInvitesPerItem = + await service.checkMaxInviteesPerItemLimit({ + limit, + data: { itemId: '', itemType: 'file' }, + }); + + expect(enforceMaxInvitesPerItem).toBeFalsy(); + }); + + it('When limit has enough invitations to surprass limit including owner, then limit should be enforced', async () => { + const limit = newFeatureLimit({ + type: LimitTypes.Counter, + value: '3', + }); + + sharingRepository.getSharingsCountBy.mockResolvedValueOnce(0); + sharingRepository.getInvitesCountBy.mockResolvedValueOnce(2); + + const enforceMaxInvitesPerItem = + await service.checkMaxInviteesPerItemLimit({ + limit, + data: { itemId: '', itemType: 'file' }, + }); + + expect(enforceMaxInvitesPerItem).toBeTruthy(); + }); + }); + + describe('checkCounterLimit', () => { + const user = newUser(); + + it('When checkfunction is not defined, then it should throw', async () => { + const limit = newFeatureLimit({ + label: 'notExistentLabel' as LimitLabels, + type: LimitTypes.Counter, + value: '3', + }); + + // limitCheckFunctions is a private value, this is just to access at runtime + service['limitCheckFunctions']['limitLabel'] = jest.fn(); + + await expect( + service.checkCounterLimit(user, limit, { + itemId: '', + itemType: 'file', + }), + ).rejects.toBeInstanceOf(InternalServerErrorException); + }); + + it('When checkfunction is defined, then it should be called with passed data', async () => { + const limit = newFeatureLimit({ + label: 'limitLabel' as LimitLabels, + type: LimitTypes.Counter, + value: '3', + }); + + const mockCheckFunction = jest.fn(); + service['limitCheckFunctions']['limitLabel'] = mockCheckFunction; + + await service.checkCounterLimit(user, limit, { + itemId: '', + itemType: 'file', + }); + + expect(mockCheckFunction).toHaveBeenCalledWith({ + limit, + user, + data: { itemId: '', itemType: 'file' }, + }); + }); + }); +}); From f78099c1944345e94d3b22b25056df4e4aa20e43 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 22 Feb 2024 06:17:08 -0400 Subject: [PATCH 20/22] chore: add unit test for guard --- .../feature-limits.guard.spec.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/modules/feature-limit/feature-limits.guard.spec.ts diff --git a/src/modules/feature-limit/feature-limits.guard.spec.ts b/src/modules/feature-limit/feature-limits.guard.spec.ts new file mode 100644 index 000000000..7e314ce09 --- /dev/null +++ b/src/modules/feature-limit/feature-limits.guard.spec.ts @@ -0,0 +1,144 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { PaymentRequiredException } from './exceptions/payment-required.exception'; +import { FeatureLimitUsecases } from './feature-limit.usecase'; +import { newUser } from '../../../test/fixtures'; +import { LimitLabels } from './limits.enum'; +import { BadRequestException, ExecutionContext } from '@nestjs/common'; +import { FeatureLimit } from './feature-limits.guard'; +import { Reflector } from '@nestjs/core'; +import { + ApplyLimitMetadata, + FEATURE_LIMIT_KEY, +} from './decorators/apply-limit.decorator'; + +const user = newUser(); + +describe('FeatureLimitUsecases', () => { + let guard: FeatureLimit; + let reflector: DeepMocked; + let featureLimitUsecases: DeepMocked; + + beforeEach(async () => { + reflector = createMock(); + featureLimitUsecases = createMock(); + guard = new FeatureLimit(reflector, featureLimitUsecases); + }); + + it('Guard should be defined', () => { + expect(guard).toBeDefined(); + }); + + it('When metadata is missing, it should throw', async () => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + + const context = createMockExecutionContext({}); + + await expect(guard.canActivate(context)).rejects.toThrow( + BadRequestException, + ); + }); + + it('When a data source is missing in the incoming request, it should throw', async () => { + mockMetadata(reflector, { + limitLabels: ['' as LimitLabels], + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }); + + const context = createMockExecutionContext({ + user, + body: {}, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + BadRequestException, + ); + }); + + it('When limit should not be enforced, it should allow access', async () => { + mockMetadata(reflector, { + limitLabels: ['' as LimitLabels], + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }); + jest.spyOn(featureLimitUsecases, 'enforceLimit').mockResolvedValue(false); + + const context = createMockExecutionContext({ + user, + body: { itemId: 'item-1' }, + }); + + await expect(guard.canActivate(context)).resolves.toBeTruthy(); + }); + + it('When two labels are passed, it should check two limits', async () => { + const limitLabels = [ + 'firstLabel' as LimitLabels, + 'secondLabel' as LimitLabels, + ]; + mockMetadata(reflector, { + limitLabels, + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }); + const enforceLimitSpy = jest.spyOn(featureLimitUsecases, 'enforceLimit'); + enforceLimitSpy.mockResolvedValue(false); + const context = createMockExecutionContext({ + user, + body: { itemId: 'item-1' }, + }); + + await guard.canActivate(context); + + expect(enforceLimitSpy).toHaveBeenNthCalledWith(1, limitLabels[0], user, { + itemId: 'item-1', + }); + expect(enforceLimitSpy).toHaveBeenNthCalledWith(2, limitLabels[1], user, { + itemId: 'item-1', + }); + }); + + it('When two or more limits are checked and one throws, it should propagate the error', async () => { + const limitLabels = [ + 'firstLabel' as LimitLabels, + 'secondLabel' as LimitLabels, + 'thirdLabel' as LimitLabels, + ]; + mockMetadata(reflector, { + limitLabels, + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }); + + jest + .spyOn(featureLimitUsecases, 'enforceLimit') + .mockResolvedValueOnce(false); + jest + .spyOn(featureLimitUsecases, 'enforceLimit') + .mockRejectedValueOnce(new PaymentRequiredException()); + + const context = createMockExecutionContext({ + user, + body: { itemId: 'item-1' }, + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + PaymentRequiredException, + ); + }); +}); + +const createMockExecutionContext = (requestData: any): ExecutionContext => { + return { + getHandler: () => ({ + name: 'endPointHandler', + }), + switchToHttp: () => ({ + getRequest: () => requestData, + }), + } as unknown as ExecutionContext; +}; + +const mockMetadata = (reflector: Reflector, metadata: ApplyLimitMetadata) => { + jest.spyOn(reflector, 'get').mockImplementation((key) => { + if (key === FEATURE_LIMIT_KEY) { + return metadata; + } + }); +}; From 745b422a6892b68676b5b5a59ec720449b5e2063 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 22 Feb 2024 06:30:09 -0400 Subject: [PATCH 21/22] chore: add test to new limit fixture --- test/fixtures.spec.ts | 34 ++++++++++++++++++++++++++++++++++ test/fixtures.ts | 4 ++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index aa465e6c5..7468946ba 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -1,3 +1,7 @@ +import { + LimitLabels, + LimitTypes, +} from '../src/modules/feature-limit/limits.enum'; import { FileStatus } from '../src/modules/file/file.domain'; import * as fixtures from './fixtures'; @@ -233,4 +237,34 @@ describe('Testing fixtures tests', () => { expect(mailLimit.attemptsLimit).toEqual(5); }); }); + + describe('Feature limit fixture', () => { + it('When it generates a new limit, then the identifier should be random', () => { + const limit = fixtures.newFeatureLimit(); + const otherLimit = fixtures.newFeatureLimit(); + + expect(limit.id).toBeTruthy(); + expect(otherLimit.id).not.toBe(limit.id); + }); + + it('When it generates a limit and a label is provided, then that label should be set', () => { + const limit = fixtures.newFeatureLimit({ + label: 'anyLabel' as LimitLabels, + type: LimitTypes.Boolean, + value: '0', + }); + + expect(limit.label).toEqual('anyLabel'); + }); + + it('When it generates a limit and a type and value are provided, then that those fields should be set', () => { + const limit = fixtures.newFeatureLimit({ + type: LimitTypes.Boolean, + value: '0', + }); + + expect(limit.type).toEqual(LimitTypes.Boolean); + expect(limit.value).toEqual('0'); + }); + }); }); diff --git a/test/fixtures.ts b/test/fixtures.ts index 1eef798ed..7e1b2d454 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -239,8 +239,8 @@ export const newFeatureLimit = (bindTo?: { }): Limit => { return Limit.build({ id: bindTo?.id ?? v4(), - type: bindTo.type, - value: bindTo.value, + type: bindTo?.type ?? LimitTypes.Counter, + value: bindTo?.value ?? '2', label: bindTo?.label ?? ('' as LimitLabels), }); }; From ac29d8fa6a9c11e232b7893608e60fa2ac8773e4 Mon Sep 17 00:00:00 2001 From: Andres Pinto Date: Thu, 22 Feb 2024 07:43:22 -0400 Subject: [PATCH 22/22] fix: allow null tier context field --- src/modules/feature-limit/models/tier.model.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/feature-limit/models/tier.model.ts b/src/modules/feature-limit/models/tier.model.ts index ec05dae0b..8d8a73d61 100644 --- a/src/modules/feature-limit/models/tier.model.ts +++ b/src/modules/feature-limit/models/tier.model.ts @@ -29,7 +29,6 @@ export class TierModel extends Model implements TierAttributes { @Column(DataType.STRING) label: string; - @AllowNull(false) @Column(DataType.STRING) context: string;