diff --git a/migrations/20240927193342-add-modification-creation-times-to-folders.js b/migrations/20240927193342-add-modification-creation-times-to-folders.js new file mode 100644 index 000000000..b2556e79a --- /dev/null +++ b/migrations/20240927193342-add-modification-creation-times-to-folders.js @@ -0,0 +1,27 @@ +'use strict'; + +const tableName = 'folders'; +const newColumn1 = 'creation_time'; +const newColumn2 = 'modification_time'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(tableName, newColumn1, { + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + allowNull: false, + }); + + await queryInterface.addColumn(tableName, newColumn2, { + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + allowNull: false, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(tableName, newColumn1); + await queryInterface.removeColumn(tableName, newColumn2); + }, +}; diff --git a/migrations/20241002040050-create-modificationtime-index-on-files.js b/migrations/20241002040050-create-modificationtime-index-on-files.js new file mode 100644 index 000000000..580b1c563 --- /dev/null +++ b/migrations/20241002040050-create-modificationtime-index-on-files.js @@ -0,0 +1,17 @@ +'use strict'; + +const tableName = 'files'; +const indexName = 'files_modification_time_index'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `CREATE INDEX CONCURRENTLY ${indexName} ON ${tableName} (modification_time);`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${indexName};`); + }, +}; diff --git a/migrations/20241002044035-create-modificationtime-index-on-folders.js b/migrations/20241002044035-create-modificationtime-index-on-folders.js new file mode 100644 index 000000000..a8817704b --- /dev/null +++ b/migrations/20241002044035-create-modificationtime-index-on-folders.js @@ -0,0 +1,17 @@ +'use strict'; + +const tableName = 'folders'; +const indexName = 'folders_modification_time_index'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `CREATE INDEX CONCURRENTLY ${indexName} ON ${tableName} (modification_time);`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${indexName};`); + }, +}; diff --git a/src/modules/file/dto/create-file.dto.ts b/src/modules/file/dto/create-file.dto.ts index d0f0c3beb..e68ec6d81 100644 --- a/src/modules/file/dto/create-file.dto.ts +++ b/src/modules/file/dto/create-file.dto.ts @@ -81,6 +81,11 @@ export class CreateFileDto { @IsOptional() date?: Date; + @ApiProperty({ + description: 'The creation time of the file (optional)', + required: false, + example: '2023-05-30T12:34:56.789Z', + }) @ApiProperty({ description: 'The date associated with the file (optional)', required: false, diff --git a/src/modules/file/file.usecase.spec.ts b/src/modules/file/file.usecase.spec.ts index 94ce45b0b..550f94b4c 100644 --- a/src/modules/file/file.usecase.spec.ts +++ b/src/modules/file/file.usecase.spec.ts @@ -753,6 +753,7 @@ describe('FileUseCases', () => { ...mockFile, plainName: newFileMeta.plainName, name: encryptedName, + modificationTime: new Date(), }, }); @@ -781,9 +782,24 @@ describe('FileUseCases', () => { expect(fileRepository.updateByUuidAndUserId).toHaveBeenCalledWith( mockFile.uuid, userMocked.id, - { plainName: newFileMeta.plainName, name: encryptedName }, + expect.objectContaining({ + plainName: newFileMeta.plainName, + name: encryptedName, + }), + ); + const { + modificationTime: _resultModificationTime, + ...resultWithoutModificationTime + } = result; + const { + modificationTime: updatedFileModificationTime, + ...updatedFileWithoutModificationTime + } = updatedFile; + + expect(resultWithoutModificationTime).toEqual( + updatedFileWithoutModificationTime, ); - expect(result).toEqual(updatedFile); + expect(mockFile).not.toBe(updatedFileModificationTime); }); }); diff --git a/src/modules/file/file.usecase.ts b/src/modules/file/file.usecase.ts index 50ecf3a53..e030775bd 100644 --- a/src/modules/file/file.usecase.ts +++ b/src/modules/file/file.usecase.ts @@ -188,15 +188,19 @@ export class FileUseCases { ); } + const modificationTime = new Date(); + await this.fileRepository.updateByUuidAndUserId(file.uuid, user.id, { plainName: newFileMetada.plainName, name: cryptoFileName, + modificationTime: modificationTime, }); return { ...file.toJSON(), name: cryptoFileName, plainName: newFileMetada.plainName, + modificationTime, }; } diff --git a/src/modules/folder/dto/create-folder.dto.ts b/src/modules/folder/dto/create-folder.dto.ts index 0836f803b..47096b1ab 100644 --- a/src/modules/folder/dto/create-folder.dto.ts +++ b/src/modules/folder/dto/create-folder.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsUUID } from 'class-validator'; +import { IsDateString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; import { FolderAttributes } from '../../folder/folder.attributes'; export class CreateFolderDto { @@ -17,4 +17,22 @@ export class CreateFolderDto { @IsNotEmpty() @IsUUID('4') parentFolderUuid: FolderAttributes['uuid']; + + @ApiProperty({ + description: 'The last modification time of the folder (optional)', + required: false, + example: '2023-05-30T12:34:56.789Z', + }) + @IsDateString() + @IsOptional() + modificationTime?: Date; + + @ApiProperty({ + description: 'The creation time of the folder (optional)', + required: false, + example: '2023-05-30T12:34:56.789Z', + }) + @IsDateString() + @IsOptional() + creationTime?: Date; } diff --git a/src/modules/folder/folder.attributes.ts b/src/modules/folder/folder.attributes.ts index 55cdd2cbd..73647bbf1 100644 --- a/src/modules/folder/folder.attributes.ts +++ b/src/modules/folder/folder.attributes.ts @@ -18,5 +18,7 @@ export interface FolderAttributes { createdAt: Date; updatedAt: Date; removedAt: Date; + creationTime: Date; + modificationTime: Date; sharings?: Sharing[]; } diff --git a/src/modules/folder/folder.controller.ts b/src/modules/folder/folder.controller.ts index 308d4793b..ce5984d07 100644 --- a/src/modules/folder/folder.controller.ts +++ b/src/modules/folder/folder.controller.ts @@ -112,11 +112,9 @@ export class FolderController { @Body() createFolderDto: CreateFolderDto, @Client() clientId: string, ) { - const { plainName, parentFolderUuid } = createFolderDto; const folder = await this.folderUseCases.createFolder( user, - plainName, - parentFolderUuid, + createFolderDto, ); this.storageNotificationService.folderCreated({ diff --git a/src/modules/folder/folder.domain.ts b/src/modules/folder/folder.domain.ts index 914d6ed48..842b65525 100644 --- a/src/modules/folder/folder.domain.ts +++ b/src/modules/folder/folder.domain.ts @@ -38,6 +38,8 @@ export class Folder implements FolderAttributes { createdAt: Date; updatedAt: Date; size: number; + creationTime: Date; + modificationTime: Date; sharings?: Sharing[]; private constructor({ @@ -59,6 +61,8 @@ export class Folder implements FolderAttributes { removed, removedAt, sharings, + creationTime, + modificationTime, }: FolderAttributes) { this.type = 'folder'; this.id = id; @@ -80,6 +84,8 @@ export class Folder implements FolderAttributes { this.removed = removed; this.removedAt = removedAt; this.sharings = sharings; + this.creationTime = creationTime; + this.modificationTime = modificationTime; } static build(folder: FolderAttributes): Folder { diff --git a/src/modules/folder/folder.model.ts b/src/modules/folder/folder.model.ts index 86511cabb..1ac13840b 100644 --- a/src/modules/folder/folder.model.ts +++ b/src/modules/folder/folder.model.ts @@ -18,6 +18,7 @@ import { FolderAttributes } from './folder.attributes'; import { SharingModel } from '../sharing/models'; import { Sharing } from '../sharing/sharing.domain'; import { WorkspaceItemUserModel } from '../workspaces/models/workspace-items-users.model'; +import { Sequelize } from 'sequelize'; @Table({ underscored: true, @@ -74,6 +75,14 @@ export class FolderModel extends Model implements FolderAttributes { @Column removed: boolean; + @Default(Sequelize.fn('NOW')) + @Column + creationTime: Date; + + @Default(Sequelize.fn('NOW')) + @Column + modificationTime: Date; + @AllowNull @Column deletedAt: Date; diff --git a/src/modules/folder/folder.usecase.spec.ts b/src/modules/folder/folder.usecase.spec.ts index 020bac959..401fa7009 100644 --- a/src/modules/folder/folder.usecase.spec.ts +++ b/src/modules/folder/folder.usecase.spec.ts @@ -13,7 +13,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import { v4 } from 'uuid'; -import { Folder, FolderAttributes, FolderOptions } from './folder.domain'; +import { Folder, FolderOptions } from './folder.domain'; import { BridgeModule } from '../../externals/bridge/bridge.module'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { CryptoService } from '../../externals/crypto/crypto.service'; @@ -92,23 +92,7 @@ describe('FolderUseCases', () => { describe('move folder to trash use case', () => { it('calls moveFolderToTrash and return file', async () => { - const mockFolder = Folder.build({ - id: 1, - parentId: null, - parentUuid: null, - name: 'name', - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - uuid: '', - plainName: '', - removed: false, - removedAt: null, - }); + const mockFolder = newFolder(); jest .spyOn(folderRepository, 'updateByFolderId') .mockResolvedValue(mockFolder); @@ -128,41 +112,23 @@ describe('FolderUseCases', () => { describe('move multiple folders to trash', () => { const rootFolderBucket = 'bucketRoot'; - const mockFolder = Folder.build({ - id: 1, - parentId: null, - parentUuid: null, - name: 'name', - bucket: rootFolderBucket, - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - uuid: '2545feaf-4d6b-40d8-9bf8-550285268bd3', - plainName: '', - removed: false, - removedAt: null, - }); + const mockFolder = newFolder(); it('When uuid and id are passed and there is a backup and drive folder, then backups and drive folders should be updated', async () => { - const mockBackupFolder = Folder.build({ - id: 1, - parentId: null, - parentUuid: null, - name: 'name', - bucket: 'bucketIdforBackup', - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - uuid: '656a3abb-36ab-47ee-8303-6e4198f2a32a', - plainName: '', - removed: false, - removedAt: null, + const mockBackupFolder = newFolder({ + attributes: { + id: 1, + parentId: null, + parentUuid: null, + name: 'name', + bucket: 'bucketIdforBackup', + userId: 1, + encryptVersion: '03-aes', + deleted: true, + plainName: '', + removed: false, + removedAt: null, + }, }); jest @@ -224,24 +190,14 @@ describe('FolderUseCases', () => { describe('get folder use case', () => { it('calls getFolder and return folder', async () => { - const mockFolder = Folder.build({ - id: 1, - parentId: null, - parentUuid: null, - name: 'name', - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - user: null, - parent: null, - uuid: '', - plainName: '', - removed: false, - removedAt: null, + const mockFolder = newFolder({ + attributes: { + bucket: 'bucket', + id: 1, + parent: null, + userId: 1, + deleted: true, + }, }); jest .spyOn(folderRepository, 'findById') @@ -290,22 +246,15 @@ describe('FolderUseCases', () => { const nameEncrypted = 'ONzgORtJ77qI28jDnr+GjwJn6xELsAEqsn3FKlKNYbHR7Z129AD/WOMkAChEKx6rm7hOER2drdmXmC296dvSXtE5y5os0XCS554YYc+dcCMIkot/v6Wu6rlBC5MPlngR+CkmvA=='; const mockFolders = [ - Folder.build({ - id: 4, - parentId: 1, - parentUuid: v4(), - name: nameEncrypted, - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - uuid: '', - plainName: '', - removed: false, - removedAt: null, + newFolder({ + attributes: { + name: nameEncrypted, + bucket: 'bucket', + parentId: 1, + id: 4, + deleted: true, + userId: 1, + }, }), ]; jest @@ -364,24 +313,17 @@ describe('FolderUseCases', () => { emailVerified: false, }); const folderId = 2713105696; - const folder = Folder.build({ - id: folderId, - parentId: 3388762609, - parentUuid: v4(), - name: 'name', - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - user: userOwnerMock, - parent: null, - uuid: '', - plainName: '', - removed: false, - removedAt: null, + const folder = newFolder({ + attributes: { + id: folderId, + parentId: 3388762609, + name: 'name', + bucket: 'bucket', + userId: 1, + encryptVersion: '03-aes', + deleted: true, + deletedAt: new Date(), + }, }); jest @@ -424,26 +366,18 @@ describe('FolderUseCases', () => { emailVerified: false, }); const folderId = 2713105696; - const folder = Folder.build({ - id: folderId, - parentId: 3388762609, - parentUuid: v4(), - name: 'name', - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: false, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - user: userOwnerMock, - parent: null, - uuid: '', - plainName: '', - removed: false, - removedAt: null, + const folder = newFolder({ + attributes: { + id: folderId, + parentId: 3388762609, + name: 'name', + bucket: 'bucket', + userId: 1, + encryptVersion: '03-aes', + deleted: false, + deletedAt: new Date(), + }, }); - jest .spyOn(folderRepository, 'deleteById') .mockImplementationOnce(() => Promise.resolve()); @@ -489,24 +423,17 @@ describe('FolderUseCases', () => { emailVerified: false, }); const folderId = 2713105696; - const folder = Folder.build({ - id: folderId, - parentId: null, - parentUuid: null, - name: 'name', - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: false, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - user: userOwnerMock, - parent: null, - uuid: '', - plainName: '', - removed: false, - removedAt: null, + const folder = newFolder({ + attributes: { + id: folderId, + parentId: null, + name: 'name', + bucket: 'bucket', + userId: 1, + encryptVersion: '03-aes', + deleted: true, + deletedAt: new Date(), + }, }); jest @@ -577,7 +504,7 @@ describe('FolderUseCases', () => { }); expect(() => service.decryptFolderName(folder)).toThrow( - new Error('Unable to decrypt folder name'), + 'Unable to decrypt folder name', ); }); }); @@ -895,7 +822,10 @@ describe('FolderUseCases', () => { jest.spyOn(folderRepository, 'findOne').mockResolvedValueOnce(null); await expect( - service.createFolder(userMocked, folderName, parentFolder.uuid), + service.createFolder(userMocked, { + plainName: folderName, + parentFolderUuid: parentFolder.uuid, + }), ).rejects.toThrow(InvalidParentFolderException); }); @@ -908,11 +838,17 @@ describe('FolderUseCases', () => { .mockResolvedValueOnce(parentFolder); await expect( - service.createFolder(userMocked, notValidName, parentFolder.uuid), + service.createFolder(userMocked, { + plainName: notValidName, + parentFolderUuid: parentFolder.uuid, + }), ).rejects.toThrow(BadRequestException); await expect( - service.createFolder(userMocked, 'Invalid/Name', parentFolder.uuid), + service.createFolder(userMocked, { + plainName: 'Invalid/Name', + parentFolderUuid: parentFolder.uuid, + }), ).rejects.toThrow(BadRequestException); }); @@ -930,7 +866,10 @@ describe('FolderUseCases', () => { .mockResolvedValueOnce(existingFolder); await expect( - service.createFolder(userMocked, folderName, parentFolder.uuid), + service.createFolder(userMocked, { + plainName: folderName, + parentFolderUuid: parentFolder.uuid, + }), ).rejects.toThrow(BadRequestException); }); @@ -944,6 +883,8 @@ describe('FolderUseCases', () => { parentUuid: parentFolder.uuid, name: encryptedFolderName, plainName: folderName, + creationTime: new Date('2024-09-08T12:00:00Z'), + modificationTime: new Date('2024-09-12T12:00:00Z'), }, }); @@ -960,11 +901,12 @@ describe('FolderUseCases', () => { .spyOn(folderRepository, 'createWithAttributes') .mockResolvedValueOnce(newFolderCreated); - const result = await service.createFolder( - userMocked, - folderName, - parentFolder.uuid, - ); + const result = await service.createFolder(userMocked, { + plainName: folderName, + parentFolderUuid: parentFolder.uuid, + creationTime: new Date('2024-09-08T12:00:00Z'), + modificationTime: new Date('2024-09-12T12:00:00Z'), + }); expect(result).toEqual(newFolderCreated); }); @@ -1160,6 +1102,7 @@ describe('FolderUseCases', () => { attributes: { name: encryptedName, plainName: newFolderMetadata.plainName, + modificationTime: new Date(), }, }); @@ -1179,8 +1122,12 @@ describe('FolderUseCases', () => { expect(folderRepository.updateByFolderId).toHaveBeenCalledWith( mockFolder.id, - { plainName: newFolderMetadata.plainName, name: encryptedName }, + expect.objectContaining({ + plainName: newFolderMetadata.plainName, + name: encryptedName, + }), ); + expect(result).toEqual(updatedFolder); }); }); diff --git a/src/modules/folder/folder.usecase.ts b/src/modules/folder/folder.usecase.ts index 3494e4302..cd3fba090 100644 --- a/src/modules/folder/folder.usecase.ts +++ b/src/modules/folder/folder.usecase.ts @@ -29,6 +29,7 @@ import { UpdateFolderMetaDto } from './dto/update-folder-meta.dto'; import { WorkspaceAttributes } from '../workspaces/attributes/workspace.attributes'; import { FileUseCases } from '../file/file.usecase'; import { File, FileStatus } from '../file/file.domain'; +import { CreateFolderDto } from './dto/create-folder.dto'; const invalidName = /[\\/]|^\s*$/; @@ -339,7 +340,11 @@ export class FolderUseCases { const updatedFolder = await this.folderRepository.updateByFolderId( folder.id, - { plainName: newFolderMetadata.plainName, name: cryptoFileName }, + { + plainName: newFolderMetadata.plainName, + name: cryptoFileName, + modificationTime: new Date(), + }, ); return updatedFolder; @@ -347,8 +352,7 @@ export class FolderUseCases { async createFolder( creator: User, - name: FolderAttributes['plainName'], - parentFolderUuid: FolderAttributes['uuid'], + newFolderDto: CreateFolderDto, ): Promise { const isAGuestOnSharedWorkspace = creator.email !== creator.bridgeUser; let user = creator; @@ -363,7 +367,7 @@ export class FolderUseCases { } const parentFolder = await this.folderRepository.findOne({ - uuid: parentFolderUuid, + uuid: newFolderDto.parentFolderUuid, userId: user.id, }); @@ -373,13 +377,16 @@ export class FolderUseCases { ); } - if (name === '' || invalidName.test(name)) { + if ( + newFolderDto.plainName === '' || + invalidName.test(newFolderDto.plainName) + ) { throw new BadRequestException('Invalid folder name'); } const nameAlreadyInUse = await this.folderRepository.findOne({ parentId: parentFolder.id, - plainName: name, + plainName: newFolderDto.plainName, deleted: false, }); @@ -390,7 +397,7 @@ export class FolderUseCases { } const encryptedFolderName = this.cryptoService.encryptName( - name, + newFolderDto.plainName, parentFolder.id, ); @@ -398,7 +405,7 @@ export class FolderUseCases { uuid: v4(), userId: user.id, name: encryptedFolderName, - plainName: name, + plainName: newFolderDto.plainName, parentId: parentFolder.id, parentUuid: parentFolder.uuid, encryptVersion: '03-aes', @@ -409,6 +416,8 @@ export class FolderUseCases { updatedAt: new Date(), removedAt: null, deletedAt: null, + modificationTime: newFolderDto.modificationTime || new Date(), + creationTime: newFolderDto.creationTime || new Date(), }); return folder; diff --git a/src/modules/share/share.usecase.spec.ts b/src/modules/share/share.usecase.spec.ts index bf8049ff1..9292e2b04 100644 --- a/src/modules/share/share.usecase.spec.ts +++ b/src/modules/share/share.usecase.spec.ts @@ -20,6 +20,7 @@ import { SequelizeShareRepository } from './share.repository'; import { ShareUseCases } from './share.usecase'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { createMock } from '@golevelup/ts-jest'; +import { newFolder } from '../../../test/fixtures'; describe('Share Use Cases', () => { let service: ShareUseCases; @@ -87,21 +88,19 @@ describe('Share Use Cases', () => { lastPasswordChangedAt: new Date(), emailVerified: false, }); - const mockFolder = Folder.build({ - id: 1, - parentId: null, - name: 'name', - bucket: 'bucket', - userId: 1, - encryptVersion: '03-aes', - deleted: true, - deletedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - uuid: '', - plainName: '', - removed: false, - removedAt: null, + const mockFolder = newFolder({ + attributes: { + id: 1, + parentId: null, + name: 'name', + bucket: 'bucket', + userId: 1, + encryptVersion: '03-aes', + deleted: true, + plainName: '', + removed: false, + removedAt: null, + }, }); const mockFile = File.build({ id: 1, diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 1dfdceafb..d465a1a94 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -809,11 +809,10 @@ export class WorkspacesUsecases { workspace.workspaceUserId, ); - const createdFolder = await this.folderUseCases.createFolder( - networkUser, - createFolderDto.name, + const createdFolder = await this.folderUseCases.createFolder(networkUser, { + plainName: createFolderDto.name, parentFolderUuid, - ); + }); const createdItemFolder = await this.workspaceRepository.createItem({ itemId: createdFolder.uuid, @@ -1341,8 +1340,7 @@ export class WorkspacesUsecases { const rootFolder = await this.folderUseCases.createFolder( workspaceNetworkUser, - v4(), - workspaceRootFolderId, + { plainName: v4(), parentFolderUuid: workspaceRootFolderId }, ); return rootFolder; diff --git a/test/fixtures.ts b/test/fixtures.ts index 74efe4fce..57846cb6b 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -90,6 +90,12 @@ export const newFolder = (params?: NewFolderParams): Folder => { removed: false, deletedAt: undefined, removedAt: undefined, + creationTime: randomDataGenerator.date(), + modificationTime: new Date( + randomDataGenerator.date({ + min: randomCreatedAt, + }), + ), }); params?.attributes &&