From c087e8d8cfbbc0bbf0059e28973c0b8beb24bd71 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:32:05 +0100 Subject: [PATCH 1/6] feat(gateway): activate drive features from gateway --- src/controller/gateway.controller.ts | 16 ++- src/core/users/MongoDBTiersRepository.ts | 6 ++ src/core/users/Tier.ts | 3 + src/server.ts | 6 +- src/services/products.service.ts | 81 +++++--------- src/services/tiers.service.ts | 22 +++- src/services/userFeaturesOverride.service.ts | 47 +++++++- src/services/users.service.ts | 9 +- .../src/controller/gateway.controller.test.ts | 2 +- .../core/users/MongoDBTiersRepository.test.ts | 13 +++ tests/src/fixtures.ts | 50 ++++++--- tests/src/helpers/services-factory.ts | 1 + tests/src/services/products.service.test.ts | 2 + tests/src/services/tiers.service.test.ts | 56 ++++++++++ .../userFeaturesOverride.service.test.ts | 101 +++++++++++++++++- tests/src/utils/factory.ts | 2 +- 16 files changed, 336 insertions(+), 81 deletions(-) diff --git a/src/controller/gateway.controller.ts b/src/controller/gateway.controller.ts index c977d391..c45fa9ce 100644 --- a/src/controller/gateway.controller.ts +++ b/src/controller/gateway.controller.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { AppConfig } from '../config'; -import { UsersService } from '../services/users.service'; +import { OverrideDriveFeatureAvailable, UsersService } from '../services/users.service'; import { NotFoundError } from '../errors/Errors'; import Logger from '../Logger'; import CacheService from '../services/cache.service'; @@ -35,7 +35,7 @@ export function gatewayController({ }, }); - fastify.post<{ Body: { feature: Service; userUuid: string } }>( + fastify.post<{ Body: { feature: Service; userUuid: string; subFeature?: string } }>( '/activate', { schema: { @@ -50,13 +50,17 @@ export function gatewayController({ userUuid: { type: 'string', }, + subFeature: { + type: 'string', + enum: ['fileVersioning', 'passwordProtectedSharing', 'restrictedItemsSharing'], + }, }, }, }, }, async (request, response) => { let user: User; - const { feature, userUuid } = request.body; + const { feature, userUuid, subFeature } = request.body; try { user = await usersService.findUserByUuid(userUuid); @@ -65,7 +69,11 @@ export function gatewayController({ throw new NotFoundError(`User with uuid ${userUuid} was not found`); } - await userFeaturesOverridesService.upsertCustomUserFeatures(user, feature); + await userFeaturesOverridesService.upsertCustomUserFeatures( + user, + feature, + subFeature as OverrideDriveFeatureAvailable, + ); await cacheService.clearUserTier(userUuid); return response.status(204).send(); diff --git a/src/core/users/MongoDBTiersRepository.ts b/src/core/users/MongoDBTiersRepository.ts index 965d52ef..40fd6c86 100644 --- a/src/core/users/MongoDBTiersRepository.ts +++ b/src/core/users/MongoDBTiersRepository.ts @@ -4,6 +4,7 @@ import { Tier } from './Tier'; export interface TiersRepository { findByProductId(where: Partial): Promise; findByTierId(tierId: Tier['id']): Promise; + getAll(): Promise; } function toDomain(tier: WithId>): Tier { @@ -22,6 +23,11 @@ export class MongoDBTiersRepository implements TiersRepository { this.collection = mongo.db('payments').collection('tiers'); } + async getAll(): Promise { + const tiers = await this.collection.find().toArray(); + return tiers.map(toDomain); + } + async findByProductId(where: Partial): Promise { const tier = await this.collection.findOne(where); return tier ? toDomain(tier) : null; diff --git a/src/core/users/Tier.ts b/src/core/users/Tier.ts index 7e593410..ff6e7502 100644 --- a/src/core/users/Tier.ts +++ b/src/core/users/Tier.ts @@ -22,6 +22,9 @@ export interface DriveFeatures { restrictedItemsSharing: { enabled: boolean; }; + fileVersioning: { + enabled: boolean; + }; } interface MeetFeatures { diff --git a/src/server.ts b/src/server.ts index 5cbef7df..004a5585 100644 --- a/src/server.ts +++ b/src/server.ts @@ -72,7 +72,11 @@ const start = async (mongoTestClient?: MongoClient): Promise => licenseCodesRepository, }); const objectStorageService = new ObjectStorageService(paymentService, envVariablesConfig, axios); - const userFeaturesOverridesService = new UserFeaturesOverridesService(usersService, userFeatureOverridesRepository); + const userFeaturesOverridesService = new UserFeaturesOverridesService( + usersService, + userFeatureOverridesRepository, + tiersService, + ); const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); const healthService = new HealthService(mongoClient, cacheService); diff --git a/src/services/products.service.ts b/src/services/products.service.ts index 48c8b22f..b419c855 100644 --- a/src/services/products.service.ts +++ b/src/services/products.service.ts @@ -107,76 +107,49 @@ export class ProductsService { private mergeTiers(individualTier: Tier, businessTier: Tier): Tier { const individualEnabledCount = this.countEnabledProducts(individualTier); const businessEnabledCount = this.countEnabledProducts(businessTier); - const tierWithMostProducts = businessEnabledCount > individualEnabledCount ? businessTier : individualTier; + const individual = individualTier.featuresPerService; + const business = businessTier.featuresPerService; + + const baseMerge = Object.values(Service).reduce( + (acc, service) => { + acc[service] = { + ...individual[service], + ...business[service], + enabled: individual[service].enabled || business[service].enabled, + }; + return acc; + }, + {} as Record, + ); + const mergedFeatures = { + ...baseMerge, [Service.Drive]: { - enabled: - individualTier.featuresPerService[Service.Drive].enabled || - businessTier.featuresPerService[Service.Drive].enabled, - maxSpaceBytes: individualTier.featuresPerService[Service.Drive].maxSpaceBytes, - workspaces: businessTier.featuresPerService[Service.Drive].workspaces, + ...baseMerge[Service.Drive], + maxSpaceBytes: individual[Service.Drive].maxSpaceBytes, + workspaces: business[Service.Drive].workspaces, passwordProtectedSharing: { enabled: - individualTier.featuresPerService[Service.Drive].passwordProtectedSharing.enabled || - businessTier.featuresPerService[Service.Drive].passwordProtectedSharing.enabled, + individual[Service.Drive].passwordProtectedSharing.enabled || + business[Service.Drive].passwordProtectedSharing.enabled, }, restrictedItemsSharing: { enabled: - individualTier.featuresPerService[Service.Drive].restrictedItemsSharing.enabled || - businessTier.featuresPerService[Service.Drive].restrictedItemsSharing.enabled, + individual[Service.Drive].restrictedItemsSharing.enabled || + business[Service.Drive].restrictedItemsSharing.enabled, }, }, - [Service.Backups]: { - enabled: - individualTier.featuresPerService[Service.Backups].enabled || - businessTier.featuresPerService[Service.Backups].enabled, - }, - [Service.Antivirus]: { - enabled: - individualTier.featuresPerService[Service.Antivirus].enabled || - businessTier.featuresPerService[Service.Antivirus].enabled, - }, [Service.Meet]: { - enabled: - individualTier.featuresPerService[Service.Meet].enabled || - businessTier.featuresPerService[Service.Meet].enabled, - paxPerCall: Math.max( - individualTier.featuresPerService[Service.Meet].paxPerCall, - businessTier.featuresPerService[Service.Meet].paxPerCall, - ), + ...baseMerge[Service.Meet], + paxPerCall: Math.max(individual[Service.Meet].paxPerCall, business[Service.Meet].paxPerCall), }, [Service.Mail]: { - enabled: - individualTier.featuresPerService[Service.Mail].enabled || - businessTier.featuresPerService[Service.Mail].enabled, - addressesPerUser: Math.max( - individualTier.featuresPerService[Service.Mail].addressesPerUser, - businessTier.featuresPerService[Service.Mail].addressesPerUser, - ), + ...baseMerge[Service.Mail], + addressesPerUser: Math.max(individual[Service.Mail].addressesPerUser, business[Service.Mail].addressesPerUser), }, [Service.Vpn]: tierWithMostProducts.featuresPerService[Service.Vpn], - [Service.Cleaner]: { - enabled: - individualTier.featuresPerService[Service.Cleaner].enabled || - businessTier.featuresPerService[Service.Cleaner].enabled, - }, - [Service.darkMonitor]: { - enabled: - individualTier.featuresPerService[Service.darkMonitor].enabled || - businessTier.featuresPerService[Service.darkMonitor].enabled, - }, - [Service.Cli]: { - enabled: - individualTier.featuresPerService[Service.Cli].enabled || - businessTier.featuresPerService[Service.Cli].enabled, - }, - [Service.rClone]: { - enabled: - individualTier.featuresPerService[Service.rClone].enabled || - businessTier.featuresPerService[Service.rClone].enabled, - }, }; return { diff --git a/src/services/tiers.service.ts b/src/services/tiers.service.ts index 982e82a6..1ea06c28 100644 --- a/src/services/tiers.service.ts +++ b/src/services/tiers.service.ts @@ -2,8 +2,9 @@ import { TiersRepository } from '../core/users/MongoDBTiersRepository'; import { User } from '../core/users/User'; import { UsersService } from './users.service'; import { StorageService } from './storage.service'; -import { Service, Tier } from '../core/users/Tier'; +import { DriveFeatures, Service, Tier } from '../core/users/Tier'; import { UsersTiersRepository } from '../core/users/MongoDBUsersTiersRepository'; + import Stripe from 'stripe'; import { FastifyBaseLogger } from 'fastify'; import axios, { isAxiosError } from 'axios'; @@ -63,6 +64,25 @@ export class TiersService { } } + async getMinimumTierWithFeatureAvailable( + feature: Service, + driveSubFeature?: keyof Pick< + DriveFeatures, + 'fileVersioning' | 'passwordProtectedSharing' | 'restrictedItemsSharing' + >, + ): Promise { + const tiers = await this.tiersRepository.getAll(); + + const individualTiers = tiers.filter((tier) => !tier.featuresPerService[Service.Drive].workspaces.enabled); + const minimumTierWithFeatureEnabled = individualTiers.find((tier) => tier.featuresPerService[feature].enabled); + + if (driveSubFeature) { + return individualTiers.find((tier) => tier.featuresPerService[Service.Drive][driveSubFeature].enabled); + } + + return minimumTierWithFeatureEnabled; + } + async getTiersProductsByUserId(userId: User['id']): Promise { const userTiers = await this.usersTiersRepository.findTierIdByUserId(userId); diff --git a/src/services/userFeaturesOverride.service.ts b/src/services/userFeaturesOverride.service.ts index c0959703..c7ecf604 100644 --- a/src/services/userFeaturesOverride.service.ts +++ b/src/services/userFeaturesOverride.service.ts @@ -1,17 +1,56 @@ import { UserFeatureOverridesRepository } from '../core/users/MongoDBUserFeatureOverridesRepository'; -import { Service } from '../core/users/Tier'; +import { DriveFeatures, Service } from '../core/users/Tier'; import { User } from '../core/users/User'; import { UserFeatureOverrides } from '../core/users/UserFeatureOverrides'; import { BadRequestError } from '../errors/Errors'; +import { TiersService } from './tiers.service'; import { UsersService } from './users.service'; export class UserFeaturesOverridesService { constructor( private readonly usersService: UsersService, private readonly userFeatureOverridesRepository: UserFeatureOverridesRepository, + private readonly tiersService: TiersService, ) {} - async upsertCustomUserFeatures(user: User, service: Service) { + private async overrideDriveFeatures( + userUuid: string, + userId: string, + driveFeature?: keyof DriveFeatures, + ): Promise { + if (!driveFeature) { + throw new BadRequestError('A Drive feature must be provided to override'); + } + if (driveFeature === 'fileVersioning') { + const minimumTier = await this.tiersService.getMinimumTierWithFeatureAvailable(Service.Drive, driveFeature); + + if (!minimumTier) { + throw new BadRequestError('No minimum tier found for file versioning'); + } + + const tierId = minimumTier?.featuresPerService[Service.Drive].foreignTierId; + await this.usersService.overrideDriveLimit({ + userUuid, + feature: driveFeature, + enabled: true, + driveTierId: tierId, + }); + } + + await this.userFeatureOverridesRepository.upsert({ + userId: userId, + featuresPerService: { + [Service.Drive]: { + enabled: true, + [driveFeature]: { + enabled: true, + }, + }, + }, + }); + } + + async upsertCustomUserFeatures(user: User, service: Service, driveFeature?: keyof DriveFeatures): Promise { const { id: userId, uuid: userUuid } = user; const overrideUserFeatures = await this.userFeatureOverridesRepository.findByUserId(userId); @@ -33,6 +72,10 @@ export class UserFeaturesOverridesService { }); break; + case Service.Drive: + await this.overrideDriveFeatures(userUuid, userId, driveFeature); + break; + case Service.Cli: case Service.rClone: await this.usersService.overrideDriveLimit({ userUuid, feature: service, enabled: true }); diff --git a/src/services/users.service.ts b/src/services/users.service.ts index ddec4ec5..89e16845 100644 --- a/src/services/users.service.ts +++ b/src/services/users.service.ts @@ -9,7 +9,7 @@ import { UsersCouponsRepository } from '../core/coupons/UsersCouponsRepository'; import { sign } from 'jsonwebtoken'; import { Axios, AxiosRequestConfig } from 'axios'; import { isProduction, type AppConfig } from '../config'; -import { Service, VpnFeatures } from '../core/users/Tier'; +import { DriveFeatures, Service, Tier, VpnFeatures } from '../core/users/Tier'; import { UserNotFoundError } from '../errors/PaymentErrors'; import { CouponNotBeingTrackedError } from '../errors/UsersErrors'; @@ -21,7 +21,8 @@ function signToken(duration: string, secret: string) { }); } -type OverrideServiceAvailable = Service.Cli | Service.rClone; +export type OverrideDriveFeatureAvailable = keyof Pick; +type OverrideServiceAvailable = Service.Cli | Service.rClone | Service.Drive | OverrideDriveFeatureAvailable; export class UsersService { constructor( @@ -327,10 +328,12 @@ export class UsersService { userUuid, feature, enabled, + driveTierId, }: { userUuid: User['uuid']; feature: OverrideServiceAvailable; enabled: boolean; + driveTierId?: Tier['featuresPerService']['drive']['foreignTierId']; }): Promise { const jwt = signToken('5m', this.config.DRIVE_NEW_GATEWAY_SECRET); @@ -344,7 +347,7 @@ export class UsersService { await this.axios.put( `${this.config.DRIVE_NEW_GATEWAY_URL}/gateway/users/${userUuid}/limits/overrides`, - { feature, value: String(enabled) }, + { feature, value: String(enabled), tierId: driveTierId }, requestConfig, ); diff --git a/tests/src/controller/gateway.controller.test.ts b/tests/src/controller/gateway.controller.test.ts index b96c0a7e..d01f43fa 100644 --- a/tests/src/controller/gateway.controller.test.ts +++ b/tests/src/controller/gateway.controller.test.ts @@ -50,7 +50,7 @@ describe('Gateway endpoints', () => { expect(response.statusCode).toBe(204); expect(userSpy).toHaveBeenCalledWith(userUuid); - expect(upsertCustomerUserFeatures).toHaveBeenCalledWith(mockedUser, feature); + expect(upsertCustomerUserFeatures).toHaveBeenCalledWith(mockedUser, feature, undefined); expect(clearUserTierSpy).toHaveBeenCalledWith(userUuid); }); diff --git a/tests/src/core/users/MongoDBTiersRepository.test.ts b/tests/src/core/users/MongoDBTiersRepository.test.ts index 2b7fc40e..0b165e4c 100644 --- a/tests/src/core/users/MongoDBTiersRepository.test.ts +++ b/tests/src/core/users/MongoDBTiersRepository.test.ts @@ -85,4 +85,17 @@ describe('Testing the tier collection', () => { expect(foundTier?.label).toBe(mockTier.label); expect(foundTier?.featuresPerService).toStrictEqual(mockTier.featuresPerService); }); + + test('When getting all tiers, then they are returned', async () => { + const collection = (repository as any).collection; + const { id: _, ...mockedTierWithoutId } = newTier(); + const insertResult = await collection.insertOne({ ...mockedTierWithoutId }); + const allTiers = await repository.getAll(); + + expect(allTiers.length).toBe(1); + expect(allTiers[0]).toStrictEqual({ + id: insertResult.insertedId.toString(), + ...mockedTierWithoutId, + }); + }); }); diff --git a/tests/src/fixtures.ts b/tests/src/fixtures.ts index f2b1d31d..12dbbecb 100644 --- a/tests/src/fixtures.ts +++ b/tests/src/fixtures.ts @@ -203,7 +203,7 @@ export const getTaxes = (params?: Partial): Stripe.Tax.C }; export const getPromoCode = (params?: DeepPartial): Stripe.PromotionCode => { - return { + const defaultPromoCode: Stripe.PromotionCode = { ...PROMOTION_CODE_BASE, id: `promo_${randomDataGenerator.string({ length: 22 })}`, active: true, @@ -214,8 +214,9 @@ export const getPromoCode = (params?: DeepPartial): Stripe currency: null, }, expires_at: null, - ...(params as any), }; + + return params ? deepMerge(defaultPromoCode, params) : defaultPromoCode; }; export const priceById = ({ @@ -614,8 +615,33 @@ export const getCoupon = (params?: Partial): Coupon => ({ ...params, }); -export const newTier = (params?: Partial): Tier => { - return { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const deepMerge = (target: T, source: DeepPartial): T => { + const result = { ...target } as any; + + for (const key in source) { + const sourceValue = source[key]; + const targetValue = (target as any)[key]; + + if ( + sourceValue !== null && + typeof sourceValue === 'object' && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === 'object' && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge(targetValue, sourceValue); + } else if (sourceValue !== undefined) { + result[key] = sourceValue; + } + } + + return result as T; +}; + +export const newTier = (params?: DeepPartial): Tier => { + const defaultTier: Tier = { id: randomDataGenerator.string({ length: 10 }), billingType: 'subscription', label: 'test-label', @@ -641,13 +667,15 @@ export const newTier = (params?: Partial): Tier => { }, passwordProtectedSharing: { enabled: false }, restrictedItemsSharing: { enabled: false }, + fileVersioning: { enabled: false }, }, darkMonitor: { enabled: false, }, }, - ...params, }; + + return params ? deepMerge(defaultTier, params) : defaultTier; }; export const getLogger = (): jest.Mocked => { @@ -751,13 +779,13 @@ export const getCryptoCurrency = (params?: Partial): Currency => ({ }); export const getInvoice = ( - params?: DeepPartial>, + params?: DeepPartial, userType = UserType.Individual, productId?: string, ): Stripe.Invoice => { const generatedProductId = productId ?? `prod_${randomDataGenerator.string({ length: 12 })}`; - return { + const defaultInvoice: Stripe.Invoice = { ...INVOICE_BASE, id: `in_${randomDataGenerator.string({ length: 14, alpha: true, numeric: true, symbols: false })}`, customer: `cus_${randomDataGenerator.string({ length: 20 })}`, @@ -789,15 +817,13 @@ export const getInvoice = ( }, ], }, - ...(params as any), }; + + return params ? deepMerge(defaultInvoice, params) : defaultInvoice; }; export function getInvoices(count = 2, paramsArray: DeepPartial[] = []): Stripe.Invoice[] { - return Array.from({ length: count }, (_, index) => ({ - ...getInvoice(), - ...(paramsArray[index] as any), - })); + return Array.from({ length: count }, (_, index) => getInvoice(paramsArray[index])); } export function getUniqueCodes() { diff --git a/tests/src/helpers/services-factory.ts b/tests/src/helpers/services-factory.ts index 7c0f0f1b..b98f96b9 100644 --- a/tests/src/helpers/services-factory.ts +++ b/tests/src/helpers/services-factory.ts @@ -115,6 +115,7 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe const userFeaturesOverridesService = new UserFeaturesOverridesService( usersService, repositories.userFeatureOverridesRepository, + tiersService, ); const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); diff --git a/tests/src/services/products.service.test.ts b/tests/src/services/products.service.test.ts index 3bd3674e..ad5fe3e3 100644 --- a/tests/src/services/products.service.test.ts +++ b/tests/src/services/products.service.test.ts @@ -183,9 +183,11 @@ describe('Products Service Tests', () => { drive: { enabled: true, maxSpaceBytes: 1000, + foreignTierId: businessTier.featuresPerService[Service.Drive].foreignTierId, workspaces: businessTier.featuresPerService[Service.Drive].workspaces, passwordProtectedSharing: { enabled: true }, restrictedItemsSharing: { enabled: true }, + fileVersioning: { enabled: false }, }, mail: { enabled: true, diff --git a/tests/src/services/tiers.service.test.ts b/tests/src/services/tiers.service.test.ts index e554d104..651320ad 100644 --- a/tests/src/services/tiers.service.test.ts +++ b/tests/src/services/tiers.service.test.ts @@ -73,6 +73,62 @@ describe('TiersService tests', () => { }); }); + describe('Get minimum tier with feature available', () => { + describe('Given feature', () => { + test('When there is no minimum tier, then it returns nothing', async () => { + const mockedTier = newTier(); + + jest.spyOn(tiersRepository, 'getAll').mockResolvedValue([mockedTier]); + const result = await tiersService.getMinimumTierWithFeatureAvailable(Service.rClone); + + expect(result).toBeUndefined(); + }); + + test('When there is a minimum tier, then it returns the minimum tier', async () => { + const mockedTier = newTier({ + featuresPerService: { + rclone: { + enabled: true, + }, + }, + }); + + jest.spyOn(tiersRepository, 'getAll').mockResolvedValue([mockedTier]); + const result = await tiersService.getMinimumTierWithFeatureAvailable(Service.rClone); + + expect(result).toStrictEqual(mockedTier); + }); + }); + + describe('Given a feature and sub feature', () => { + test('When there is no minimum tier, then it returns nothing', async () => { + const mockedTier = newTier(); + + jest.spyOn(tiersRepository, 'getAll').mockResolvedValue([mockedTier]); + const result = await tiersService.getMinimumTierWithFeatureAvailable(Service.Drive, 'fileVersioning'); + + expect(result).toBeUndefined(); + }); + + test('When there is a minimum tier, then it returns the minimum tier', async () => { + const mockedTier = newTier({ + featuresPerService: { + drive: { + fileVersioning: { + enabled: true, + }, + }, + }, + }); + + jest.spyOn(tiersRepository, 'getAll').mockResolvedValue([mockedTier]); + const result = await tiersService.getMinimumTierWithFeatureAvailable(Service.Drive, 'fileVersioning'); + + expect(result).toStrictEqual(mockedTier); + }); + }); + }); + describe('Get the tier products using the user Id', () => { it('When the user has no assigned tiers, then an error indicating so is thrown', async () => { const { id: userId } = getUser(); diff --git a/tests/src/services/userFeaturesOverride.service.test.ts b/tests/src/services/userFeaturesOverride.service.test.ts index 42900010..60b6c9eb 100644 --- a/tests/src/services/userFeaturesOverride.service.test.ts +++ b/tests/src/services/userFeaturesOverride.service.test.ts @@ -1,9 +1,10 @@ import { Service } from '../../../src/core/users/Tier'; import { BadRequestError } from '../../../src/errors/Errors'; -import { getUser } from '../fixtures'; +import { getUser, newTier } from '../fixtures'; import { createTestServices } from '../helpers/services-factory'; -const { usersService, userFeaturesOverridesService, userFeatureOverridesRepository } = createTestServices(); +const { usersService, userFeaturesOverridesService, userFeatureOverridesRepository, tiersService } = + createTestServices(); describe('User Tier Override', () => { beforeEach(() => { jest.clearAllMocks(); @@ -157,6 +158,102 @@ describe('User Tier Override', () => { enabled: true, }); }); + + describe('Drive Service', () => { + test('When overriding file versioning, then it fetches the minimum tier and overrides the drive limit', async () => { + const mockedUser = getUser(); + const mockedTier = newTier({ + featuresPerService: { + drive: { + fileVersioning: { enabled: true }, + }, + }, + }); + + const findByUserIdSpy = jest.spyOn(userFeatureOverridesRepository, 'findByUserId').mockResolvedValue(null); + const getMinimumTierSpy = jest + .spyOn(tiersService, 'getMinimumTierWithFeatureAvailable') + .mockResolvedValue(mockedTier); + const overrideDriveLimitSpy = jest.spyOn(usersService, 'overrideDriveLimit').mockResolvedValue(); + const upsertSpy = jest.spyOn(userFeatureOverridesRepository, 'upsert').mockResolvedValue(); + + await userFeaturesOverridesService.upsertCustomUserFeatures(mockedUser, Service.Drive, 'fileVersioning'); + + expect(findByUserIdSpy).toHaveBeenCalledWith(mockedUser.id); + expect(getMinimumTierSpy).toHaveBeenCalledWith(Service.Drive, 'fileVersioning'); + expect(overrideDriveLimitSpy).toHaveBeenCalledWith({ + userUuid: mockedUser.uuid, + feature: 'fileVersioning', + enabled: true, + driveTierId: mockedTier.featuresPerService[Service.Drive].foreignTierId, + }); + expect(upsertSpy).toHaveBeenCalledWith({ + userId: mockedUser.id, + featuresPerService: { + [Service.Drive]: { + enabled: true, + fileVersioning: { enabled: true }, + }, + }, + }); + }); + + test('When overriding file versioning and no minimum tier is found, then an error indicating so is thrown', async () => { + const mockedUser = getUser(); + + const findByUserIdSpy = jest.spyOn(userFeatureOverridesRepository, 'findByUserId').mockResolvedValue(null); + const getMinimumTierSpy = jest + .spyOn(tiersService, 'getMinimumTierWithFeatureAvailable') + .mockResolvedValue(undefined); + + await expect( + userFeaturesOverridesService.upsertCustomUserFeatures(mockedUser, Service.Drive, 'fileVersioning'), + ).rejects.toThrow(BadRequestError); + + expect(findByUserIdSpy).toHaveBeenCalledWith(mockedUser.id); + expect(getMinimumTierSpy).toHaveBeenCalledWith(Service.Drive, 'fileVersioning'); + }); + + test('When overriding another feature than file versioning, then it upsert the feature directly', async () => { + const mockedUser = getUser(); + + const findByUserIdSpy = jest.spyOn(userFeatureOverridesRepository, 'findByUserId').mockResolvedValue(null); + const getMinimumTierSpy = jest.spyOn(tiersService, 'getMinimumTierWithFeatureAvailable'); + const overrideDriveLimitSpy = jest.spyOn(usersService, 'overrideDriveLimit'); + const upsertSpy = jest.spyOn(userFeatureOverridesRepository, 'upsert').mockResolvedValue(); + + await userFeaturesOverridesService.upsertCustomUserFeatures( + mockedUser, + Service.Drive, + 'passwordProtectedSharing', + ); + + expect(findByUserIdSpy).toHaveBeenCalledWith(mockedUser.id); + expect(getMinimumTierSpy).not.toHaveBeenCalled(); + expect(overrideDriveLimitSpy).not.toHaveBeenCalled(); + expect(upsertSpy).toHaveBeenCalledWith({ + userId: mockedUser.id, + featuresPerService: { + [Service.Drive]: { + enabled: true, + passwordProtectedSharing: { enabled: true }, + }, + }, + }); + }); + + test('When there is no drive feature, then an error indicating so is thrown', async () => { + const mockedUser = getUser(); + + const findByUserIdSpy = jest.spyOn(userFeatureOverridesRepository, 'findByUserId').mockResolvedValue(null); + + await expect(userFeaturesOverridesService.upsertCustomUserFeatures(mockedUser, Service.Drive)).rejects.toThrow( + BadRequestError, + ); + + expect(findByUserIdSpy).toHaveBeenCalledWith(mockedUser.id); + }); + }); }); describe('Get the custom user features', () => { diff --git a/tests/src/utils/factory.ts b/tests/src/utils/factory.ts index 8fe75a8b..e5ea75cd 100644 --- a/tests/src/utils/factory.ts +++ b/tests/src/utils/factory.ts @@ -20,7 +20,7 @@ const getUsersRepositoryForTest = (): UsersRepository => { }; const getTiersRepository = (): TiersRepository => { - return { findByProductId: jest.fn(), findByTierId: jest.fn() } as TiersRepository; + return { findByProductId: jest.fn(), findByTierId: jest.fn(), getAll: jest.fn() } as TiersRepository; }; const getUsersTiersRepository = (): UsersTiersRepository => { From b6649c24b45e850d46769d3c34bf78498e004ba8 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:35:46 +0100 Subject: [PATCH 2/6] fix(tiers): sort by max space bytes --- src/services/tiers.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/tiers.service.ts b/src/services/tiers.service.ts index 1ea06c28..f62f25f0 100644 --- a/src/services/tiers.service.ts +++ b/src/services/tiers.service.ts @@ -73,7 +73,11 @@ export class TiersService { ): Promise { const tiers = await this.tiersRepository.getAll(); - const individualTiers = tiers.filter((tier) => !tier.featuresPerService[Service.Drive].workspaces.enabled); + const individualTiers = tiers + .filter((tier) => !tier.featuresPerService[Service.Drive].workspaces.enabled) + .sort( + (a, b) => a.featuresPerService[Service.Drive].maxSpaceBytes - b.featuresPerService[Service.Drive].maxSpaceBytes, + ); const minimumTierWithFeatureEnabled = individualTiers.find((tier) => tier.featuresPerService[feature].enabled); if (driveSubFeature) { From e917e5f86a6616641c80f9b9c4e9e8bba3c565b5 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:00:25 +0100 Subject: [PATCH 3/6] chore: add file versioning to mongo-init script --- infrastructure/mongodb/mongo-init.js | 27 +++++++++++++++++++++++++++ src/controller/gateway.controller.ts | 3 ++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/infrastructure/mongodb/mongo-init.js b/infrastructure/mongodb/mongo-init.js index 08cb576e..107f190a 100644 --- a/infrastructure/mongodb/mongo-init.js +++ b/infrastructure/mongodb/mongo-init.js @@ -57,6 +57,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: false, }, + fileVersioning: { + enabled: false, + }, }, meet: { enabled: false, @@ -111,6 +114,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: false, }, + fileVersioning: { + enabled: false, + }, }, meet: { enabled: false, @@ -164,6 +170,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: true, }, + fileVersioning: { + enabled: true, + }, }, meet: { enabled: false, @@ -218,6 +227,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: true, }, + fileVersioning: { + enabled: true, + }, }, meet: { enabled: true, @@ -272,6 +284,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: true, }, + fileVersioning: { + enabled: true, + }, }, meet: { enabled: false, @@ -326,6 +341,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: false, }, + fileVersioning: { + enabled: false, + }, }, meet: { enabled: false, @@ -380,6 +398,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: true, }, + fileVersioning: { + enabled: true, + }, }, meet: { enabled: false, @@ -434,6 +455,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: true, }, + fileVersioning: { + enabled: true, + }, }, meet: { enabled: true, @@ -488,6 +512,9 @@ db.tiers.insertMany([ restrictedItemsSharing: { enabled: true, }, + fileVersioning: { + enabled: true, + }, }, meet: { enabled: true, diff --git a/src/controller/gateway.controller.ts b/src/controller/gateway.controller.ts index c45fa9ce..3b0ccd2c 100644 --- a/src/controller/gateway.controller.ts +++ b/src/controller/gateway.controller.ts @@ -9,6 +9,7 @@ import { User } from '../core/users/User'; import { UserFeaturesOverridesService } from '../services/userFeaturesOverride.service'; import { setupAuth } from '../plugins/auth'; import { LicenseCodeAlreadyAppliedError, LicenseCodesService } from '../services/licenseCodes.service'; +import jwt from 'jsonwebtoken'; interface GatewayControllerPayload { cacheService: CacheService; @@ -45,7 +46,7 @@ export function gatewayController({ properties: { feature: { type: 'string', - enum: [Service.Antivirus, Service.Backups, Service.Cleaner, Service.Cli, Service.rClone], + enum: [Service.Antivirus, Service.Backups, Service.Cleaner, Service.Cli, Service.rClone, Service.Drive], }, userUuid: { type: 'string', From 518f2ececb06e84e83e0ada9e290c8da7dfdf453 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:53:44 +0100 Subject: [PATCH 4/6] fix: coexist already drive overrided features --- src/controller/gateway.controller.ts | 17 ++++------ .../MongoDBUserFeatureOverridesRepository.ts | 11 +++++- src/core/users/UserFeatureOverrides.ts | 21 +++++++++--- src/services/userFeaturesOverride.service.ts | 4 +-- ...goDBUserFeatureOverridesRepository.test.ts | 34 +++++++++++++++++++ 5 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/controller/gateway.controller.ts b/src/controller/gateway.controller.ts index 3b0ccd2c..da09d044 100644 --- a/src/controller/gateway.controller.ts +++ b/src/controller/gateway.controller.ts @@ -1,15 +1,14 @@ import { FastifyInstance } from 'fastify'; import { AppConfig } from '../config'; -import { OverrideDriveFeatureAvailable, UsersService } from '../services/users.service'; +import { UsersService } from '../services/users.service'; import { NotFoundError } from '../errors/Errors'; import Logger from '../Logger'; import CacheService from '../services/cache.service'; -import { Service } from '../core/users/Tier'; +import { DriveFeatures, Service } from '../core/users/Tier'; import { User } from '../core/users/User'; import { UserFeaturesOverridesService } from '../services/userFeaturesOverride.service'; import { setupAuth } from '../plugins/auth'; import { LicenseCodeAlreadyAppliedError, LicenseCodesService } from '../services/licenseCodes.service'; -import jwt from 'jsonwebtoken'; interface GatewayControllerPayload { cacheService: CacheService; @@ -36,7 +35,7 @@ export function gatewayController({ }, }); - fastify.post<{ Body: { feature: Service; userUuid: string; subFeature?: string } }>( + fastify.post<{ Body: { feature: Service; userUuid: string; driveFeature?: keyof DriveFeatures } }>( '/activate', { schema: { @@ -51,7 +50,7 @@ export function gatewayController({ userUuid: { type: 'string', }, - subFeature: { + driveFeature: { type: 'string', enum: ['fileVersioning', 'passwordProtectedSharing', 'restrictedItemsSharing'], }, @@ -61,7 +60,7 @@ export function gatewayController({ }, async (request, response) => { let user: User; - const { feature, userUuid, subFeature } = request.body; + const { feature, userUuid, driveFeature } = request.body; try { user = await usersService.findUserByUuid(userUuid); @@ -70,11 +69,7 @@ export function gatewayController({ throw new NotFoundError(`User with uuid ${userUuid} was not found`); } - await userFeaturesOverridesService.upsertCustomUserFeatures( - user, - feature, - subFeature as OverrideDriveFeatureAvailable, - ); + await userFeaturesOverridesService.upsertCustomUserFeatures(user, feature, driveFeature); await cacheService.clearUserTier(userUuid); return response.status(204).send(); diff --git a/src/core/users/MongoDBUserFeatureOverridesRepository.ts b/src/core/users/MongoDBUserFeatureOverridesRepository.ts index 0c0c2103..f650eca1 100644 --- a/src/core/users/MongoDBUserFeatureOverridesRepository.ts +++ b/src/core/users/MongoDBUserFeatureOverridesRepository.ts @@ -26,6 +26,15 @@ export class MongoDBUserFeatureOverridesRepository implements UserFeatureOverrid } async upsert(userFeatureOverrides: Omit): Promise { + const newFeatures = userFeatureOverrides.featuresPerService || {}; + + const mergedServices: Record = {}; + for (const [serviceKey, serviceValue] of Object.entries(newFeatures)) { + mergedServices[serviceKey] = { + $mergeObjects: [{ $ifNull: [`$featuresPerService.${serviceKey}`, {}] }, serviceValue], + }; + } + await this.collection.updateOne( { userId: userFeatureOverrides.userId, @@ -34,7 +43,7 @@ export class MongoDBUserFeatureOverridesRepository implements UserFeatureOverrid { $set: { featuresPerService: { - $mergeObjects: [{ $ifNull: ['$featuresPerService', {}] }, userFeatureOverrides.featuresPerService || {}], + $mergeObjects: [{ $ifNull: ['$featuresPerService', {}] }, mergedServices], }, }, }, diff --git a/src/core/users/UserFeatureOverrides.ts b/src/core/users/UserFeatureOverrides.ts index da50a02c..16cc05f1 100644 --- a/src/core/users/UserFeatureOverrides.ts +++ b/src/core/users/UserFeatureOverrides.ts @@ -1,11 +1,22 @@ import { Service } from './Tier'; import { User } from './User'; +interface DriveFeatureOverride { + enabled: boolean; + passwordProtectedSharing?: { enabled: boolean }; + restrictedItemsSharing?: { enabled: boolean }; + fileVersioning?: { enabled: boolean }; +} + +type ServiceFeatureOverride = { enabled: boolean }; + export interface UserFeatureOverrides { userId: User['id']; - featuresPerService: Partial<{ - [key in Service]: { - enabled: boolean; - }; - }>; + featuresPerService: Partial< + { + [K in Exclude]: ServiceFeatureOverride; + } & { + [Service.Drive]: DriveFeatureOverride; + } + >; } diff --git a/src/services/userFeaturesOverride.service.ts b/src/services/userFeaturesOverride.service.ts index c7ecf604..e1865566 100644 --- a/src/services/userFeaturesOverride.service.ts +++ b/src/services/userFeaturesOverride.service.ts @@ -53,8 +53,8 @@ export class UserFeaturesOverridesService { async upsertCustomUserFeatures(user: User, service: Service, driveFeature?: keyof DriveFeatures): Promise { const { id: userId, uuid: userUuid } = user; const overrideUserFeatures = await this.userFeatureOverridesRepository.findByUserId(userId); - - if (overrideUserFeatures?.featuresPerService?.[service]?.enabled) { + const isDriveFeatureOverride = driveFeature && overrideUserFeatures?.featuresPerService?.[Service.Drive]?.enabled; + if (!isDriveFeatureOverride && overrideUserFeatures?.featuresPerService?.[service]?.enabled) { return; } diff --git a/tests/src/core/users/MongoDBUserFeatureOverridesRepository.test.ts b/tests/src/core/users/MongoDBUserFeatureOverridesRepository.test.ts index 494559b4..b1a2bdbf 100644 --- a/tests/src/core/users/MongoDBUserFeatureOverridesRepository.test.ts +++ b/tests/src/core/users/MongoDBUserFeatureOverridesRepository.test.ts @@ -134,4 +134,38 @@ describe('User Features OVerrides Repository', () => { expect(foundOverrides).toStrictEqual(existingPayload); }); + + it('when adding a new feature to the same service, then existing features within that service are preserved', async () => { + const userId = getUser().id; + + await repository.upsert({ + userId, + featuresPerService: { + drive: { + enabled: true, + passwordProtectedSharing: { enabled: true }, + }, + }, + }); + + await repository.upsert({ + userId, + featuresPerService: { + drive: { + enabled: true, + fileVersioning: { enabled: true }, + }, + }, + }); + + const foundOverrides = await repository.findByUserId(userId); + + expect(foundOverrides?.featuresPerService).toStrictEqual({ + drive: { + enabled: true, + passwordProtectedSharing: { enabled: true }, + fileVersioning: { enabled: true }, + }, + }); + }); }); From d0930d368f580129cfdbad37e1bbab2956e74cac Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:57:34 +0100 Subject: [PATCH 5/6] feat(tier): add file versioning when getting tier --- src/services/products.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/services/products.service.ts b/src/services/products.service.ts index b419c855..da6a2da7 100644 --- a/src/services/products.service.ts +++ b/src/services/products.service.ts @@ -140,6 +140,9 @@ export class ProductsService { individual[Service.Drive].restrictedItemsSharing.enabled || business[Service.Drive].restrictedItemsSharing.enabled, }, + fileVersioning: { + enabled: individual[Service.Drive].fileVersioning.enabled || business[Service.Drive].fileVersioning.enabled, + }, }, [Service.Meet]: { ...baseMerge[Service.Meet], From 4601a1991d76dfe9cd24d6cfa8456c43301528ac Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:43:59 +0100 Subject: [PATCH 6/6] fix: use a package to do deepMerges --- package.json | 2 ++ tests/src/fixtures.ts | 32 +++++++------------------------- yarn.lock | 9 ++++++++- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index dbf3e136..e2892de5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@internxt/eslint-config-internxt": "^1.0.9", "@internxt/prettier-config": "^1.0.2", "@types/chance": "^1.1.6", + "@types/deepmerge": "^2.2.3", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.2", @@ -49,6 +50,7 @@ "@fastify/rate-limit": "^10.1.1", "axios": "^1.12.0", "dayjs": "^1.11.13", + "deepmerge": "^4.3.1", "dotenv": "^16.4.5", "fastify": "^5.3.2", "ioredis": "^5.4.1", diff --git a/tests/src/fixtures.ts b/tests/src/fixtures.ts index 12dbbecb..b487a842 100644 --- a/tests/src/fixtures.ts +++ b/tests/src/fixtures.ts @@ -36,6 +36,7 @@ import { COUPON_BASE, } from './fixtures/stripe-base.generated'; import { HealthStatus } from '../../src/services/health.service'; +import deepmerge from 'deepmerge'; const randomDataGenerator = new Chance(); @@ -45,6 +46,12 @@ type DeepPartial = T extends object } : T; +const deepMerge = (target: T, source: DeepPartial): T => { + return deepmerge(target as object, source as object, { + arrayMerge: (_target, source) => source, + }) as T; +}; + export const getHealthCheck = (params?: Partial): HealthStatus => { return { status: 'ok', @@ -615,31 +622,6 @@ export const getCoupon = (params?: Partial): Coupon => ({ ...params, }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const deepMerge = (target: T, source: DeepPartial): T => { - const result = { ...target } as any; - - for (const key in source) { - const sourceValue = source[key]; - const targetValue = (target as any)[key]; - - if ( - sourceValue !== null && - typeof sourceValue === 'object' && - !Array.isArray(sourceValue) && - targetValue !== null && - typeof targetValue === 'object' && - !Array.isArray(targetValue) - ) { - result[key] = deepMerge(targetValue, sourceValue); - } else if (sourceValue !== undefined) { - result[key] = sourceValue; - } - } - - return result as T; -}; - export const newTier = (params?: DeepPartial): Tier => { const defaultTier: Tier = { id: randomDataGenerator.string({ length: 10 }), diff --git a/yarn.lock b/yarn.lock index abc68b99..82d30860 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1136,6 +1136,13 @@ resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.1.6.tgz#2fe3de58742629602c3fbab468093b27207f04ad" integrity sha512-V+pm3stv1Mvz8fSKJJod6CglNGVqEQ6OyuqitoDkWywEODM/eJd1eSuIp9xt6DrX8BWZ2eDSIzbw1tPCUTvGbQ== +"@types/deepmerge@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/deepmerge/-/deepmerge-2.2.3.tgz#0110fa667d8361e8a163498e07a4cabbbba45170" + integrity sha512-ct4srnukH/SHdVPyJIFV73YJIt9PTYTaqQbjrCvRrbc9LxHdGcJb132SuWwnDTPyx5UjCVS/I00wj0i5IXfqSA== + dependencies: + deepmerge "*" + "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" @@ -2081,7 +2088,7 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.3.1: +deepmerge@*, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==