Repository: internxt/drive-server-wip
Pull request #481: [PB-4051] feat: migrated usage endpoint and implement cache module
Author: login: apsantiso
Labels: name: enhancement
description: New feature or request
name: ready-for-preview
Created: 2025-03-10T15:45:33Z | Merged: 2025-03-13T14:42:08Z

We need to continue with the migration of the endpoints in drive-server to this new API. This PR intends to migrate the /usage endpoint while also implementing caching using Nestjs Cache manager. 

### Changes

1. Migrated users/storage/usage endpoint.
2. Implemented Cache manager.

### Requirements

1. Add `REDIS_CONNECTION_STRING` to drive-server-wip if still not set.


Diff:
diff --git a/package.json b/package.json
index 4de6a577d..56a05b78b 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,9 @@
     "@hapi/joi": "^17.1.1",
     "@internxt/inxt-js": "^2.0.8",
     "@internxt/lib": "^1.2.0",
+    "@keyv/redis": "^4.3.1",
     "@nestjs/axios": "^3.0.1",
+    "@nestjs/cache-manager": "^3.0.0",
     "@nestjs/cli": "^10.2.1",
     "@nestjs/common": "^10.2.8",
     "@nestjs/config": "^3.1.1",
@@ -58,6 +60,7 @@
     "analytics-node": "^6.2.0",
     "bcryptjs": "^2.4.3",
     "bip32": "^4.0.0",
+    "cache-manager": "^6.4.1",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.0",
     "crypto": "^1.0.1",
diff --git a/src/config/configuration.ts b/src/config/configuration.ts
index 156e10db8..fc18aa3be 100644
--- a/src/config/configuration.ts
+++ b/src/config/configuration.ts
@@ -32,6 +32,9 @@ export default () => ({
       },
     },
   },
+  cache: {
+    redisConnectionString: process.env.REDIS_CONNECTION_STRING,
+  },
   secrets: {
     magicIv: process.env.MAGIC_IV,
     magicSalt: process.env.MAGIC_SALT,
diff --git a/src/modules/cache-manager/cache-manager.module.spec.ts b/src/modules/cache-manager/cache-manager.module.spec.ts
new file mode 100644
index 000000000..fea0de549
--- /dev/null
+++ b/src/modules/cache-manager/cache-manager.module.spec.ts
@@ -0,0 +1,53 @@
+import { Test } from '@nestjs/testing';
+import { CacheManagerModule } from './cache-manager.module';
+import { ConfigModule } from '@nestjs/config';
+import { Logger } from '@nestjs/common';
+import { EventEmitter } from 'events';
+import * as keyvRedis from '@keyv/redis';
+
+jest.mock('@keyv/redis');
+
+describe('CacheManagerModule', () => {
+  let loggerErrorSpy;
+  let mockRedisClient;
+
+  beforeEach(() => {
+    mockRedisClient = new EventEmitter();
+    (keyvRedis.createKeyv as jest.Mock) = jest
+      .fn()
+      .mockReturnValue(mockRedisClient);
+    loggerErrorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation();
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+    loggerErrorSpy.mockRestore();
+  });
+
+  it('When error occur on redis client, then it should log it', async () => {
+    await Test.createTestingModule({
+      imports: [
+        ConfigModule.forRoot({
+          isGlobal: true,
+          load: [
+            () => ({
+              cache: {
+                redisConnectionString: 'redis://localhost:6379',
+              },
+            }),
+          ],
+        }),
+        CacheManagerModule,
+      ],
+    })
+      .overrideProvider('CACHE_MANAGER')
+      .useValue({})
+      .compile();
+
+    mockRedisClient.emit('error', new Error('Test Redis error'));
+
+    expect(loggerErrorSpy).toHaveBeenCalledWith(
+      expect.stringContaining('Error on redis client'),
+    );
+  });
+});
diff --git a/src/modules/cache-manager/cache-manager.module.ts b/src/modules/cache-manager/cache-manager.module.ts
new file mode 100644
index 000000000..bf996f558
--- /dev/null
+++ b/src/modules/cache-manager/cache-manager.module.ts
@@ -0,0 +1,46 @@
+import { Logger, Module } from '@nestjs/common';
+import { CacheModule, CacheOptions } from '@nestjs/cache-manager';
+import { createKeyv } from '@keyv/redis';
+import { CacheManagerService } from './cache-manager.service';
+import { ConfigModule, ConfigService } from '@nestjs/config';
+
+@Module({
+  imports: [
+    CacheModule.registerAsync({
+      imports: [ConfigModule],
+      inject: [ConfigService],
+      useFactory: async (
+        configService: ConfigService,
+      ): Promise<CacheOptions> => {
+        const logger = new Logger('REDIS_MODULE');
+        const redisUrl = configService.get('cache.redisConnectionString');
+
+        const redisStore = createKeyv({
+          url: redisUrl,
+          // Let client throw instead of create an offline queue
+          disableOfflineQueue: true,
+          socket: {
+            reconnectStrategy: false,
+          },
+        });
+
+        // Error propagation should be stopped by adding event listener
+        redisStore.on('error', (err) =>
+          logger.error(
+            `Error on redis client: error: ${JSON.stringify(
+              err,
+            )}, url: ${redisUrl}`,
+          ),
+        );
+
+        return {
+          stores: redisStore,
+          nonBlocking: true,
+        };
+      },
+    }),
+  ],
+  providers: [CacheManagerService],
+  exports: [CacheManagerService],
+})
+export class CacheManagerModule {}
diff --git a/src/modules/cache-manager/cache-manager.service.spec.ts b/src/modules/cache-manager/cache-manager.service.spec.ts
new file mode 100644
index 000000000..cf63ef071
--- /dev/null
+++ b/src/modules/cache-manager/cache-manager.service.spec.ts
@@ -0,0 +1,84 @@
+import { Test } from '@nestjs/testing';
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+import { CacheManagerService } from './cache-manager.service';
+import { Cache } from 'cache-manager';
+import { createMock } from '@golevelup/ts-jest';
+import { v4 } from 'uuid';
+
+describe('CacheManagerService', () => {
+  let cacheManagerService: CacheManagerService;
+  let cacheManager: Cache;
+
+  beforeEach(async () => {
+    const moduleRef = await Test.createTestingModule({
+      providers: [CacheManagerService],
+    })
+      .useMocker(() => createMock())
+      .compile();
+
+    cacheManagerService =
+      moduleRef.get<CacheManagerService>(CacheManagerService);
+    cacheManager = moduleRef.get<Cache>(CACHE_MANAGER);
+
+    jest.clearAllMocks();
+  });
+
+  describe('getUserUsage', () => {
+    it('When getting user usage, then it should append the user uuid to the usage key prefix', async () => {
+      const userUuid = v4();
+      const cachedUsage = { usage: 1024 };
+
+      jest.spyOn(cacheManager, 'get').mockResolvedValue(cachedUsage);
+
+      const result = await cacheManagerService.getUserUsage(userUuid);
+
+      expect(cacheManager.get).toHaveBeenCalledWith(`usage:${userUuid}`);
+      expect(result).toEqual(cachedUsage);
+    });
+
+    it('When cache returns null for user usage, then it should return null', async () => {
+      const userUuid = v4();
+
+      jest.spyOn(cacheManager, 'get').mockResolvedValue(null);
+
+      const result = await cacheManagerService.getUserUsage(userUuid);
+
+      expect(cacheManager.get).toHaveBeenCalledWith(`usage:${userUuid}`);
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('setUserUsage', () => {
+    it('When setting user usage, then it should store the usage with correct key and expiration', async () => {
+      const userUuid = v4();
+      const usage = 2048;
+
+      jest.spyOn(cacheManager, 'set').mockResolvedValue(undefined);
+
+      await cacheManagerService.setUserUsage(userUuid, usage);
+
+      expect(cacheManager.set).toHaveBeenCalledWith(
+        `usage:${userUuid}`,
+        { usage },
+        10000 * 60,
+      );
+    });
+
+    it('When user usage is set, then it should return set value', async () => {
+      const userUuid = v4();
+      const usage = 1024;
+      const returnValue = { result: 'success' };
+
+      jest.spyOn(cacheManager, 'set').mockResolvedValue(returnValue as any);
+
+      const result = await cacheManagerService.setUserUsage(userUuid, usage);
+
+      expect(cacheManager.set).toHaveBeenCalledWith(
+        `usage:${userUuid}`,
+        { usage },
+        10000 * 60,
+      );
+      expect(result).toEqual(returnValue);
+    });
+  });
+});
diff --git a/src/modules/cache-manager/cache-manager.service.ts b/src/modules/cache-manager/cache-manager.service.ts
new file mode 100644
index 000000000..c6b2a9897
--- /dev/null
+++ b/src/modules/cache-manager/cache-manager.service.ts
@@ -0,0 +1,35 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+import { Cache } from 'cache-manager';
+
+@Injectable()
+export class CacheManagerService {
+  private readonly USAGE_KEY_PREFIX = 'usage:';
+  private readonly LIMIT_KEY_PREFIX = 'limit:';
+
+  constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
+
+  /**
+   * Get user's storage usage
+   */
+  async getUserUsage(userUuid: string) {
+    const cachedUsage = this.cacheManager.get<{ usage: number }>(
+      `${this.USAGE_KEY_PREFIX}${userUuid}`,
+    );
+
+    return cachedUsage;
+  }
+
+  /**
+   * Set user's storage usage
+   */
+  async setUserUsage(userUuid: string, usage: number) {
+    const cachedUsage = await this.cacheManager.set(
+      `${this.USAGE_KEY_PREFIX}${userUuid}`,
+      { usage },
+      10000 * 60,
+    );
+
+    return cachedUsage;
+  }
+}
diff --git a/src/modules/file/file.repository.spec.ts b/src/modules/file/file.repository.spec.ts
index 43ebedafd..b70c52240 100644
--- a/src/modules/file/file.repository.spec.ts
+++ b/src/modules/file/file.repository.spec.ts
@@ -5,7 +5,7 @@ import { newFile, newUser, newWorkspace } from '../../../test/fixtures';
 import { FileAttributes, FileStatus } from './file.domain';
 import { FileModel } from './file.model';
 import { FileRepository, SequelizeFileRepository } from './file.repository';
-import { Op } from 'sequelize';
+import { Op, Sequelize } from 'sequelize';
 import { v4 } from 'uuid';
 
 describe('FileRepository', () => {
@@ -494,4 +494,49 @@ describe('FileRepository', () => {
       });
     });
   });
+
+  describe('sumExistentFileSizes', () => {
+    const userId = 123;
+
+    it('When called with valid userId, then it should return the sum of file sizes that are not deleted', async () => {
+      const totalSize = 5000;
+      const sizesSum = [{ total: totalSize }];
+
+      jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce(sizesSum as any);
+
+      const result = await repository.sumExistentFileSizes(userId);
+
+      expect(fileModel.findAll).toHaveBeenCalledWith({
+        attributes: [[Sequelize.fn(`SUM`, Sequelize.col('size')), 'total']],
+        where: {
+          userId,
+          status: {
+            [Op.ne]: 'DELETED',
+          },
+        },
+        raw: true,
+      });
+      expect(result).toEqual(totalSize);
+    });
+
+    it('When no files are found or total size is null, then it should return 0', async () => {
+      const sizesSum = [{ total: null }];
+
+      jest.spyOn(fileModel, 'findAll').mockResolvedValueOnce(sizesSum as any);
+
+      const result = await repository.sumExistentFileSizes(userId);
+
+      expect(fileModel.findAll).toHaveBeenCalledWith(
+        expect.objectContaining({
+          where: {
+            userId,
+            status: {
+              [Op.ne]: 'DELETED',
+            },
+          },
+        }),
+      );
+      expect(result).toEqual(0);
+    });
+  });
 });
diff --git a/src/modules/file/file.repository.ts b/src/modules/file/file.repository.ts
index 230682b84..69a677c10 100644
--- a/src/modules/file/file.repository.ts
+++ b/src/modules/file/file.repository.ts
@@ -113,6 +113,7 @@ export interface FileRepository {
     userId: User['id'],
     fileIds: FileAttributes['fileId'][],
   ): Promise<File[]>;
+  sumExistentFileSizes(userId: FileAttributes['userId']): Promise<number>;
 }
 
 @Injectable()
@@ -778,4 +779,21 @@ export class SequelizeFileRepository implements FileRepository {
   private toModel(domain: File): Partial<FileAttributes> {
     return domain.toJSON();
   }
+
+  async sumExistentFileSizes(
+    userId: FileAttributes['userId'],
+  ): Promise<number> {
+    const result = await this.fileModel.findAll({
+      attributes: [[Sequelize.fn(`SUM`, Sequelize.col('size')), 'total']],
+      where: {
+        userId,
+        status: {
+          [Op.ne]: 'DELETED',
+        },
+      },
+      raw: true,
+    });
+
+    return Number(result[0]['total']) as unknown as number;
+  }
 }
diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts
index 7cca89760..e8494258c 100644
--- a/src/modules/file/file.usecase.spec.ts
+++ b/src/modules/file/file.usecase.spec.ts
@@ -1119,4 +1119,16 @@ describe('FileUseCases', () => {
       );
     });
   });
+
+  describe('getUserUsedStorage', () => {
+    it('When called, it should return the user total used space', async () => {
+      const totalUsage = 1000;
+      jest
+        .spyOn(fileRepository, 'sumExistentFileSizes')
+        .mockResolvedValueOnce(totalUsage);
+
+      const result = await service.getUserUsedStorage(userMocked);
+      expect(result).toEqual(totalUsage);
+    });
+  });
 });
diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts
index a63dcda2d..ef89d0926 100644
--- a/src/modules/file/file.usecase.ts
+++ b/src/modules/file/file.usecase.ts
@@ -63,6 +63,10 @@ export class FileUseCases {
     return this.fileRepository.findByUuids(uuids, { userId: user.id });
   }
 
+  getUserUsedStorage(user: User) {
+    return this.fileRepository.sumExistentFileSizes(user.id);
+  }
+
   getByUserExceptParents(arg: any): Promise<File[]> {
     throw new Error('Method not implemented.');
   }
diff --git a/src/modules/user/dto/responses/get-user-usage.dto.ts b/src/modules/user/dto/responses/get-user-usage.dto.ts
new file mode 100644
index 000000000..ac8957ecb
--- /dev/null
+++ b/src/modules/user/dto/responses/get-user-usage.dto.ts
@@ -0,0 +1,6 @@
+import { ApiProperty } from '@nestjs/swagger';
+
+export class GetUserUsageDto {
+  @ApiProperty()
+  drive: number;
+}
diff --git a/src/modules/user/user.controller.spec.ts b/src/modules/user/user.controller.spec.ts
index d8117a8e2..1854ce664 100644
--- a/src/modules/user/user.controller.spec.ts
+++ b/src/modules/user/user.controller.spec.ts
@@ -876,4 +876,30 @@ describe('User Controller', () => {
       expect(userUseCases.confirmDeactivation).toHaveBeenCalledWith(token);
     });
   });
+
+  describe('GET /storage/usage', () => {
+    const user = newUser();
+
+    it('When storage usage is requested, then it should return the user usage data', async () => {
+      const mockUsage = {
+        drive: 1024000,
+      };
+
+      userUseCases.getUserUsage.mockResolvedValueOnce(mockUsage);
+
+      const result = await userController.getUserUsage(user);
+
+      expect(userUseCases.getUserUsage).toHaveBeenCalledWith(user);
+      expect(result).toEqual(mockUsage);
+    });
+
+    it('When getUserUsage throws an error, then it should propagate the error', async () => {
+      const errorMessage = 'Failed to get storage usage';
+
+      userUseCases.getUserUsage.mockRejectedValueOnce(new Error(errorMessage));
+
+      await expect(userController.getUserUsage(user)).rejects.toThrow(Error);
+      expect(userUseCases.getUserUsage).toHaveBeenCalledWith(user);
+    });
+  });
 });
diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts
index 073897394..8aa06fe6a 100644
--- a/src/modules/user/user.controller.ts
+++ b/src/modules/user/user.controller.ts
@@ -79,6 +79,7 @@ import { avatarStorageS3Config } from '../../externals/multer';
 import { Client } from '../auth/decorators/client.decorator';
 import { DeactivationRequestEvent } from '../../externals/notifications/events/deactivation-request.event';
 import { ConfirmAccountDeactivationDto } from './dto/confirm-deactivation.dto';
+import { GetUserUsageDto } from './dto/responses/get-user-usage.dto';
 
 @ApiTags('User')
 @Controller('users')
@@ -969,4 +970,16 @@ export class UserController {
 
     return this.userUseCases.confirmDeactivation(token);
   }
+
+  @Get('/usage')
+  @ApiBearerAuth()
+  @ApiOperation({
+    summary: 'Get User used storage space',
+  })
+  @ApiOkResponse({ type: GetUserUsageDto })
+  async getUserUsage(@UserDecorator() user: User): Promise<GetUserUsageDto> {
+    const usage = await this.userUseCases.getUserUsage(user);
+
+    return usage;
+  }
 }
diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts
index eb44c4af6..282856fba 100644
--- a/src/modules/user/user.module.ts
+++ b/src/modules/user/user.module.ts
@@ -48,6 +48,7 @@ import { WorkspacesModule } from '../workspaces/workspaces.module';
 import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository';
 import { UserNotificationTokensModel } from './user-notification-tokens.model';
 import { BackupModule } from '../backups/backup.module';
+import { CacheManagerModule } from '../cache-manager/cache-manager.module';
 
 @Module({
   imports: [
@@ -76,6 +77,7 @@ import { BackupModule } from '../backups/backup.module';
     forwardRef(() => FeatureLimitModule),
     forwardRef(() => WorkspacesModule),
     BackupModule,
+    CacheManagerModule,
   ],
   controllers: [UserController],
   providers: [
diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts
index f80504639..735c29c06 100644
--- a/src/modules/user/user.usecase.spec.ts
+++ b/src/modules/user/user.usecase.spec.ts
@@ -69,6 +69,7 @@ import { KeyServerUseCases } from '../keyserver/key-server.usecase';
 import { AppSumoUseCase } from '../app-sumo/app-sumo.usecase';
 import { BackupUseCase } from '../backups/backup.usecase';
 import { convertSizeToBytes } from '../../lib/convert-size-to-bytes';
+import { CacheManagerService } from '../cache-manager/cache-manager.service';
 
 jest.mock('../../middlewares/passport', () => {
   const originalModule = jest.requireActual('../../middlewares/passport');
@@ -100,6 +101,7 @@ describe('User use cases', () => {
   let keyServerUseCases: KeyServerUseCases;
   let appSumoUseCases: AppSumoUseCase;
   let backupUseCases: BackupUseCase;
+  let cacheManagerService: CacheManagerService;
 
   const loggerErrorSpy = jest.spyOn(Logger, 'error').mockImplementation();
 
@@ -173,6 +175,9 @@ describe('User use cases', () => {
 
     appSumoUseCases = moduleRef.get<AppSumoUseCase>(AppSumoUseCase);
     backupUseCases = moduleRef.get<BackupUseCase>(BackupUseCase);
+    cacheManagerService =
+      moduleRef.get<CacheManagerService>(CacheManagerService);
+
     jest.clearAllMocks();
   });
 
@@ -2079,6 +2084,47 @@ describe('User use cases', () => {
       });
     });
   });
+
+  describe('getUserUsage', () => {
+    it('When cache has user usage data, then it should return the cached data', async () => {
+      const cachedUsage = { usage: 1024 };
+
+      jest
+        .spyOn(cacheManagerService, 'getUserUsage')
+        .mockResolvedValue(cachedUsage);
+      jest.spyOn(fileUseCases, 'getUserUsedStorage');
+      jest.spyOn(cacheManagerService, 'setUserUsage');
+
+      const result = await userUseCases.getUserUsage(user);
+
+      expect(cacheManagerService.getUserUsage).toHaveBeenCalledWith(user.uuid);
+      expect(fileUseCases.getUserUsedStorage).not.toHaveBeenCalled();
+      expect(cacheManagerService.setUserUsage).not.toHaveBeenCalled();
+      expect(result).toEqual({ drive: cachedUsage.usage });
+    });
+
+    it('When cache does not have user usage data, then it should get data from database and cache it', async () => {
+      const driveUsage = 2048;
+
+      jest.spyOn(cacheManagerService, 'getUserUsage').mockResolvedValue(null);
+      jest
+        .spyOn(fileUseCases, 'getUserUsedStorage')
+        .mockResolvedValue(driveUsage);
+      jest
+        .spyOn(cacheManagerService, 'setUserUsage')
+        .mockResolvedValue(undefined);
+
+      const result = await userUseCases.getUserUsage(user);
+
+      expect(cacheManagerService.getUserUsage).toHaveBeenCalledWith(user.uuid);
+      expect(fileUseCases.getUserUsedStorage).toHaveBeenCalledWith(user);
+      expect(cacheManagerService.setUserUsage).toHaveBeenCalledWith(
+        user.uuid,
+        driveUsage,
+      );
+      expect(result).toEqual({ drive: driveUsage });
+    });
+  });
 });
 
 const createTestingModule = (): Promise<TestingModule> => {
diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts
index 8551603ca..5b4c03f57 100644
--- a/src/modules/user/user.usecase.ts
+++ b/src/modules/user/user.usecase.ts
@@ -82,6 +82,7 @@ import { UserKeysEncryptVersions } from '../keyserver/key-server.domain';
 import { AppSumoUseCase } from '../app-sumo/app-sumo.usecase';
 import { BackupUseCase } from '../backups/backup.usecase';
 import { convertSizeToBytes } from '../../lib/convert-size-to-bytes';
+import { CacheManagerService } from '../cache-manager/cache-manager.service';
 
 export class ReferralsNotAvailableError extends Error {
   constructor() {
@@ -156,6 +157,7 @@ export class UserUseCases {
     private readonly keyServerUseCases: KeyServerUseCases,
     private readonly appSumoUseCases: AppSumoUseCase,
     private readonly backupUseCases: BackupUseCase,
+    private readonly cacheManager: CacheManagerService,
   ) {}
 
   findByEmail(email: User['email']): Promise<User | null> {
@@ -1544,6 +1546,21 @@ export class UserUseCases {
     );
   }
 
+  async getUserUsage(user: User): Promise<{ drive: number }> {
+    let totalDriveUsage = 0;
+    const cachedUsage = await this.cacheManager.getUserUsage(user.uuid);
+
+    if (cachedUsage) {
+      totalDriveUsage = cachedUsage.usage;
+    } else {
+      const driveUsage = await this.fileUseCases.getUserUsedStorage(user);
+      await this.cacheManager.setUserUsage(user.uuid, driveUsage);
+      totalDriveUsage = driveUsage;
+    }
+
+    return { drive: totalDriveUsage };
+  }
+
   async confirmDeactivation(token: string) {
     const userEmail = await this.networkService.confirmDeactivation(token);
     const user = await this.userRepository.findByEmail(userEmail);
diff --git a/yarn.lock b/yarn.lock
index 6a99ab398..ad3cd7c05 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1492,6 +1492,22 @@
   resolved "https://registry.yarnpkg.com/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz#9299f82874bab9e4c7f9c48d865becbfe8d6907c"
   integrity sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==
 
+"@keyv/redis@^4.3.1":
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/@keyv/redis/-/redis-4.3.1.tgz#81f6221044368a4d8c2e1bc99151472830c1bf49"
+  integrity sha512-+UJU9eFBOFbvfg/Om8ajCPa5NxtctpQuE2ElE5Wf7xwbghmhlbLsJ2K4ZEmPnUF0YUolFBJFHd/ukDIvyTA5kw==
+  dependencies:
+    cluster-key-slot "^1.1.2"
+    keyv "^5.3.1"
+    redis "^4.7.0"
+
+"@keyv/serialize@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.0.3.tgz#e0fe3710e2a379cb0490cd41e5a5ffa2bab58bf6"
+  integrity sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==
+  dependencies:
+    buffer "^6.0.3"
+
 "@lukeed/csprng@^1.0.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@lukeed/csprng/-/csprng-1.1.0.tgz#1e3e4bd05c1cc7a0b2ddbd8a03f39f6e4b5e6cfe"
@@ -1532,6 +1548,11 @@
   resolved "https://registry.yarnpkg.com/@nestjs/axios/-/axios-3.0.1.tgz#b006f81dd54a49def92cfaf9a8970434567e75ce"
   integrity sha512-VlOZhAGDmOoFdsmewn8AyClAdGpKXQQaY1+3PGB+g6ceurGIdTxZgRX3VXc1T6Zs60PedWjg3A82TDOB05mrzQ==
 
+"@nestjs/cache-manager@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@nestjs/cache-manager/-/cache-manager-3.0.0.tgz#7d2c7aeee4733a7669333f66500417c0f74d1391"
+  integrity sha512-csKvxHSQWfC0OiDo0bNEhLqrmYDopHEvRyC81MxV9xFj1AO+rOKocpHa4M1ZGH//6uKFIPGN9oiR0mvZY77APA==
+
 "@nestjs/cli@^10.2.1":
   version "10.2.1"
   resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.2.1.tgz#a1d32c28e188f0fb4c3f54235c55745de4c6dd7f"
@@ -1888,6 +1909,40 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
 
+"@redis/bloom@1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71"
+  integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==
+
+"@redis/client@1.6.0":
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.6.0.tgz#dcf4ae1319763db6fdddd6de7f0af68a352c30ea"
+  integrity sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==
+  dependencies:
+    cluster-key-slot "1.1.2"
+    generic-pool "3.9.0"
+    yallist "4.0.0"
+
+"@redis/graph@1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.1.tgz#8c10df2df7f7d02741866751764031a957a170ea"
+  integrity sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==
+
+"@redis/json@1.0.7":
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.7.tgz#016257fcd933c4cbcb9c49cde8a0961375c6893b"
+  integrity sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==
+
+"@redis/search@1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.2.0.tgz#50976fd3f31168f585666f7922dde111c74567b8"
+  integrity sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==
+
+"@redis/time-series@1.1.0":
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.1.0.tgz#cba454c05ec201bd5547aaf55286d44682ac8eb5"
+  integrity sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==
+
 "@rudderstack/rudder-sdk-node@^2.0.6":
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/@rudderstack/rudder-sdk-node/-/rudder-sdk-node-2.0.6.tgz#20d2e9b2b8fa5434275be5106a7ef296d008096c"
@@ -4226,6 +4281,14 @@ buffer@^5.5.0:
     base64-js "^1.3.1"
     ieee754 "^1.1.13"
 
+buffer@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
+  integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
+  dependencies:
+    base64-js "^1.3.1"
+    ieee754 "^1.2.1"
+
 bull@4.11.3:
   version "4.11.3"
   resolved "https://registry.yarnpkg.com/bull/-/bull-4.11.3.tgz#466214c8d2aa6ac4fc3133c3616d31a37af4c099"
@@ -4276,6 +4339,13 @@ cacache@^18.0.0:
     tar "^6.1.11"
     unique-filename "^3.0.0"
 
+cache-manager@^6.4.1:
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/cache-manager/-/cache-manager-6.4.1.tgz#ce56bd1492cc2e477a68722bf5bfa492a69c03bb"
+  integrity sha512-DVy28v0FbeBp2SmFgKTa4o6muKP3T78Id0nht5D6mBAar1Nuc5z4nL6G0jpnEbYq8FS10dtrBNzQri5l9uJ/zA==
+  dependencies:
+    keyv "^5.3.1"
+
 call-bind@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
@@ -4523,6 +4593,11 @@ clone@^1.0.2:
   resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
   integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
 
+cluster-key-slot@1.1.2, cluster-key-slot@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
+  integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
+
 cluster-key-slot@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz#10ccb9ded0729464b6d2e7d714b100a2d1259d43"
@@ -5860,6 +5935,11 @@ function-bind@^1.1.2:
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
   integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
 
+generic-pool@3.9.0:
+  version "3.9.0"
+  resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4"
+  integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==
+
 gensync@^1.0.0-beta.2:
   version "1.0.0-beta.2"
   resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@@ -6265,7 +6345,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ieee754@^1.1.13, ieee754@^1.1.4:
+ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
@@ -7184,6 +7264,13 @@ jws@^3.2.2:
     jwa "^1.4.1"
     safe-buffer "^5.0.1"
 
+keyv@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-5.3.1.tgz#d3acebeecedafd4bf2b929e8866bcd79db071f1e"
+  integrity sha512-13hQT2q2VIwOoaJdJa7nY3J8UVbYtMTJFHnwm9LI+SaQRfUiM6Em9KZeOVTCKbMnGcRIL3NSUFpAdjZCq24nLQ==
+  dependencies:
+    "@keyv/serialize" "^1.0.3"
+
 kleur@^3.0.3:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
@@ -8722,6 +8809,18 @@ redis-parser@^3.0.0:
   dependencies:
     redis-errors "^1.0.0"
 
+redis@^4.7.0:
+  version "4.7.0"
+  resolved "https://registry.yarnpkg.com/redis/-/redis-4.7.0.tgz#b401787514d25dd0cfc22406d767937ba3be55d6"
+  integrity sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==
+  dependencies:
+    "@redis/bloom" "1.2.0"
+    "@redis/client" "1.6.0"
+    "@redis/graph" "1.1.1"
+    "@redis/json" "1.0.7"
+    "@redis/search" "1.2.0"
+    "@redis/time-series" "1.1.0"
+
 reflect-metadata@^0.1.13:
   version "0.1.13"
   resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
@@ -10273,7 +10372,7 @@ y18n@^5.0.5:
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
   integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
 
-yallist@^4.0.0:
+yallist@4.0.0, yallist@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==


Reviews:
user: login: sg-gs
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-13T12:39:40Z
user: login: apsantiso
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-13T14:40:07Z

Review comments:
diff_hunk: @@ -969,4 +970,16 @@ export class UserController {
 
     return this.userUseCases.confirmDeactivation(token);
   }
+
+  @Get('/storage/usage')
path: src/modules/user/user.controller.ts
user: login: sg-gs
body: Just use /usage if it is not an issue, as it could be more semantic, to get the usage a user is doing of Drive, which now implies the bytes, but it could imply more things in the future
created_at: 2025-03-13T12:36:40Z
author_association: MEMBER
diff_hunk: @@ -969,4 +970,16 @@ export class UserController {
 
     return this.userUseCases.confirmDeactivation(token);
   }
+
+  @Get('/storage/usage')
path: src/modules/user/user.controller.ts
user: login: apsantiso
body: Done
created_at: 2025-03-13T14:40:07Z
author_association: COLLABORATOR

PR comments:
user: login: sg-gs
created_at: 2025-03-13T12:28:29Z
body: The `REDIS_CONNECTION_STRING` variable is already set. Although you have conflicts with master @apsantiso 
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-03-13T14:36:39Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=internxt_drive-server-wip&pullRequest=481) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=internxt_drive-server-wip&pullRequest=481&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=internxt_drive-server-wip&pullRequest=481&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=internxt_drive-server-wip&pullRequest=481&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [97.8% Coverage on New Code](https://sonarcloud.io/component_measures?id=internxt_drive-server-wip&pullRequest=481&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=internxt_drive-server-wip&pullRequest=481&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=internxt_drive-server-wip&pullRequest=481)


author_association: NONE