diff --git a/apps/storage/apps/backend/package.json b/apps/storage/apps/backend/package.json index 9ff68e0d3..773670ed0 100644 --- a/apps/storage/apps/backend/package.json +++ b/apps/storage/apps/backend/package.json @@ -29,6 +29,7 @@ "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", "@nestjs/platform-express": "^10.4.15", + "@nestjs/throttler": "^6.2.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", diff --git a/apps/storage/apps/backend/src/app.module.ts b/apps/storage/apps/backend/src/app.module.ts index 3e60a551a..0f0c10375 100644 --- a/apps/storage/apps/backend/src/app.module.ts +++ b/apps/storage/apps/backend/src/app.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ThrottlerModule } from '@nestjs/throttler'; import { DatabaseModule } from './db/database.module'; import { HealthModule } from '@manacore/shared-nestjs-health'; import { FileModule } from './file/file.module'; @@ -16,6 +17,12 @@ import { AdminModule } from './admin/admin.module'; ConfigModule.forRoot({ isGlobal: true, }), + ThrottlerModule.forRoot([ + { + ttl: 60000, // 60 seconds + limit: 100, // 100 requests per minute + }, + ]), DatabaseModule, HealthModule.forRoot({ serviceName: 'storage-backend', route: 'api/v1/health' }), StorageModule, diff --git a/apps/storage/apps/backend/src/file/file-versions.spec.ts b/apps/storage/apps/backend/src/file/file-versions.spec.ts new file mode 100644 index 000000000..d1910ebef --- /dev/null +++ b/apps/storage/apps/backend/src/file/file-versions.spec.ts @@ -0,0 +1,267 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { JwtAuthGuard } from '@manacore/shared-nestjs-auth'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { FileService } from './file.service'; +import { FileController } from './file.controller'; +import { StorageService } from '../storage/storage.service'; +import { createMockDb, mockFileFactory } from '../__tests__/utils/mock-factories'; +import { randomUUID } from 'crypto'; + +// FileVersion mock factory +const mockFileVersionFactory = { + create: (overrides: Record = {}) => ({ + id: randomUUID(), + fileId: randomUUID(), + versionNumber: 1, + storagePath: 'users/test-user-id/versions/test-file.pdf', + storageKey: `users/test-user-id/versions/${randomUUID()}-test-file.pdf`, + size: 1024, + checksum: null, + comment: null, + createdBy: 'test-user-id', + createdAt: new Date(), + ...overrides, + }), +}; + +describe('FileService - Versions', () => { + let service: FileService; + let mockDb: ReturnType; + let mockStorageService: Record; + + beforeEach(async () => { + mockDb = createMockDb(); + mockStorageService = { + uploadFile: jest.fn(), + downloadFile: jest.fn(), + deleteFile: jest.fn(), + deleteFiles: jest.fn(), + getDownloadUrl: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FileService, + { provide: DATABASE_CONNECTION, useValue: mockDb }, + { provide: StorageService, useValue: mockStorageService }, + ], + }).compile(); + + service = module.get(FileService); + }); + + describe('getVersions', () => { + it('should return versions sorted by version number desc', async () => { + const fileId = randomUUID(); + const file = mockFileFactory.create({ id: fileId, currentVersion: 3 }); + + // findOne query + mockDb.where.mockResolvedValueOnce([file]); + + const versions = [ + mockFileVersionFactory.create({ fileId, versionNumber: 3 }), + mockFileVersionFactory.create({ fileId, versionNumber: 2 }), + mockFileVersionFactory.create({ fileId, versionNumber: 1 }), + ]; + + // getVersions query (select -> from -> where -> orderBy) + mockDb.orderBy.mockResolvedValueOnce(versions); + + const result = await service.getVersions('test-user-id', fileId); + + expect(result).toEqual(versions); + expect(result[0].versionNumber).toBe(3); + expect(result[2].versionNumber).toBe(1); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent file', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.getVersions('test-user-id', 'nonexistent-id')).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('uploadVersion', () => { + it('should create new version and update file', async () => { + const fileId = randomUUID(); + const file = mockFileFactory.create({ id: fileId, currentVersion: 2 }); + + // findOne query + mockDb.where.mockResolvedValueOnce([file]); + + const uploadResult = { + storageKey: 'users/test-user-id/versions/new-file.pdf', + storagePath: 'users/test-user-id/versions/new-file.pdf', + }; + mockStorageService.uploadFile.mockResolvedValueOnce(uploadResult); + + const createdVersion = mockFileVersionFactory.create({ + fileId, + versionNumber: 3, + comment: 'Updated layout', + }); + + // insert().values().returning() for version record + mockDb.returning.mockResolvedValueOnce([createdVersion]); + + // update().set().where() for file update + mockDb.where.mockResolvedValueOnce(undefined); + + const multerFile = { + buffer: Buffer.from('new version content'), + originalname: 'updated-file.pdf', + mimetype: 'application/pdf', + size: 2048, + } as Express.Multer.File; + + const result = await service.uploadVersion( + 'test-user-id', + fileId, + multerFile, + 'Updated layout' + ); + + expect(result).toEqual(createdVersion); + expect(mockStorageService.uploadFile).toHaveBeenCalledWith( + 'test-user-id', + multerFile.buffer, + 'updated-file.pdf', + 'application/pdf', + 'versions' + ); + }); + + it('should increment version number', async () => { + const fileId = randomUUID(); + const file = mockFileFactory.create({ id: fileId, currentVersion: 5 }); + + // findOne query + mockDb.where.mockResolvedValueOnce([file]); + + const uploadResult = { + storageKey: 'users/test-user-id/versions/file.pdf', + storagePath: 'users/test-user-id/versions/file.pdf', + }; + mockStorageService.uploadFile.mockResolvedValueOnce(uploadResult); + + const createdVersion = mockFileVersionFactory.create({ + fileId, + versionNumber: 6, + }); + + // insert().values().returning() + mockDb.returning.mockResolvedValueOnce([createdVersion]); + + // update().set().where() + mockDb.where.mockResolvedValueOnce(undefined); + + const multerFile = { + buffer: Buffer.from('content'), + originalname: 'file.pdf', + mimetype: 'application/pdf', + size: 512, + } as Express.Multer.File; + + const result = await service.uploadVersion('test-user-id', fileId, multerFile); + + expect(result.versionNumber).toBe(6); + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ + currentVersion: 6, + }) + ); + }); + }); +}); + +describe('FileController - Versions', () => { + let controller: FileController; + let fileService: jest.Mocked; + + const mockUser = { userId: 'test-user-id', email: 'test@example.com', role: 'user' }; + + beforeEach(async () => { + const mockFileService = { + findAll: jest.fn(), + findOne: jest.fn(), + upload: jest.fn(), + update: jest.fn(), + move: jest.fn(), + delete: jest.fn(), + toggleFavorite: jest.fn(), + download: jest.fn(), + getDownloadUrl: jest.fn(), + getStats: jest.fn(), + getVersions: jest.fn(), + uploadVersion: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [FileController], + providers: [{ provide: FileService, useValue: mockFileService }], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(FileController); + fileService = module.get(FileService); + }); + + describe('getVersions', () => { + it('should call service getVersions with correct params', async () => { + const fileId = 'file-123'; + const versions = [ + mockFileVersionFactory.create({ fileId, versionNumber: 2 }), + mockFileVersionFactory.create({ fileId, versionNumber: 1 }), + ]; + fileService.getVersions.mockResolvedValue(versions); + + const result = await controller.getVersions(mockUser, fileId); + + expect(fileService.getVersions).toHaveBeenCalledWith('test-user-id', fileId); + expect(result).toEqual(versions); + }); + }); + + describe('uploadVersion', () => { + it('should call service uploadVersion with file and comment', async () => { + const fileId = 'file-123'; + const mockFile = { + originalname: 'updated.pdf', + mimetype: 'application/pdf', + size: 2048, + buffer: Buffer.from('updated content'), + } as Express.Multer.File; + const comment = 'Fixed typos'; + + const createdVersion = mockFileVersionFactory.create({ + fileId, + versionNumber: 2, + comment, + }); + fileService.uploadVersion.mockResolvedValue(createdVersion); + + const result = await controller.uploadVersion(mockUser, fileId, mockFile, comment); + + expect(fileService.uploadVersion).toHaveBeenCalledWith( + 'test-user-id', + fileId, + mockFile, + comment + ); + expect(result).toEqual(createdVersion); + }); + + it('should throw BadRequestException when no file is provided', async () => { + await expect( + controller.uploadVersion(mockUser, 'file-123', undefined as any) + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/storage/apps/backend/src/file/file.controller.ts b/apps/storage/apps/backend/src/file/file.controller.ts index c18580c2c..bb7ef0925 100644 --- a/apps/storage/apps/backend/src/file/file.controller.ts +++ b/apps/storage/apps/backend/src/file/file.controller.ts @@ -16,6 +16,7 @@ import { } from '@nestjs/common'; import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { Response } from 'express'; +import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth'; import type { CurrentUserData } from '@manacore/shared-nestjs-auth'; import { FileService } from './file.service'; @@ -42,12 +43,32 @@ export class FileController { return this.fileService.getStats(user.userId); } + @Get(':id/versions') + async getVersions(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + return this.fileService.getVersions(user.userId, id); + } + + @Post(':id/versions') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_FILE_SIZE } })) + async uploadVersion( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @UploadedFile() file: Express.Multer.File, + @Body('comment') comment?: string + ) { + if (!file) { + throw new BadRequestException('No file provided'); + } + return this.fileService.uploadVersion(user.userId, id, file, comment); + } + @Get(':id') async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { return this.fileService.findOne(user.userId, id); } @Post('upload') + @Throttle({ default: { ttl: 60000, limit: 20 } }) @UseInterceptors( FileInterceptor('file', { limits: { fileSize: MAX_FILE_SIZE }, @@ -65,6 +86,7 @@ export class FileController { } @Post('upload-multiple') + @Throttle({ default: { ttl: 60000, limit: 10 } }) @UseInterceptors( FilesInterceptor('files', MAX_FILES, { limits: { fileSize: MAX_FILE_SIZE }, diff --git a/apps/storage/apps/backend/src/file/file.service.ts b/apps/storage/apps/backend/src/file/file.service.ts index fd87fe58c..396d18fc7 100644 --- a/apps/storage/apps/backend/src/file/file.service.ts +++ b/apps/storage/apps/backend/src/file/file.service.ts @@ -3,7 +3,7 @@ import { eq, and, isNull } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { files, fileVersions } from '../db/schema'; -import type { File, NewFile, NewFileVersion } from '../db/schema'; +import type { File, FileVersion, NewFile, NewFileVersion } from '../db/schema'; import { StorageService } from '../storage/storage.service'; import { CreateFileDto, UpdateFileDto, MoveFileDto } from './dto/create-file.dto'; @@ -164,6 +164,67 @@ export class FileService { return this.storageService.getDownloadUrl(file.storageKey); } + async getVersions(userId: string, fileId: string): Promise { + // Verify file belongs to user + await this.findOne(userId, fileId); + + const { desc } = await import('drizzle-orm'); + return this.db + .select() + .from(fileVersions) + .where(eq(fileVersions.fileId, fileId)) + .orderBy(desc(fileVersions.versionNumber)); + } + + async uploadVersion( + userId: string, + fileId: string, + file: Express.Multer.File, + comment?: string + ): Promise { + const existingFile = await this.findOne(userId, fileId); + + // Upload new version to S3 + const uploadResult = await this.storageService.uploadFile( + userId, + file.buffer, + file.originalname, + file.mimetype, + 'versions' + ); + + const newVersionNumber = existingFile.currentVersion + 1; + + // Create version record + const version: NewFileVersion = { + fileId, + versionNumber: newVersionNumber, + storagePath: uploadResult.storagePath, + storageKey: uploadResult.storageKey, + size: file.size, + comment, + createdBy: userId, + }; + + const [createdVersion] = await this.db.insert(fileVersions).values(version).returning(); + + // Update file's current version and size + await this.db + .update(files) + .set({ + currentVersion: newVersionNumber, + size: file.size, + name: file.originalname, + mimeType: file.mimetype, + storagePath: uploadResult.storagePath, + storageKey: uploadResult.storageKey, + updatedAt: new Date(), + }) + .where(eq(files.id, fileId)); + + return createdVersion; + } + async getStats(userId: string): Promise<{ totalFiles: number; totalSize: number; @@ -187,9 +248,7 @@ export class FileService { count: count(files.id), }) .from(files) - .where( - and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true)) - ); + .where(and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true))); // Get recent files (last 5) const { desc } = await import('drizzle-orm'); diff --git a/apps/storage/apps/web/src/lib/api/client.ts b/apps/storage/apps/web/src/lib/api/client.ts index 9ebd5e58c..bbdb2e2aa 100644 --- a/apps/storage/apps/web/src/lib/api/client.ts +++ b/apps/storage/apps/web/src/lib/api/client.ts @@ -116,6 +116,19 @@ export interface Share { createdAt: string; } +export interface FileVersion { + id: string; + fileId: string; + versionNumber: number; + storagePath: string; + storageKey: string; + size: number; + checksum: string | null; + comment: string | null; + createdBy: string; + createdAt: string; +} + export interface Tag { id: string; userId: string; @@ -172,6 +185,20 @@ export const filesApi = { delete: (id: string) => request<{ success: boolean }>(`/files/${id}`, { method: 'DELETE' }), toggleFavorite: (id: string) => request(`/files/${id}/favorite`, { method: 'POST' }), + + getVersions: (fileId: string) => request(`/files/${fileId}/versions`), + + uploadVersion: async ( + fileId: string, + file: File, + comment?: string + ): Promise> => { + const formData = new FormData(); + formData.append('file', file); + if (comment) formData.append('comment', comment); + const result = await api.upload(`/files/${fileId}/versions`, formData); + return toLegacyResponse(result); + }, }; // Folders API