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/20240128230532-create-tiers-table.js b/migrations/20240128230532-create-tiers-table.js new file mode 100644 index 000000000..26a2f081c --- /dev/null +++ b/migrations/20240128230532-create-tiers-table.js @@ -0,0 +1,37 @@ +'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, + }, + 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..9ed07ea2b --- /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.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/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/migrations/20240220163114-create-paid-plans-tiers.js b/migrations/20240220163114-create-paid-plans-tiers.js new file mode 100644 index 000000000..5ca13e3b4 --- /dev/null +++ b/migrations/20240220163114-create-paid-plans-tiers.js @@ -0,0 +1,46 @@ +'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', + }, + }, + description: { + type: Sequelize.STRING, + }, + 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/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) + `); + }, +}; 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/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/externals/payments/payments.service.ts b/src/externals/payments/payments.service.ts index a25d89f12..592acc2cd 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 '../../middlewares/passport'; @Injectable() export class PaymentsService { @@ -10,7 +12,9 @@ export class PaymentsService { constructor( @Inject(ConfigService) - configService: ConfigService, + private configService: ConfigService, + @Inject(HttpClient) + private httpClient: HttpClient, ) { const stripeTest = new Stripe(process.env.STRIPE_SK_TEST, { apiVersion: '2023-10-16', @@ -48,4 +52,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/decorators/apply-limit.decorator.ts b/src/modules/feature-limit/decorators/apply-limit.decorator.ts new file mode 100644 index 000000000..884236a6e --- /dev/null +++ b/src/modules/feature-limit/decorators/apply-limit.decorator.ts @@ -0,0 +1,18 @@ +import { SetMetadata } from '@nestjs/common'; +import { LimitLabels } from '../limits.enum'; + +interface DataSource { + sourceKey: 'body' | 'params' | 'query' | 'headers'; + fieldName: string; +} + +export interface ApplyLimitMetadata { + limitLabels: LimitLabels[]; + dataSources?: DataSource[]; + context?: object; +} + +export const FEATURE_LIMIT_KEY = 'feature-limit'; + +export const ApplyLimit = (metadata: ApplyLimitMetadata) => + SetMetadata(FEATURE_LIMIT_KEY, metadata); 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..4cae9c2de --- /dev/null +++ b/src/modules/feature-limit/exceptions/payment-required.exception.ts @@ -0,0 +1,11 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class PaymentRequiredException extends HttpException { + constructor(message?: string) { + super( + 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-migration.service.ts b/src/modules/feature-limit/feature-limit-migration.service.ts new file mode 100644 index 000000000..39b730e57 --- /dev/null +++ b/src/modules/feature-limit/feature-limit-migration.service.ts @@ -0,0 +1,129 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { SequelizeUserRepository } from '../user/user.repository'; +import { SequelizeFeatureLimitsRepository } from './feature-limit.repository'; +import { AxiosError } from 'axios'; +import { User } from '../user/user.domain'; +import { PaymentsService } from 'src/externals/payments/payments.service'; +import { PLAN_FREE_TIER_ID } from './limits.enum'; + +@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 paymentsService: PaymentsService, + ) {} + + async asignTiersToUsers() { + const limit = 20; + let processed = 0; + let resultsCount = 0; + + await this.loadTiers(); + + while (resultsCount % limit === 0) { + const users = await this.userRepository.findAllByWithPagination( + { tierId: null }, + limit, + ); + + resultsCount = users.length; + + 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}`); + } + 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(PLAN_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) { + try { + const subscription = await this.paymentsService.getCurrentSubscription( + user.uuid, + ); + return subscription; + } catch (error) { + if (error instanceof AxiosError) { + if (error.response && error.response.status === 404) { + return; + } + + 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(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`, + ); + 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 new file mode 100644 index 000000000..215162962 --- /dev/null +++ b/src/modules/feature-limit/feature-limit.module.ts @@ -0,0 +1,44 @@ +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 { 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 { 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: [ + SequelizeModule.forFeature([ + TierModel, + Limitmodel, + TierLimitsModel, + TierLimitsModel, + PaidPlansModel, + ]), + HttpClientModule, + forwardRef(() => SharingModule), + forwardRef(() => UserModule), + ], + providers: [ + SequelizeFeatureLimitsRepository, + FeatureLimitUsecases, + FeatureLimit, + FeatureLimitsMigrationService, + ConfigModule, + PaymentsService, + ], + 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 new file mode 100644 index 000000000..62477f63b --- /dev/null +++ b/src/modules/feature-limit/feature-limit.repository.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { TierModel } from './models/tier.model'; +import { Limitmodel } from './models/limit.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(Limitmodel) + private limitModel: typeof Limitmodel, + @InjectModel(PaidPlansModel) + private paidPlansModel: typeof PaidPlansModel, + ) {} + + 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; + } + + 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/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' }, + }); + }); + }); +}); diff --git a/src/modules/feature-limit/feature-limit.usecase.ts b/src/modules/feature-limit/feature-limit.usecase.ts new file mode 100644 index 000000000..76a178b9c --- /dev/null +++ b/src/modules/feature-limit/feature-limit.usecase.ts @@ -0,0 +1,145 @@ +import { + Injectable, + InternalServerErrorException, + 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, + MaxSharedItemsAttribute, +} from './limits.attributes'; +import { PaymentRequiredException } from './exceptions/payment-required.exception'; + +@Injectable() +export class FeatureLimitUsecases { + constructor( + private readonly limitsRepository: SequelizeFeatureLimitsRepository, + private readonly sharingRepository: SequelizeSharingRepository, + ) {} + + private limitCheckFunctions: { + [K in LimitLabels]?: (params: { + limit: Limit; + data: LimitTypeMapping[K]; + user: User; + }) => Promise; + } = { + [LimitLabels.MaxSharedItems]: this.checkMaxSharedItemsLimit.bind(this), + [LimitLabels.MaxSharedItemInvites]: + this.checkMaxInviteesPerItemLimit.bind(this), + }; + + 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.limitCheckFunctions[limit.label as LimitLabels]; + + if (!checkFunction) { + new Logger().error( + `[FEATURE-LIMIT] Check counter function not defined for label: ${limit.label}.`, + ); + throw new InternalServerErrorException(); + } + return checkFunction({ limit, data, user }); + } + + async checkMaxSharedItemsLimit({ + limit, + user, + data, + }: { + limit: Limit; + user: User; + data: MaxSharedItemsAttribute; + }) { + const limitContext = { bypassLimit: false, currentCount: 0 }; + const alreadySharedItem = await this.sharingRepository.findOneSharingBy({ + itemId: data.itemId, + }); + + if (alreadySharedItem) { + limitContext.bypassLimit = true; + } else { + const sharingsCount = + await this.sharingRepository.getSharedItemsNumberByUser(user.uuid); + limitContext.currentCount = sharingsCount; + } + + return limit.shouldLimitBeEnforced(limitContext); + } + + 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({ + itemId, + itemType, + type: SharingType.Private, + }), + this.sharingRepository.getInvitesCountBy({ + itemId, + itemType, + }), + ]); + + // Add 1 to include owner in the limit count. + limitContext.currentCount = + sharingsCountForThisItem + invitesCountForThisItem + 1; + + return limit.shouldLimitBeEnforced(limitContext); + } + + async getLimitByLabelAndTier(label: string, tierId: string) { + return this.limitsRepository.findLimitByLabelAndTier(tierId, label); + } +} 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; + } + }); +}; 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..9e71bf0f5 --- /dev/null +++ b/src/modules/feature-limit/feature-limits.guard.ts @@ -0,0 +1,89 @@ +import { + BadRequestException, + CanActivate, + ExecutionContext, + Injectable, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { LimitLabels } from './limits.enum'; +import { FeatureLimitUsecases } from './feature-limit.usecase'; +import { + ApplyLimitMetadata, + 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 { + constructor( + private readonly reflector: Reflector, + private readonly featureLimitsUseCases: FeatureLimitUsecases, + ) {} + + 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( + `[FEATURE_LIMIT]: Missing metadata for feature limit guard! url: ${request.url} handler: ${handler.name}`, + ); + throw new BadRequestException(`Missing Metadata`); + } + + const { limitLabels, dataSources } = metadata; + + if (!limitLabels) { + return true; + } + + const extractedData = this.extractDataFromRequest(request, dataSources); + + await Promise.all( + limitLabels.map(async (limitLabel) => { + const shouldLimitBeEnforced = + await this.featureLimitsUseCases.enforceLimit( + limitLabel, + user, + extractedData as LimitTypeMapping[typeof limitLabel], + ); + + if (shouldLimitBeEnforced) { + throw new PaymentRequiredException(); + } + }), + ); + + return true; + } + + extractDataFromRequest( + request: any, + dataSources: ApplyLimitMetadata['dataSources'], + ) { + const extractedData = {}; + + 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! field: ${fieldName}`, + ); + throw new BadRequestException(`Missing required field: ${fieldName}`); + } + + extractedData[fieldName] = value; + } + + return extractedData; + } +} 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 new file mode 100644 index 000000000..daf98ea70 --- /dev/null +++ b/src/modules/feature-limit/limit.domain.ts @@ -0,0 +1,52 @@ +import { + LimitAttributes, + ShouldLimitBeEnforcedContext, +} from './limits.attributes'; +import { LimitTypes, LimitLabels } from './limits.enum'; + +export class Limit { + 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; + this.type = type; + this.value = value; + } + + static build(limitAttributes: LimitAttributes): Limit { + return new Limit(limitAttributes); + } + + isBooleanLimit() { + return this.type === LimitTypes.Boolean; + } + + private isFeatureEnabled() { + return this.isBooleanLimit() && this.value === 'true'; + } + + private isCounterLimitExceeded(currentCount: number) { + return ( + 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 new file mode 100644 index 000000000..97c7be986 --- /dev/null +++ b/src/modules/feature-limit/limits.attributes.ts @@ -0,0 +1,31 @@ +import { User } from '../user/user.domain'; +import { LimitLabels, LimitTypes } from './limits.enum'; + +export interface LimitAttributes { + id: string; + label: LimitLabels; + type: LimitTypes; + value: string; +} + +export interface ShouldLimitBeEnforcedContext { + bypassLimit?: boolean; + currentCount?: number; +} + +export interface MaxInviteesPerItemAttribute { + itemId: string; + itemType: 'folder' | 'file'; +} + +export interface MaxSharedItemsAttribute { + user: User; + itemId: string; + isPublicSharing: boolean; +} + +export interface LimitTypeMapping { + [LimitLabels.MaxSharedItemInvites]: MaxInviteesPerItemAttribute; + [LimitLabels.MaxSharedItems]: MaxSharedItemsAttribute; + [key: string]: any; +} diff --git a/src/modules/feature-limit/limits.enum.ts b/src/modules/feature-limit/limits.enum.ts new file mode 100644 index 000000000..7b9ac8625 --- /dev/null +++ b/src/modules/feature-limit/limits.enum.ts @@ -0,0 +1,11 @@ +export enum LimitLabels { + MaxSharedItems = 'max-shared-items', + MaxSharedItemInvites = 'max-shared-invites', +} + +export enum LimitTypes { + Boolean = 'boolean', + Counter = 'counter', +} + +export const PLAN_FREE_TIER_ID = 'free_000000'; 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..2705d7b0d --- /dev/null +++ b/src/modules/feature-limit/models/limit.model.ts @@ -0,0 +1,50 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + BelongsToMany, + AllowNull, +} 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.STRING) + value: string; + + @BelongsToMany(() => TierModel, { + through: () => TierLimitsModel, + }) + tiers: TierModel[]; + + @Column + createdAt: Date; + + @Column + updatedAt: Date; +} 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..0df44134d --- /dev/null +++ b/src/modules/feature-limit/models/paid-plans.model.ts @@ -0,0 +1,43 @@ +import { + Column, + Model, + Table, + PrimaryKey, + DataType, + AllowNull, + AutoIncrement, + ForeignKey, + BelongsTo, +} 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) + @AllowNull(false) + @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/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..8d8a73d61 --- /dev/null +++ b/src/modules/feature-limit/models/tier.model.ts @@ -0,0 +1,40 @@ +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; + + @Column(DataType.STRING) + context: string; + + @Column + createdAt: Date; + + @Column + updatedAt: 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/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 222b43a87..6a42f0cfa 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,14 @@ export class SharingController { } @Post('/invites/send') + /* @ApplyLimit({ + limitLabels: [LimitLabels.MaxSharedItemInvites, LimitLabels.MaxSharedItems], + dataSources: [ + { sourceKey: 'body', fieldName: 'itemId' }, + { sourceKey: 'body', fieldName: 'itemType' }, + ], + }) + @UseGuards(FeatureLimit) */ createInvite( @UserDecorator() user: User, @Body() createInviteDto: CreateInviteDto, @@ -591,6 +602,11 @@ export class SharingController { } @Post('/') + /* @ApplyLimit({ + limitLabels: [LimitLabels.MaxSharedItems], + dataSources: [{ sourceKey: 'body', fieldName: 'itemId' }], + }) + @UseGuards(FeatureLimit) */ createSharing( @UserDecorator() user, @Body() acceptInviteDto: CreateSharingDto, diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index 67846176d..fe31c0ac6 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -20,6 +20,8 @@ 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'; +import { HttpClientModule } from 'src/externals/http/http.module'; @Module({ imports: [ @@ -36,6 +38,8 @@ import { AppSumoModule } from '../app-sumo/app-sumo.module'; BridgeModule, AppSumoModule, forwardRef(() => UserModule), + forwardRef(() => FeatureLimitModule), + HttpClientModule, ], controllers: [SharingController], providers: [ @@ -44,6 +48,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..35be43e4b 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 sharingsCount = await this.sharings.count({ + where: { ownerId: userUuid }, + distinct: true, + col: 'itemId', + }); + + return sharingsCount; + } + async createSharing(sharing: Omit): Promise { const raw = await this.sharings.create(sharing); 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; } 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.repository.ts b/src/modules/user/user.repository.ts index 9cd0e2421..f6f71f162 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: { 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 { 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 698e2b9a2..7e1b2d454 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 ?? LimitTypes.Counter, + value: bindTo?.value ?? '2', + label: bindTo?.label ?? ('' as LimitLabels), + }); +};