From a33553b6b1f83b61959b078b9073f74c55146bfd Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 15 Nov 2024 07:31:09 -0600 Subject: [PATCH 1/8] feat: add workspace logs functionality with migration, model, and interceptor --- ...41113173746-create-workspace-logs-table.js | 84 +++++++++++++++++++ .../attributes/workspace-logs.attributes.ts | 28 +++++++ .../workspacesLogs.interceptor.ts | 81 ++++++++++++++++++ .../workspaces/models/workspace-logs.model.ts | 51 +++++++++++ .../repositories/workspaces.repository.ts | 8 ++ .../workspaces/workspaces.controller.ts | 3 + src/modules/workspaces/workspaces.module.ts | 2 + 7 files changed, 257 insertions(+) create mode 100644 migrations/20241113173746-create-workspace-logs-table.js create mode 100644 src/modules/workspaces/attributes/workspace-logs.attributes.ts create mode 100644 src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts create mode 100644 src/modules/workspaces/models/workspace-logs.model.ts diff --git a/migrations/20241113173746-create-workspace-logs-table.js b/migrations/20241113173746-create-workspace-logs-table.js new file mode 100644 index 00000000..4ccffb29 --- /dev/null +++ b/migrations/20241113173746-create-workspace-logs-table.js @@ -0,0 +1,84 @@ +'use strict'; + +const tableName = 'workspace_logs'; +const indexName = 'workspace_id_workspace_logs_index'; +const indexName2 = 'created_at_workspace_logs_index'; +const indexName3 = 'platform_workspace_logs_index'; +const indexName4 = 'creator_workspace_logs_index'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.DataTypes.UUID, + primaryKey: true, + defaultValue: Sequelize.DataTypes.UUIDV4, + }, + workspace_id: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + references: { + model: 'workspaces', + key: 'id', + }, + onDelete: 'CASCADE', + }, + creator: { + type: Sequelize.STRING(36), + allowNull: false, + references: { + model: 'users', + key: 'uuid', + }, + onDelete: 'CASCADE', + }, + type: { + type: Sequelize.DataTypes.STRING, + allowNull: false, + }, + paltform: { + type: Sequelize.DataTypes.STRING, + allowNull: false, + }, + entity_id: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + }, + created_at: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + await queryInterface.addIndex(tableName, { + fields: ['workspace_id'], + name: indexName, + }); + await queryInterface.addIndex(tableName, { + fields: ['created_at'], + name: indexName2, + }); + await queryInterface.addIndex(tableName, { + fields: ['paltform'], + name: indexName3, + }); + await queryInterface.addIndex(tableName, { + fields: ['creator'], + name: indexName4, + }); + }, + + async down(queryInterface) { + await queryInterface.removeIndex(tableName, indexName); + await queryInterface.removeIndex(tableName, indexName2); + await queryInterface.removeIndex(tableName, indexName3); + await queryInterface.removeIndex(tableName, indexName4); + await queryInterface.dropTable(tableName); + }, +}; diff --git a/src/modules/workspaces/attributes/workspace-logs.attributes.ts b/src/modules/workspaces/attributes/workspace-logs.attributes.ts new file mode 100644 index 00000000..0453a234 --- /dev/null +++ b/src/modules/workspaces/attributes/workspace-logs.attributes.ts @@ -0,0 +1,28 @@ +export enum WorkspaceLogType { + LOGIN = 'LOGIN', + RESET_PASSWORD = 'RESET_PASSWORD', + LOGOUT = 'LOGOUT', + SHARE_FILE = 'SHARE_FILE', + SHARE_FOLDER = 'SHARE_FOLDER', + DELETE_FILE = 'DELETE_FILE', + UPLOAD_FILE = 'UPLOAD_FILE', + DOWNLOAD_FILE = 'DOWNLOAD_FILE', +} + +export enum WorkspaceLogPlatform { + WEB = 'WEB', + MOBILE = 'MOBILE', + DESKTOP = 'DESKTOP', + UNSPECIFIED = 'UNSPECIFIED', +} + +export interface WorkspaceLogAttributes { + id: string; + workspaceId: string; + creator: string; + type: WorkspaceLogType; + platform: WorkspaceLogPlatform; + entityId?: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts b/src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts new file mode 100644 index 00000000..7171a243 --- /dev/null +++ b/src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts @@ -0,0 +1,81 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + applyDecorators, + UseInterceptors, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; +import { + WorkspaceLogAttributes, + WorkspaceLogPlatform, +} from '../attributes/workspace-logs.attributes'; + +@Injectable() +export class WorkspacesLogsInterceptor implements NestInterceptor { + constructor( + private readonly workspaceRepository: SequelizeWorkspaceRepository, + private readonly logAction: WorkspaceLogAttributes['type'], + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const user = request.user; + const client = request.headers['internxt-client']; + + let platform: WorkspaceLogPlatform; + + switch (client) { + case 'drive-web': + platform = WorkspaceLogPlatform.WEB; + break; + case 'drive-mobile': + platform = WorkspaceLogPlatform.MOBILE; + break; + case 'drive-desktop': + platform = WorkspaceLogPlatform.DESKTOP; + break; + default: + platform = WorkspaceLogPlatform.UNSPECIFIED; + } + + return next.handle().pipe( + tap({ + next: async () => { + await this.workspaceRepository.registerLog({ + platform, + creator: user.id, + type: this.logAction, + workspaceId: request.params.id, + createdAt: new Date(), + updatedAt: new Date(), + }); + }, + }), + ); + } +} + +export function LogAction(logAction: WorkspaceLogAttributes['type']) { + return applyDecorators( + UseInterceptors(WorkspacesLogsInterceptor), + (target: any, key: string, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + descriptor.value = function (...args: any[]) { + const context = args[0]; + const workspaceRepository = context + .switchToHttp() + .getRequest() + .app.get(SequelizeWorkspaceRepository); + const interceptor = new WorkspacesLogsInterceptor( + workspaceRepository, + logAction, + ); + return interceptor.intercept(context, originalMethod.apply(this, args)); + }; + }, + ); +} diff --git a/src/modules/workspaces/models/workspace-logs.model.ts b/src/modules/workspaces/models/workspace-logs.model.ts new file mode 100644 index 00000000..8f42c0e8 --- /dev/null +++ b/src/modules/workspaces/models/workspace-logs.model.ts @@ -0,0 +1,51 @@ +import { + Model, + Table, + Column, + DataType, + PrimaryKey, + ForeignKey, + BelongsTo, +} from 'sequelize-typescript'; +import { UserModel } from '../../user/user.model'; +import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'workspace_logs', +}) +export class WorkspaceLogModel extends Model { + @PrimaryKey + @Column({ type: DataType.UUID, defaultValue: DataType.UUIDV4 }) + id: string; + + @ForeignKey(() => UserModel) + @Column({ type: DataType.UUID, allowNull: false }) + creator: string; + + @BelongsTo(() => UserModel, { + foreignKey: 'creator', + targetKey: 'uuid', + as: 'user', + }) + user: UserModel; + + @Column({ + type: DataType.STRING, + allowNull: false, + }) + type: WorkspaceLogAttributes['type']; + + @Column(DataType.STRING) + entity: string; + + @Column(DataType.STRING) + entityId: string; + + @Column + createdAt: Date; + + @Column + updatedAt: Date; +} diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index f673eff2..8038ea96 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -23,6 +23,8 @@ import { FileModel } from '../../file/file.model'; import { FileAttributes } from '../../file/file.domain'; import { FolderAttributes } from '../../folder/folder.domain'; import { FolderModel } from '../../folder/folder.model'; +import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; +import { WorkspaceLogModel } from '../models/workspace-logs.model'; @Injectable() export class SequelizeWorkspaceRepository { @@ -35,6 +37,8 @@ export class SequelizeWorkspaceRepository { private readonly modelWorkspaceInvite: typeof WorkspaceInviteModel, @InjectModel(WorkspaceItemUserModel) private readonly modelWorkspaceItemUser: typeof WorkspaceItemUserModel, + @InjectModel(WorkspaceLogModel) + private readonly modelWorkspaceLog: typeof WorkspaceLogModel, ) {} async findById(id: WorkspaceAttributes['id']): Promise { const workspace = await this.modelWorkspace.findByPk(id); @@ -469,6 +473,10 @@ export class SequelizeWorkspaceRepository { }); } + async registerLog(log: Omit): Promise { + await this.modelWorkspaceLog.create(log); + } + toDomain(model: WorkspaceModel): Workspace { return Workspace.build({ ...model.toJSON(), diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index d8506680..5ca8f892 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -68,6 +68,8 @@ import { Public } from '../auth/decorators/public.decorator'; import { BasicPaginationDto } from '../../common/dto/basic-pagination.dto'; import { GetSharedItemsDto } from './dto/get-shared-items.dto'; import { GetSharedWithDto } from './dto/shared-with.dto'; +import { LogAction } from './interceptors/workspacesLogs.interceptor'; +import { WorkspaceLogType } from './attributes/workspace-logs.attributes'; @ApiTags('Workspaces') @Controller('workspaces') @@ -542,6 +544,7 @@ export class WorkspacesController { @UseGuards(WorkspaceGuard, SharingPermissionsGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) @RequiredSharingPermissions(SharingActionName.UploadFile) + @LogAction(WorkspaceLogType.UPLOAD_FILE) async createFile( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 14de6dc8..d6408cbe 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -23,6 +23,7 @@ import { HttpClientModule } from 'src/externals/http/http.module'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { FuzzySearchModule } from '../fuzzy-search/fuzzy-search.module'; +import { WorkspaceLogModel } from './models/workspace-logs.model'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { FuzzySearchModule } from '../fuzzy-search/fuzzy-search.module'; WorkspaceTeamUserModel, WorkspaceUserModel, WorkspaceInviteModel, + WorkspaceLogModel, ]), forwardRef(() => UserModule), forwardRef(() => FolderModule), From fbcc8aa3316935a38348fb7d9711e326f94cc7de Mon Sep 17 00:00:00 2001 From: Ederson Date: Mon, 16 Dec 2024 18:32:47 -0400 Subject: [PATCH 2/8] feat: handle access logs with an interceptor --- ...41113173746-create-workspace-logs-table.js | 4 +- src/modules/sharing/sharing.controller.ts | 3 + src/modules/sharing/sharing.module.ts | 2 + src/modules/trash/trash.controller.ts | 3 + src/modules/trash/trash.module.ts | 3 +- .../attributes/workspace-logs.attributes.ts | 12 +- .../workspace-log-action.decorator.ts | 9 + .../domains/workspace-log.domain.ts | 65 ++ .../workspaces/dto/get-workspace-logs.ts | 51 ++ .../guards/workspaces.guard.spec.ts | 105 +++ .../workspaces/guards/workspaces.guard.ts | 62 +- .../workspaces-logs.interceptor.spec.ts | 705 ++++++++++++++++++ .../workspaces-logs.interceptor.ts | 334 +++++++++ .../workspacesLogs.interceptor.ts | 81 -- .../workspaces/models/workspace-logs.model.ts | 30 +- .../workspaces.repository.spec.ts | 289 +++++++ .../repositories/workspaces.repository.ts | 158 +++- .../workspaces/workspaces.controller.spec.ts | 112 ++- .../workspaces/workspaces.controller.ts | 46 +- .../workspaces/workspaces.usecase.spec.ts | 185 +++++ src/modules/workspaces/workspaces.usecase.ts | 29 + 21 files changed, 2190 insertions(+), 98 deletions(-) create mode 100644 src/modules/workspaces/decorators/workspace-log-action.decorator.ts create mode 100644 src/modules/workspaces/domains/workspace-log.domain.ts create mode 100644 src/modules/workspaces/dto/get-workspace-logs.ts create mode 100644 src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts create mode 100644 src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts delete mode 100644 src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts diff --git a/migrations/20241113173746-create-workspace-logs-table.js b/migrations/20241113173746-create-workspace-logs-table.js index 4ccffb29..63912e5c 100644 --- a/migrations/20241113173746-create-workspace-logs-table.js +++ b/migrations/20241113173746-create-workspace-logs-table.js @@ -37,7 +37,7 @@ module.exports = { type: Sequelize.DataTypes.STRING, allowNull: false, }, - paltform: { + platform: { type: Sequelize.DataTypes.STRING, allowNull: false, }, @@ -65,7 +65,7 @@ module.exports = { name: indexName2, }); await queryInterface.addIndex(tableName, { - fields: ['paltform'], + fields: ['platform'], name: indexName3, }); await queryInterface.addIndex(tableName, { diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 6bdf93bd..97c07e71 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -64,6 +64,8 @@ import { WorkspacesInBehalfGuard, } from '../workspaces/guards/workspaces-resources-in-behalf.decorator'; import { GetDataFromRequest } from '../../common/extract-data-from-request'; +import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator'; +import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('Sharing') @Controller('sharings') @@ -619,6 +621,7 @@ export class SharingController { { sourceKey: 'body', fieldName: 'itemType' }, ]) @WorkspacesInBehalfGuard() + @WorkspaceLogAction(WorkspaceLogType.SHARE) createSharing( @UserDecorator() user, @Body() acceptInviteDto: CreateSharingDto, diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index ed49ddb0..8ceec46b 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -23,6 +23,7 @@ import { AppSumoModule } from '../app-sumo/app-sumo.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { HttpClientModule } from '../../externals/http/http.module'; import { WorkspacesModule } from '../workspaces/workspaces.module'; +import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; @Module({ imports: [ @@ -48,6 +49,7 @@ import { WorkspacesModule } from '../workspaces/workspaces.module'; SharingService, SequelizeSharingRepository, SequelizeUserReferralsRepository, + SequelizeWorkspaceRepository, PaymentsService, ], exports: [SharingService, SequelizeSharingRepository, SequelizeModule], diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index 63688f3f..11302e22 100644 --- a/src/modules/trash/trash.controller.ts +++ b/src/modules/trash/trash.controller.ts @@ -49,6 +49,8 @@ import { GetDataFromRequest } from '../../common/extract-data-from-request'; import { StorageNotificationService } from '../../externals/notifications/storage.notifications.service'; import { BasicPaginationDto } from '../../common/dto/basic-pagination.dto'; import { Requester } from '../auth/decorators/requester.decorator'; +import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator'; +import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('Trash') @Controller('storage/trash') @@ -266,6 +268,7 @@ export class TrashController { }) @GetDataFromRequest([{ sourceKey: 'body', fieldName: 'items' }]) @WorkspacesInBehalfGuard(WorkspaceResourcesAction.DeleteItemsFromTrash) + @WorkspaceLogAction(WorkspaceLogType.DELETE) async deleteItems( @Body() deleteItemsDto: DeleteItemsDto, @UserDecorator() user: User, diff --git a/src/modules/trash/trash.module.ts b/src/modules/trash/trash.module.ts index 6bb32aa9..d96ed22a 100644 --- a/src/modules/trash/trash.module.ts +++ b/src/modules/trash/trash.module.ts @@ -11,6 +11,7 @@ import { ShareModel } from '../share/share.repository'; import { FileModel } from '../file/file.model'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { SharingModule } from '../sharing/sharing.module'; +import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; @Module({ imports: [ @@ -26,6 +27,6 @@ import { SharingModule } from '../sharing/sharing.module'; NotificationModule, ], controllers: [TrashController], - providers: [Logger, TrashUseCases], + providers: [Logger, TrashUseCases, SequelizeWorkspaceRepository], }) export class TrashModule {} diff --git a/src/modules/workspaces/attributes/workspace-logs.attributes.ts b/src/modules/workspaces/attributes/workspace-logs.attributes.ts index 0453a234..5194b7d4 100644 --- a/src/modules/workspaces/attributes/workspace-logs.attributes.ts +++ b/src/modules/workspaces/attributes/workspace-logs.attributes.ts @@ -1,12 +1,14 @@ export enum WorkspaceLogType { LOGIN = 'LOGIN', - RESET_PASSWORD = 'RESET_PASSWORD', + CHANGED_PASSWORD = 'CHANGED_PASSWORD', LOGOUT = 'LOGOUT', + SHARE = 'SHARE', SHARE_FILE = 'SHARE_FILE', SHARE_FOLDER = 'SHARE_FOLDER', + DELETE = 'DELETE', DELETE_FILE = 'DELETE_FILE', - UPLOAD_FILE = 'UPLOAD_FILE', - DOWNLOAD_FILE = 'DOWNLOAD_FILE', + DELETE_FOLDER = 'DELETE_FOLDER', + DELETE_ALL = 'DELETE_ALL', } export enum WorkspaceLogPlatform { @@ -23,6 +25,10 @@ export interface WorkspaceLogAttributes { type: WorkspaceLogType; platform: WorkspaceLogPlatform; entityId?: string; + user?: any; + workspace?: any; + file?: any; + folder?: any; createdAt: Date; updatedAt: Date; } diff --git a/src/modules/workspaces/decorators/workspace-log-action.decorator.ts b/src/modules/workspaces/decorators/workspace-log-action.decorator.ts new file mode 100644 index 00000000..f4345930 --- /dev/null +++ b/src/modules/workspaces/decorators/workspace-log-action.decorator.ts @@ -0,0 +1,9 @@ +import { applyDecorators, SetMetadata, UseInterceptors } from '@nestjs/common'; +import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; +import { WorkspacesLogsInterceptor } from './../interceptors/workspaces-logs.interceptor'; + +export const WorkspaceLogAction = (action: WorkspaceLogType) => + applyDecorators( + SetMetadata('workspaceLogAction', action), + UseInterceptors(WorkspacesLogsInterceptor), + ); diff --git a/src/modules/workspaces/domains/workspace-log.domain.ts b/src/modules/workspaces/domains/workspace-log.domain.ts new file mode 100644 index 00000000..64ab33b6 --- /dev/null +++ b/src/modules/workspaces/domains/workspace-log.domain.ts @@ -0,0 +1,65 @@ +import { + WorkspaceLogAttributes, + WorkspaceLogPlatform, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; + +export class WorkspaceLog implements WorkspaceLogAttributes { + id: string; + workspaceId: string; + creator: string; + type: WorkspaceLogType; + platform: WorkspaceLogPlatform; + entityId?: string; + user?: any; + workspace?: any; + file?: any; + folder?: any; + createdAt: Date; + updatedAt: Date; + + constructor({ + id, + workspaceId, + creator, + type, + platform, + entityId, + user, + workspace, + file, + folder, + createdAt, + updatedAt, + }: WorkspaceLogAttributes) { + this.id = id; + this.workspaceId = workspaceId; + this.creator = creator; + this.type = type; + this.platform = platform; + this.entityId = entityId; + this.user = user; + this.workspace = workspace; + this.file = file; + this.folder = folder; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static build(user: WorkspaceLogAttributes): WorkspaceLog { + return new WorkspaceLog(user); + } + + toJSON() { + return { + id: this.id, + workspaceId: this.workspaceId, + creator: this.creator, + type: this.type, + platform: this.platform, + entityId: this.entityId, + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/workspaces/dto/get-workspace-logs.ts b/src/modules/workspaces/dto/get-workspace-logs.ts new file mode 100644 index 00000000..0153a2f2 --- /dev/null +++ b/src/modules/workspaces/dto/get-workspace-logs.ts @@ -0,0 +1,51 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsEnum, + IsInt, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { OrderBy } from './../../../common/order.type'; +import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; +import { Type } from 'class-transformer'; + +export class GetWorkspaceLogsDto { + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsEnum(WorkspaceLogType, { each: true }) + activity?: WorkspaceLogType[]; + + @ApiPropertyOptional({ + description: 'Order by', + example: 'name:asc', + }) + @IsOptional() + @IsString() + orderBy?: OrderBy; + + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + offset?: number; + + @IsOptional() + @IsString() + member?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + lastDays?: number; +} diff --git a/src/modules/workspaces/guards/workspaces.guard.spec.ts b/src/modules/workspaces/guards/workspaces.guard.spec.ts index c8bc1730..761f8bcf 100644 --- a/src/modules/workspaces/guards/workspaces.guard.spec.ts +++ b/src/modules/workspaces/guards/workspaces.guard.spec.ts @@ -3,6 +3,7 @@ import { BadRequestException, ExecutionContext, ForbiddenException, + Logger, NotFoundException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; @@ -21,6 +22,8 @@ import { import { WorkspaceUser } from '../domains/workspace-user.domain'; import { WorkspaceTeamUser } from '../domains/workspace-team-user.domain'; import { v4 } from 'uuid'; +import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; +import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; describe('WorkspaceGuard', () => { let guard: WorkspaceGuard; @@ -60,6 +63,44 @@ describe('WorkspaceGuard', () => { expect(canUserAccess).toBeFalsy(); }); + it('When workspaceLogAction is DELETE_ALL, then it should set request.items', async () => { + const mockGetItems = [{ type: WorkspaceItemType.File, uuid: v4() }]; + const workspaceOwner = newUser(); + const workspace = newWorkspace({ owner: workspaceOwner }); + + const mockRequest = { + user: workspaceOwner, + params: { workspaceId: workspace.id }, + }; + + const mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + getHandler: () => jest.fn(), + } as unknown as ExecutionContext; + + jest.spyOn(reflector, 'get').mockReturnValueOnce({ + requiredRole: WorkspaceRole.OWNER, + accessContext: AccessContext.WORKSPACE, + idSource: 'params', + }); + jest + .spyOn(reflector, 'get') + .mockReturnValueOnce(WorkspaceLogType.DELETE_ALL); + + workspaceUseCases.findUserAndWorkspace.mockResolvedValue({ + workspace, + workspaceUser: {} as WorkspaceUser, + }); + + jest.spyOn(guard, 'getItems').mockResolvedValueOnce(mockGetItems); + + const request = mockContext.switchToHttp().getRequest(); + await guard.canActivate(mockContext); + expect(request.items).toEqual(mockGetItems); + }); + describe('Workspace Permissions', () => { it('When workspace id is not valid, then throw ', async () => { const user = newUser(); @@ -457,6 +498,70 @@ describe('WorkspaceGuard', () => { ); }); }); + + describe('getItems()', () => { + const user = newUser(); + const workspaceId = v4(); + + it('When there are trashed files and folders, then it should return an array of trashed items', async () => { + const mockFiles = [{ uuid: 'file-uuid-1' }, { uuid: 'file-uuid-2' }]; + const mockFolders = [{ uuid: 'folder-uuid-1' }, { uuid: null }]; + + workspaceUseCases.getWorkspaceUserTrashedItems + .mockResolvedValueOnce({ result: mockFiles } as any) + .mockResolvedValueOnce({ result: mockFolders } as any); + + const result = await guard.getItems(user, workspaceId); + + expect(result).toEqual([ + { type: WorkspaceItemType.File, uuid: 'file-uuid-1' }, + { type: WorkspaceItemType.File, uuid: 'file-uuid-2' }, + { type: WorkspaceItemType.Folder, uuid: 'folder-uuid-1' }, + ]); + }); + + it('When there are no trashed items, then it should return an empty array', async () => { + workspaceUseCases.getWorkspaceUserTrashedItems + .mockResolvedValueOnce({ result: [] }) + .mockResolvedValueOnce({ result: [] }); + + const result = await guard.getItems(user, workspaceId); + + expect(result).toEqual([]); + }); + + it('When there are items without a uuid, then it should filter them out', async () => { + const mockFiles = [{ uuid: null }, { uuid: 'file-uuid-1' }]; + const mockFolders = [{ uuid: 'folder-uuid-1' }, { uuid: null }]; + + workspaceUseCases.getWorkspaceUserTrashedItems + .mockResolvedValueOnce({ result: mockFiles } as any) + .mockResolvedValueOnce({ result: mockFolders } as any); + + const result = await guard.getItems(user, workspaceId); + + expect(result).toEqual([ + { type: WorkspaceItemType.File, uuid: 'file-uuid-1' }, + { type: WorkspaceItemType.Folder, uuid: 'folder-uuid-1' }, + ]); + }); + + it('When there is an error fetching trashed items, then it should handle the error gracefully', async () => { + const loggerDebugSpy = jest.spyOn(Logger, 'debug').mockImplementation(); + workspaceUseCases.getWorkspaceUserTrashedItems + .mockRejectedValueOnce(new Error('Error fetching files')) + .mockResolvedValueOnce({ result: [] }); + + const result = await guard.getItems(user, workspaceId); + + expect(result).toEqual(undefined); + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACES/GUARD] Error fetching trashed items:', + expect.any(Error), + ); + loggerDebugSpy.mockRestore(); + }); + }); }); const createMockExecutionContext = ( diff --git a/src/modules/workspaces/guards/workspaces.guard.ts b/src/modules/workspaces/guards/workspaces.guard.ts index ba5972e4..cb3b218f 100644 --- a/src/modules/workspaces/guards/workspaces.guard.ts +++ b/src/modules/workspaces/guards/workspaces.guard.ts @@ -17,6 +17,13 @@ import { } from './workspace-required-access.decorator'; import { User } from '../../user/user.domain'; import { isUUID } from 'class-validator'; +import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; +import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; + +interface TrashItem { + type: WorkspaceItemType; + uuid: string; +} @Injectable() export class WorkspaceGuard implements CanActivate { @@ -57,13 +64,22 @@ export class WorkspaceGuard implements CanActivate { ); } + let verified = false; + const workspaceLogAction = + this.reflector.get('workspaceLogAction', context.getHandler()) || null; + if (accessContext === AccessContext.WORKSPACE) { - return this.verifyWorkspaceAccessByRole(user, id, requiredRole); + verified = await this.verifyWorkspaceAccessByRole(user, id, requiredRole); } else if (accessContext === AccessContext.TEAM) { - return this.verifyTeamAccessByRole(user, id, requiredRole); + verified = await this.verifyTeamAccessByRole(user, id, requiredRole); + } + + if (verified && workspaceLogAction === WorkspaceLogType.DELETE_ALL) { + const items: TrashItem[] = await this.getItems(user, id); + request.items = items; } - return false; + return verified; } private async verifyWorkspaceAccessByRole( @@ -156,4 +172,44 @@ export class WorkspaceGuard implements CanActivate { ): string | undefined { return request[source]?.[field]; } + + async getItems(user: User, workspaceId: string): Promise { + try { + const { result: files } = + await this.workspaceUseCases.getWorkspaceUserTrashedItems( + user, + workspaceId, + WorkspaceItemType.File, + null, + ); + + const { result: folders } = + await this.workspaceUseCases.getWorkspaceUserTrashedItems( + user, + workspaceId, + WorkspaceItemType.Folder, + null, + ); + + const items: TrashItem[] = [ + ...(Array.isArray(files) ? files : []) + .filter((file) => file.uuid != null) + .map((file) => ({ + type: WorkspaceItemType.File, + uuid: file.uuid, + })), + ...(Array.isArray(folders) ? folders : []) + .filter((folder) => folder.uuid != null) + .map((folder) => ({ + type: WorkspaceItemType.Folder, + uuid: folder.uuid, + })), + ]; + + return items; + } catch (error) { + Logger.debug('[WORKSPACES/GUARD] Error fetching trashed items:', error); + return; + } + } } diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts new file mode 100644 index 00000000..a6f78310 --- /dev/null +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -0,0 +1,705 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { WorkspacesLogsInterceptor } from './workspaces-logs.interceptor'; +import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; +import { + WorkspaceLogType, + WorkspaceLogPlatform, +} from '../attributes/workspace-logs.attributes'; +import { CallHandler, ExecutionContext, Logger } from '@nestjs/common'; +import { of } from 'rxjs'; +import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; + +describe('WorkspacesLogsInterceptor', () => { + let interceptor: WorkspacesLogsInterceptor; + let workspaceRepository: DeepMocked; + const loggerDebugSpy = jest.spyOn(Logger, 'debug').mockImplementation(); + + beforeEach(async () => { + workspaceRepository = createMock(); + interceptor = new WorkspacesLogsInterceptor(workspaceRepository); + jest.clearAllMocks(); + }); + + describe('determinePlatform()', () => { + it('When client is drive-web, then it should return WEB platform', () => { + const platform = interceptor.determinePlatform('drive-web'); + expect(platform).toBe(WorkspaceLogPlatform.WEB); + }); + + it('When client is drive-mobile, then it should return MOBILE platform', () => { + const platform = interceptor.determinePlatform('drive-mobile'); + expect(platform).toBe(WorkspaceLogPlatform.MOBILE); + }); + + it('When client is drive-desktop, then it should return DESKTOP platform', () => { + const platform = interceptor.determinePlatform('drive-desktop'); + expect(platform).toBe(WorkspaceLogPlatform.DESKTOP); + }); + + it('When client is unknown, then it should return UNSPECIFIED platform', () => { + const platform = interceptor.determinePlatform('unknown-client'); + expect(platform).toBe(WorkspaceLogPlatform.UNSPECIFIED); + }); + }); + + describe('intercept()', () => { + it('When log action is valid, then it should call handleAction', async () => { + const mockHandler = jest.fn(); + Reflect.defineMetadata( + 'workspaceLogAction', + WorkspaceLogType.LOGIN, + mockHandler, + ); + + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { 'internxt-client': 'drive-web' }, + }), + }), + getHandler: () => mockHandler, + } as any; + + const next: CallHandler = { + handle: jest.fn().mockReturnValue(of({})), + }; + + const handleActionSpy = jest + .spyOn(interceptor, 'handleAction') + .mockResolvedValue(undefined); + + await interceptor.intercept(context, next).toPromise(); + + expect(handleActionSpy).toHaveBeenCalled(); + }); + + it('When log action is invalid, then it should log a debug message', async () => { + const mockHandler = jest.fn(); + Reflect.defineMetadata( + 'workspaceLogAction', + 'INVALID_ACTION', + mockHandler, + ); + + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { 'internxt-client': 'drive-web' }, + }), + }), + getHandler: () => mockHandler, + } as any; + + const next: CallHandler = { + handle: jest.fn().mockReturnValue(of({})), + }; + + const loggerDebugSpy = jest.spyOn(Logger, 'debug'); + + interceptor.intercept(context, next); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] Invalid log action: INVALID_ACTION', + ); + }); + }); + + describe('handleAction()', () => { + const req = {}; + const res = {}; + + it('When action is recognized, then it should call the corresponding method', async () => { + const platform = WorkspaceLogPlatform.WEB; + + const handleUserActionSpy = jest + .spyOn(interceptor, 'handleUserAction') + .mockImplementation(); + + await interceptor.handleAction( + platform, + WorkspaceLogType.LOGIN, + req, + res, + ); + + expect(handleUserActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.LOGIN, + req, + res, + ); + }); + + it('When action is not recognized, then it should log a debug message', async () => { + const platform = WorkspaceLogPlatform.WEB; + + await interceptor.handleAction( + platform, + 'INVALID_ACTION' as WorkspaceLogType, + req, + res, + ); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] Action not recognized: INVALID_ACTION', + ); + }); + }); + + describe('registerWorkspaceLog()', () => { + const payload = { + workspaceId: 'workspace-id', + creator: 'user-id', + type: WorkspaceLogType.LOGIN, + platform: WorkspaceLogPlatform.WEB, + entityId: 'entity-id', + }; + + it('When registerLog is called, then it should call the repository method', async () => { + await interceptor.registerWorkspaceLog(payload); + + expect(workspaceRepository.registerLog).toHaveBeenCalledWith({ + ...payload, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + }); + + it('When an error occurs, then it should log the error', async () => { + jest + .spyOn(workspaceRepository, 'registerLog') + .mockRejectedValue(new Error('Database error')); + + await interceptor.registerWorkspaceLog(payload); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'An error occurred trying to register a log of type LOGIN for the user user-id', + ), + expect.any(Error), + ); + }); + }); + + describe('handleUserAction()', () => { + it('When user is valid, then it should register logs for all workspaces', async () => { + const req = {}; + const res = { user: { uuid: 'user-id' } }; + const workspaceIds = ['workspace-id-1', 'workspace-id-2']; + + jest + .spyOn(interceptor, 'getUserWorkspaces') + .mockResolvedValue(workspaceIds); + const registerLogSpy = jest + .spyOn(interceptor, 'registerWorkspaceLog') + .mockImplementation(); + + await interceptor.handleUserAction( + WorkspaceLogPlatform.WEB, + WorkspaceLogType.LOGIN, + req, + res, + ); + + expect(registerLogSpy).toHaveBeenCalledTimes(workspaceIds.length); + workspaceIds.forEach((workspaceId) => { + expect(registerLogSpy).toHaveBeenCalledWith({ + workspaceId, + creator: 'user-id', + type: WorkspaceLogType.LOGIN, + platform: WorkspaceLogPlatform.WEB, + }); + }); + }); + + it('When user is invalid, then it should log a debug message', async () => { + const req = {}; + const res = {}; + + await interceptor.handleUserAction( + WorkspaceLogPlatform.WEB, + WorkspaceLogType.LOGIN, + req, + res, + ); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] User is required', + ); + }); + }); + + describe('handleUserWorkspaceAction()', () => { + it('When request data is valid, then it should register a workspace log', async () => { + const req = { + body: { itemId: 'item-id' }, + params: {}, + workspace: { id: 'workspace-id' }, + }; + const res = { user: { uuid: 'user-id' } }; + const platform = WorkspaceLogPlatform.WEB; + + jest.spyOn(interceptor, 'extractRequestData').mockReturnValue({ + ok: true, + requesterUuid: 'user-id', + workspaceId: 'workspace-id', + }); + const registerLogSpy = jest + .spyOn(interceptor, 'registerWorkspaceLog') + .mockImplementation(); + + await interceptor.handleUserWorkspaceAction( + platform, + WorkspaceLogType.SHARE_FILE, + req, + res, + ); + + expect(registerLogSpy).toHaveBeenCalledWith({ + workspaceId: 'workspace-id', + creator: 'user-id', + type: WorkspaceLogType.SHARE_FILE, + platform, + entityId: 'item-id', + }); + }); + + it('When request data is invalid, then it should log a debug message', async () => { + const req = { body: {}, params: {}, workspace: {} }; + const res = { user: { uuid: 'user-id' } }; + const platform = WorkspaceLogPlatform.WEB; + + await interceptor.handleUserWorkspaceAction( + platform, + WorkspaceLogType.SHARE_FILE, + req, + res, + ); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] Item Id is required', + ); + }); + }); + + describe('getItemType()', () => { + it('When itemType is in body, then it should return itemType', () => { + const req = { body: { itemType: 'FILE' } }; + const result = interceptor.getItemType(req); + expect(result).toBe('FILE'); + }); + + it('When itemType is in params, then it should return itemType', () => { + const req = { params: { itemType: 'FOLDER' } }; + const result = interceptor.getItemType(req); + expect(result).toBe('FOLDER'); + }); + + it('When itemType is not present, then it should return undefined', () => { + const req = { body: {}, params: {} }; + const result = interceptor.getItemType(req); + expect(result).toBeUndefined(); + }); + }); + + describe('getEntity()', () => { + it('When itemId is in body, then it should return itemId', () => { + const req = { body: { itemId: 'item-id' }, params: {} }; + const result = interceptor.getEntity(req, {}); + expect(result).toBe('item-id'); + }); + + it('When itemId is in params, then it should return itemId', () => { + const req = { body: {}, params: { itemId: 'item-id' } }; + const result = interceptor.getEntity(req, {}); + expect(result).toBe('item-id'); + }); + + it('When itemId is in res, then it should return itemId', () => { + const req = { body: {}, params: {} }; + const res = { itemId: 'item-id' }; + const result = interceptor.getEntity(req, res); + expect(result).toBe('item-id'); + }); + + it('When itemId is not present, then it should return undefined', () => { + const req = { body: {}, params: {} }; + const result = interceptor.getEntity(req, {}); + expect(result).toBeUndefined(); + }); + }); + + describe('determineAction()', () => { + it('When type is SHARE and itemType is File, then it should return SHARE_FILE', () => { + const action = interceptor.determineAction( + 'SHARE', + WorkspaceItemType.File, + ); + expect(action).toBe(WorkspaceLogType.SHARE_FILE); + }); + + it('When type is SHARE and itemType is Folder, then it should return SHARE_FOLDER', () => { + const action = interceptor.determineAction( + 'SHARE', + WorkspaceItemType.Folder, + ); + expect(action).toBe(WorkspaceLogType.SHARE_FOLDER); + }); + + it('When type is DELETE and itemType is File, then it should return DELETE_FILE', () => { + const action = interceptor.determineAction( + 'DELETE', + WorkspaceItemType.File, + ); + expect(action).toBe(WorkspaceLogType.DELETE_FILE); + }); + + it('When type is DELETE and itemType is Folder, then it should return DELETE_FOLDER', () => { + const action = interceptor.determineAction( + 'DELETE', + WorkspaceItemType.Folder, + ); + expect(action).toBe(WorkspaceLogType.DELETE_FOLDER); + }); + + it('When type is invalid, then it should log a debug message', () => { + const action = interceptor.determineAction( + 'INVALID_TYPE' as any, + WorkspaceItemType.File, + ); + expect(action).toBeNull(); + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] Invalid action type: INVALID_TYPE or item type: file', + ); + }); + + it('When itemType is invalid, then it should log a debug message', () => { + const action = interceptor.determineAction( + 'SHARE', + 'INVALID_ITEM_TYPE' as any, + ); + expect(action).toBeNull(); + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] Invalid action type: SHARE or item type: INVALID_ITEM_TYPE', + ); + }); + }); + + describe('extractRequestData()', () => { + it('When requester and workspace are present, then it should return valid data', () => { + const req = { + params: { workspaceId: 'workspace-id' }, + requester: { uuid: 'requester-uuid' }, + workspace: {}, + user: { uuid: 'user-uuid' }, + }; + const result = interceptor.extractRequestData(req); + expect(result).toEqual({ + ok: true, + requesterUuid: 'requester-uuid', + workspaceId: 'workspace-id', + }); + }); + + it('When requester is missing, then it should return invalid data', () => { + const req = { + params: { workspaceId: 'workspace-id' }, + requester: {}, + workspace: {}, + user: {}, + }; + const result = interceptor.extractRequestData(req); + expect(result).toEqual({ + ok: false, + requesterUuid: undefined, + workspaceId: 'workspace-id', + }); + }); + + it('When workspaceId is missing, then it should return invalid data', () => { + const req = { + params: {}, + requester: { uuid: 'requester-uuid' }, + workspace: {}, + user: { uuid: 'user-uuid' }, + }; + const result = interceptor.extractRequestData(req); + expect(result).toEqual({ + ok: false, + requesterUuid: 'requester-uuid', + workspaceId: undefined, + }); + }); + }); + + describe('getUserWorkspaces()', () => { + it('When user has workspaces, then it should return their IDs', async () => { + const uuid = 'user-id'; + + const workspaces = [ + { + workspace: { id: 'workspace-id-1', isWorkspaceReady: () => true }, + workspaceUser: { deactivated: false }, + }, + { + workspace: { id: 'workspace-id-2', isWorkspaceReady: () => true }, + workspaceUser: { deactivated: false }, + }, + ] as any; + jest + .spyOn(workspaceRepository, 'findUserAvailableWorkspaces') + .mockResolvedValue(workspaces); + + const result = await interceptor.getUserWorkspaces(uuid); + expect(result).toEqual(['workspace-id-1', 'workspace-id-2']); + }); + + it('When user has no available workspaces, then it should return an empty array', async () => { + const uuid = 'user-id'; + const workspaces = [ + { + workspace: { id: 'workspace-id-1', isWorkspaceReady: () => false }, + workspaceUser: { deactivated: false }, + }, + { + workspace: { id: 'workspace-id-2', isWorkspaceReady: () => true }, + workspaceUser: { deactivated: true }, + }, + ] as any; + jest + .spyOn(workspaceRepository, 'findUserAvailableWorkspaces') + .mockResolvedValue(workspaces); + + const result = await interceptor.getUserWorkspaces(uuid); + expect(result).toEqual([]); + }); + }); + + describe('logIn()', () => { + it('When called, then it should call handleUser Action with LOGIN type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserActionSpy = jest + .spyOn(interceptor, 'handleUserAction') + .mockImplementation(); + + await interceptor.logIn(platform, req, res); + + expect(handleUserActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.LOGIN, + req, + res, + ); + }); + }); + + describe('changedPassword()', () => { + it('When called, then it should call handleUser Action with CHANGED_PASSWORD type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserActionSpy = jest + .spyOn(interceptor, 'handleUserAction') + .mockImplementation(); + + await interceptor.changedPassword(platform, req, res); + + expect(handleUserActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.CHANGED_PASSWORD, + req, + res, + ); + }); + }); + + describe('logout()', () => { + it('When called, then it should call handleUser Action with LOGOUT type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserActionSpy = jest + .spyOn(interceptor, 'handleUserAction') + .mockImplementation(); + + await interceptor.logout(platform, req, res); + + expect(handleUserActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.LOGOUT, + req, + res, + ); + }); + }); + + describe('share()', () => { + it('When itemType is valid, then it should call handleUserWorkspaceAction', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = { body: { itemType: 'file' } }; + const res = {}; + + const determineActionSpy = jest + .spyOn(interceptor, 'determineAction') + .mockReturnValue(WorkspaceLogType.SHARE_FILE); + const handleUserWorkspaceActionSpy = jest + .spyOn(interceptor, 'handleUserWorkspaceAction') + .mockImplementation(); + + await interceptor.share(platform, req, res); + + expect(determineActionSpy).toHaveBeenCalledWith('SHARE', 'file'); + expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.SHARE_FILE, + req, + res, + ); + }); + + it('When itemType is not provided, then it should log a debug message', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = { body: {} }; + const res = {}; + + await interceptor.share(platform, req, res); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] The item type is required', + ); + }); + }); + + describe('shareFile()', () => { + it('When called, then it should call handleUser WorkspaceAction with SHARE_FILE type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserWorkspaceActionSpy = jest + .spyOn(interceptor, 'handleUserWorkspaceAction') + .mockImplementation(); + + await interceptor.shareFile(platform, req, res); + + expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.SHARE_FILE, + req, + res, + ); + }); + }); + + describe('shareFolder()', () => { + it('When called, then it should call handleUser WorkspaceAction with SHARE_FOLDER type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserWorkspaceActionSpy = jest + .spyOn(interceptor, 'handleUserWorkspaceAction') + .mockImplementation(); + + await interceptor.shareFolder(platform, req, res); + + expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.SHARE_FOLDER, + req, + res, + ); + }); + }); + + describe('delete()', () => { + it('When items are provided, then it should register logs for each item', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = { body: { items: [{ type: 'file', uuid: 'file-id' }] } }; + const res = {}; + const registerLogSpy = jest + .spyOn(interceptor, 'registerWorkspaceLog') + .mockResolvedValue(undefined); + const extractRequestDataSpy = jest + .spyOn(interceptor, 'extractRequestData') + .mockReturnValue({ + ok: true, + requesterUuid: 'requester-uuid', + workspaceId: 'workspace-id', + }); + jest + .spyOn(interceptor, 'determineAction') + .mockReturnValue(WorkspaceLogType.DELETE_FILE); + + await interceptor.delete(platform, req, res); + + expect(extractRequestDataSpy).toHaveBeenCalledWith(req); + expect(registerLogSpy).toHaveBeenCalledWith({ + workspaceId: 'workspace-id', + creator: 'requester-uuid', + type: WorkspaceLogType.DELETE_FILE, + platform, + entityId: 'file-id', + }); + }); + + it('When no items are provided, then it should log a debug message', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = { body: {} }; + const res = {}; + + await interceptor.delete(platform, req, res); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + '[WORKSPACE/LOGS] The items are required', + ); + }); + }); + + describe('deleteFile()', () => { + it('When called, then it should call handleUser WorkspaceAction with DELETE_FILE type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserWorkspaceActionSpy = jest + .spyOn(interceptor, 'handleUserWorkspaceAction') + .mockImplementation(); + + await interceptor.deleteFile(platform, req, res); + + expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.DELETE_FILE, + req, + res, + ); + }); + }); + + describe('deleteFolder()', () => { + it('When called, then it should call handleUser WorkspaceAction with DELETE_FOLDER type', async () => { + const platform = WorkspaceLogPlatform.WEB; + const req = {}; + const res = {}; + + const handleUserWorkspaceActionSpy = jest + .spyOn(interceptor, 'handleUserWorkspaceAction') + .mockImplementation(); + + await interceptor.deleteFolder(platform, req, res); + + expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.DELETE_FOLDER, + req, + res, + ); + }); + }); +}); diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts new file mode 100644 index 00000000..7a3e7abd --- /dev/null +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -0,0 +1,334 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; +import { + WorkspaceLogAttributes, + WorkspaceLogPlatform, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; +import { User } from '../../user/user.domain'; +import { DeleteItem } from './../../trash/dto/controllers/delete-item.dto'; +import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; + +type ActionHandler = { + [key in WorkspaceLogType]: ( + platform: WorkspaceLogPlatform, + req: any, + res: any, + ) => Promise; +}; + +@Injectable() +export class WorkspacesLogsInterceptor implements NestInterceptor { + public actionHandler: ActionHandler; + public logAction: WorkspaceLogType; + + constructor( + private readonly workspaceRepository: SequelizeWorkspaceRepository, + ) { + this.actionHandler = { + [WorkspaceLogType.LOGIN]: this.logIn.bind(this), + [WorkspaceLogType.CHANGED_PASSWORD]: this.changedPassword.bind(this), + [WorkspaceLogType.LOGOUT]: this.logout.bind(this), + [WorkspaceLogType.DELETE]: this.delete.bind(this), + [WorkspaceLogType.DELETE_ALL]: this.delete.bind(this), + [WorkspaceLogType.DELETE_FILE]: this.deleteFile.bind(this), + [WorkspaceLogType.DELETE_FOLDER]: this.deleteFolder.bind(this), + [WorkspaceLogType.SHARE]: this.share.bind(this), + [WorkspaceLogType.SHARE_FILE]: this.shareFile.bind(this), + [WorkspaceLogType.SHARE_FOLDER]: this.shareFolder.bind(this), + }; + } + + determinePlatform(client: string): WorkspaceLogPlatform { + const platforms = { + 'drive-web': WorkspaceLogPlatform.WEB, + 'drive-mobile': WorkspaceLogPlatform.MOBILE, + 'drive-desktop': WorkspaceLogPlatform.DESKTOP, + }; + return platforms[client] || WorkspaceLogPlatform.UNSPECIFIED; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + this.logAction = this.getWorkspaceLogAction(context); + + if (!Object.values(WorkspaceLogType).includes(this.logAction)) { + Logger.debug(`[WORKSPACE/LOGS] Invalid log action: ${this.logAction}`); + return; + } + + const platform = this.determinePlatform(request.headers['internxt-client']); + + return next.handle().pipe( + tap({ + next: async (res) => { + await this.handleAction(platform, this.logAction, request, res); + }, + }), + ); + } + + async logIn(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserAction(platform, WorkspaceLogType.LOGIN, req, res); + } + + async changedPassword(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserAction( + platform, + WorkspaceLogType.CHANGED_PASSWORD, + req, + res, + ); + } + + async logout(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserAction(platform, WorkspaceLogType.LOGOUT, req, res); + } + + async share(platform: WorkspaceLogPlatform, req: any, res: any) { + const itemType = this.getItemType(req); + if (!itemType) { + Logger.debug('[WORKSPACE/LOGS] The item type is required'); + return; + } + + const action = this.determineAction( + 'SHARE', + itemType as unknown as WorkspaceItemType, + ); + if (action) { + await this.handleUserWorkspaceAction(platform, action, req, res); + } + } + + async shareFile(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserWorkspaceAction( + platform, + WorkspaceLogType.SHARE_FILE, + req, + res, + ); + } + + async shareFolder(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserWorkspaceAction( + platform, + WorkspaceLogType.SHARE_FOLDER, + req, + res, + ); + } + + async delete(platform: WorkspaceLogPlatform, req: any, res: any) { + const items: DeleteItem[] = req?.body?.items || req?.items; + if (!items || items.length === 0) { + Logger.debug('[WORKSPACE/LOGS] The items are required'); + return; + } + + const { ok, requesterUuid, workspaceId } = this.extractRequestData(req); + + if (ok) { + const deletePromises = items + .map((item) => { + const action = this.determineAction( + 'DELETE', + item.type as unknown as WorkspaceItemType, + ); + if (action) { + return this.registerWorkspaceLog({ + workspaceId: workspaceId, + creator: requesterUuid, + type: action, + platform, + entityId: item.uuid || item.id, + }); + } + return null; + }) + .filter((promise): promise is Promise => promise !== null); + await Promise.all(deletePromises); + } + } + + async deleteFile(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserWorkspaceAction( + platform, + WorkspaceLogType.DELETE_FILE, + req, + res, + ); + } + + async deleteFolder(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserWorkspaceAction( + platform, + WorkspaceLogType.DELETE_FOLDER, + req, + res, + ); + } + + async handleAction( + platform: WorkspaceLogPlatform, + action: WorkspaceLogType, + req: any, + res: any, + ) { + const dispatchAction = this.actionHandler[action]; + if (dispatchAction) { + await dispatchAction(platform, req, res); + } else { + Logger.debug(`[WORKSPACE/LOGS] Action not recognized: ${action}`); + } + } + + getWorkspaceLogAction(context: ExecutionContext): WorkspaceLogType { + const handler = context.getHandler(); + return Reflect.getMetadata('workspaceLogAction', handler) || null; + } + + async registerWorkspaceLog( + payload: Omit, + ) { + try { + await this.workspaceRepository.registerLog({ + ...payload, + createdAt: new Date(), + updatedAt: new Date(), + }); + } catch (error) { + Logger.debug( + `[WORKSPACE/LOGS] An error occurred trying to register a log of type ${payload.type} for the user ${payload.creator}`, + error, + ); + } + } + + async getUserWorkspaces(uuid: string) { + const availableWorkspaces = + await this.workspaceRepository.findUserAvailableWorkspaces(uuid); + return availableWorkspaces + .filter( + ({ workspace, workspaceUser }) => + workspace.isWorkspaceReady() && !workspaceUser.deactivated, + ) + .map(({ workspace }) => workspace.id); + } + + async handleUserAction( + platform: WorkspaceLogPlatform, + actionType: WorkspaceLogType, + req: any, + res: any, + ) { + const user: User = res?.user || req?.user; + + if (!user || !user.uuid) { + Logger.debug('[WORKSPACE/LOGS] User is required'); + return; + } + + const workspaceIds = await this.getUserWorkspaces(user.uuid); + await Promise.all( + workspaceIds.map((workspaceId) => + this.registerWorkspaceLog({ + workspaceId, + creator: user.uuid, + type: actionType, + platform, + }), + ), + ); + } + + async handleUserWorkspaceAction( + platform: WorkspaceLogPlatform, + actionType: WorkspaceLogType, + req: any, + res: any, + entity?: string, + ) { + const { ok, requesterUuid, workspaceId } = this.extractRequestData(req); + + const entityId = entity || this.getEntity(req, res); + if (!entityId) { + Logger.debug('[WORKSPACE/LOGS] Item Id is required'); + } + + if (ok && requesterUuid && workspaceId && entityId) { + await this.registerWorkspaceLog({ + workspaceId, + creator: requesterUuid, + type: actionType, + platform, + entityId, + }); + } + } + + getItemType(req: any): string { + return req?.body?.itemType || req?.params?.itemType; + } + + getEntity(req: any, res: any): string { + return req?.body?.itemId || req?.params?.itemId || res?.itemId; + } + + determineAction( + type: 'SHARE' | 'DELETE', + itemType: WorkspaceItemType, + ): WorkspaceLogType { + const actionMap = { + SHARE: { + [WorkspaceItemType.File]: WorkspaceLogType.SHARE_FILE, + [WorkspaceItemType.Folder]: WorkspaceLogType.SHARE_FOLDER, + }, + DELETE: { + [WorkspaceItemType.File]: WorkspaceLogType.DELETE_FILE, + [WorkspaceItemType.Folder]: WorkspaceLogType.DELETE_FOLDER, + }, + }; + + const action = actionMap[type]?.[itemType]; + + if (!action) { + Logger.debug( + `[WORKSPACE/LOGS] Invalid action type: ${type} or item type: ${itemType}`, + ); + return null; + } + + return action; + } + + extractRequestData(req: any) { + const { params, requester, workspace, user } = req; + + const requesterUuid = + requester?.uuid || (params?.workspaceId ? user?.uuid : null); + if (!requesterUuid) { + Logger.debug('[WORKSPACE/LOGS] Requester not found'); + } + const workspaceId = workspace?.id || params?.workspaceId; + if (!workspaceId) { + Logger.debug('[WORKSPACE/LOGS] Workspace is required'); + } + + const ok = !!(requesterUuid && workspaceId); + + return { + ok, + requesterUuid, + workspaceId, + }; + } +} diff --git a/src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts b/src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts deleted file mode 100644 index 7171a243..00000000 --- a/src/modules/workspaces/interceptors/workspacesLogs.interceptor.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - applyDecorators, - UseInterceptors, -} from '@nestjs/common'; -import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; -import { - WorkspaceLogAttributes, - WorkspaceLogPlatform, -} from '../attributes/workspace-logs.attributes'; - -@Injectable() -export class WorkspacesLogsInterceptor implements NestInterceptor { - constructor( - private readonly workspaceRepository: SequelizeWorkspaceRepository, - private readonly logAction: WorkspaceLogAttributes['type'], - ) {} - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); - const user = request.user; - const client = request.headers['internxt-client']; - - let platform: WorkspaceLogPlatform; - - switch (client) { - case 'drive-web': - platform = WorkspaceLogPlatform.WEB; - break; - case 'drive-mobile': - platform = WorkspaceLogPlatform.MOBILE; - break; - case 'drive-desktop': - platform = WorkspaceLogPlatform.DESKTOP; - break; - default: - platform = WorkspaceLogPlatform.UNSPECIFIED; - } - - return next.handle().pipe( - tap({ - next: async () => { - await this.workspaceRepository.registerLog({ - platform, - creator: user.id, - type: this.logAction, - workspaceId: request.params.id, - createdAt: new Date(), - updatedAt: new Date(), - }); - }, - }), - ); - } -} - -export function LogAction(logAction: WorkspaceLogAttributes['type']) { - return applyDecorators( - UseInterceptors(WorkspacesLogsInterceptor), - (target: any, key: string, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - descriptor.value = function (...args: any[]) { - const context = args[0]; - const workspaceRepository = context - .switchToHttp() - .getRequest() - .app.get(SequelizeWorkspaceRepository); - const interceptor = new WorkspacesLogsInterceptor( - workspaceRepository, - logAction, - ); - return interceptor.intercept(context, originalMethod.apply(this, args)); - }; - }, - ); -} diff --git a/src/modules/workspaces/models/workspace-logs.model.ts b/src/modules/workspaces/models/workspace-logs.model.ts index 8f42c0e8..dcedb963 100644 --- a/src/modules/workspaces/models/workspace-logs.model.ts +++ b/src/modules/workspaces/models/workspace-logs.model.ts @@ -1,3 +1,5 @@ +import { FolderModel } from './../../folder/folder.model'; +import { FileModel } from './../../file/file.model'; import { Model, Table, @@ -9,6 +11,7 @@ import { } from 'sequelize-typescript'; import { UserModel } from '../../user/user.model'; import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; +import { WorkspaceModel } from './workspace.model'; @Table({ underscored: true, @@ -20,6 +23,17 @@ export class WorkspaceLogModel extends Model { @Column({ type: DataType.UUID, defaultValue: DataType.UUIDV4 }) id: string; + @ForeignKey(() => WorkspaceModel) + @Column({ type: DataType.UUID, allowNull: false }) + workspaceId: string; + + @BelongsTo(() => WorkspaceModel, { + foreignKey: 'workspaceId', + targetKey: 'id', + as: 'workspace', + }) + workspace: WorkspaceModel; + @ForeignKey(() => UserModel) @Column({ type: DataType.UUID, allowNull: false }) creator: string; @@ -38,11 +52,25 @@ export class WorkspaceLogModel extends Model { type: WorkspaceLogAttributes['type']; @Column(DataType.STRING) - entity: string; + platform: string; @Column(DataType.STRING) entityId: string; + @BelongsTo(() => FileModel, { + foreignKey: 'entity_id', + targetKey: 'uuid', + as: 'file', + }) + file?: FileModel; + + @BelongsTo(() => FolderModel, { + foreignKey: 'entity_id', + targetKey: 'uuid', + as: 'folder', + }) + folder?: FolderModel; + @Column createdAt: Date; diff --git a/src/modules/workspaces/repositories/workspaces.repository.spec.ts b/src/modules/workspaces/repositories/workspaces.repository.spec.ts index 5a0a5cc1..0ec1fa3a 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.spec.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.spec.ts @@ -16,12 +16,20 @@ import { import { Workspace } from '../domains/workspaces.domain'; import { User } from '../../user/user.domain'; import { Op } from 'sequelize'; +import { WorkspaceLogModel } from '../models/workspace-logs.model'; +import { + WorkspaceLogPlatform, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; +import { v4 } from 'uuid'; +import { WorkspaceLog } from '../domains/workspace-log.domain'; describe('SequelizeWorkspaceRepository', () => { let repository: SequelizeWorkspaceRepository; let workspaceModel: typeof WorkspaceModel; let workspaceUserModel: typeof WorkspaceUserModel; let workspaceInviteModel: typeof WorkspaceInviteModel; + let workspaceLogModel: typeof WorkspaceLogModel; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -42,6 +50,9 @@ describe('SequelizeWorkspaceRepository', () => { workspaceInviteModel = module.get( getModelToken(WorkspaceInviteModel), ); + workspaceLogModel = module.get( + getModelToken(WorkspaceLogModel), + ); }); describe('findInvite', () => { @@ -377,4 +388,282 @@ describe('SequelizeWorkspaceRepository', () => { expect(result).toBeNull(); }); }); + + describe('accessLogs', () => { + const workspaceId = v4(); + const user = newUser({ attributes: { email: 'test@example.com' } }); + const pagination = { limit: 10, offset: 0 }; + const member = 'test@example.com'; + const logType: WorkspaceLog['type'][] = [ + WorkspaceLogType.LOGIN, + WorkspaceLogType.LOGOUT, + ]; + const lastDays = 7; + const order: [string, string][] = [['createdAt', 'DESC']]; + const date = new Date(); + + const workspaceLogtoJson = { + id: v4(), + workspaceId, + creator: user.uuid, + type: WorkspaceLogType.LOGIN, + platform: WorkspaceLogPlatform.WEB, + entityId: null, + createdAt: date, + updatedAt: date, + }; + const mockLogs: WorkspaceLog[] = [ + { + ...workspaceLogtoJson, + user: { + id: 4, + name: user.name, + lastname: user.lastname, + email: user.email, + uuid: user.uuid, + }, + workspace: { + id: workspaceId, + name: 'My Workspace', + }, + file: null, + folder: null, + toJSON: () => ({ ...workspaceLogtoJson }), + }, + ]; + + it('when lastDays is provided, then should filter logs by date', async () => { + const dateLimit = new Date(); + dateLimit.setDate(dateLimit.getDate() - lastDays); + dateLimit.setMilliseconds(0); + + const whereConditions = { + workspaceId, + createdAt: { [Op.gte]: dateLimit }, + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + member, + logType, + pagination, + lastDays, + order, + ); + + expect(workspaceLogModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining(whereConditions), + include: expect.any(Array), + ...pagination, + order, + }); + }); + + it('when member is provided, then should filter logs by member email or name', async () => { + const whereConditions = { + workspaceId, + [Op.or]: [ + { '$user.email$': { [Op.iLike]: `%${member}%` } }, + { '$user.name$': { [Op.iLike]: `%${member}%` } }, + ], + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + member, + logType, + pagination, + lastDays, + order, + ); + + expect(workspaceLogModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining(whereConditions), + include: expect.any(Array), + ...pagination, + order, + }); + }); + + it('when logType is provided, then should filter logs by type', async () => { + const whereConditions = { + workspaceId, + type: { [Op.in]: logType }, + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + member, + logType, + pagination, + lastDays, + order, + ); + + expect(workspaceLogModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining(whereConditions), + include: expect.any(Array), + ...pagination, + order, + }); + }); + + it('when summary is true, then should return summary of logs', async () => { + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + jest + .spyOn(repository, 'workspaceLogToDomainSummary') + .mockImplementation((log) => log as WorkspaceLog); + + await repository.accessLogs( + workspaceId, + true, + member, + logType, + pagination, + lastDays, + order, + ); + + expect(repository.workspaceLogToDomainSummary).toHaveBeenCalledWith( + mockLogs[0], + ); + }); + + it('when pagination is not provided, then should use default pagination', async () => { + const whereConditions = { + workspaceId, + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + member, + logType, + undefined, + lastDays, + order, + ); + + expect(workspaceLogModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining(whereConditions), + include: expect.any(Array), + order, + }); + }); + + it('when order is not provided, then should use default order', async () => { + const whereConditions = { + workspaceId, + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + member, + logType, + pagination, + lastDays, + ); + + expect(workspaceLogModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining(whereConditions), + include: expect.any(Array), + ...pagination, + order: [['createdAt', 'DESC']], + }); + }); + }); + + describe('workspaceLogToDomain()', () => { + const workspaceId = v4(); + const fileId = v4(); + it('When model is provided, then it should return a WorkspaceLog entity', async () => { + const toJson = { + id: v4(), + workspaceId: workspaceId, + type: WorkspaceLogType.SHARE_FILE, + createdAt: new Date(), + updatedAt: new Date(), + creator: v4(), + entityId: fileId, + platform: 'WEB', + }; + const model: WorkspaceLogModel = { + user: { id: 10, name: 'John Doe' }, + workspace: { id: workspaceId, name: 'My Workspace' }, + file: { uuid: fileId, plainName: 'example.txt' }, + folder: null, + toJSON: () => ({ ...toJson }), + } as any; + + const result = repository.workspaceLogToDomain(model); + + expect(result).toEqual( + expect.objectContaining({ + ...toJson, + }), + ); + }); + }); + + describe('workspaceLogToDomainSummary()', () => { + const workspaceId = v4(); + const folderId = v4(); + it('When model is provided, then it should return a summary of WorkspaceLog entity', async () => { + const toJson = { + id: v4(), + type: WorkspaceLogType.SHARE_FOLDER, + workspaceId: workspaceId, + createdAt: new Date(), + updatedAt: new Date(), + creator: v4(), + entityId: folderId, + platform: WorkspaceLogPlatform.WEB, + }; + const model: WorkspaceLogModel = { + user: { id: 20, name: 'John Doe' }, + workspace: { id: workspaceId, name: 'My Workspace' }, + file: null, + folder: { uuid: folderId, plainName: 'My Folder' }, + toJSON: () => ({ ...toJson }), + } as any; + + const result = repository.workspaceLogToDomainSummary(model); + + expect(result).toEqual( + expect.objectContaining({ + ...toJson, + file: null, + folder: model.folder, + user: model.user, + workspace: model.workspace, + }), + ); + }); + }); }); diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index 8038ea96..377a58bd 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -12,7 +12,7 @@ import { WorkspaceInviteAttributes } from '../attributes/workspace-invite.attrib import { WorkspaceUserAttributes } from '../attributes/workspace-users.attributes'; import { UserModel } from '../../user/user.model'; import { User } from '../../user/user.domain'; -import { Op } from 'sequelize'; +import { Op, Sequelize } from 'sequelize'; import { WorkspaceItemUserModel } from '../models/workspace-items-users.model'; import { WorkspaceItemType, @@ -20,11 +20,15 @@ import { } from '../attributes/workspace-items-users.attributes'; import { WorkspaceItemUser } from '../domains/workspace-item-user.domain'; import { FileModel } from '../../file/file.model'; -import { FileAttributes } from '../../file/file.domain'; -import { FolderAttributes } from '../../folder/folder.domain'; +import { File, FileAttributes } from '../../file/file.domain'; +import { Folder, FolderAttributes } from '../../folder/folder.domain'; import { FolderModel } from '../../folder/folder.model'; -import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; +import { + WorkspaceLogAttributes, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; import { WorkspaceLogModel } from '../models/workspace-logs.model'; +import { WorkspaceLog } from '../domains/workspace-log.domain'; @Injectable() export class SequelizeWorkspaceRepository { @@ -477,6 +481,107 @@ export class SequelizeWorkspaceRepository { await this.modelWorkspaceLog.create(log); } + async accessLogs( + workspaceId: Workspace['id'], + summary: boolean = false, + member?: string, + logType?: WorkspaceLog['type'][], + pagination?: { + limit?: number; + offset?: number; + }, + lastDays?: number, + order: [string, string][] = [['createdAt', 'DESC']], + ) { + const dateLimit = new Date(); + if (lastDays) { + dateLimit.setDate(dateLimit.getDate() - lastDays); + dateLimit.setMilliseconds(0); + } + + const whereConditions: any = { + workspaceId, + ...(lastDays && dateLimit ? { createdAt: { [Op.gte]: dateLimit } } : {}), + }; + + if (member) { + whereConditions[Op.or] = [ + { '$user.email$': { [Op.iLike]: `%${member}%` } }, + { '$user.name$': { [Op.iLike]: `%${member}%` } }, + ]; + } + + if (logType && logType.length > 0) { + whereConditions.type = { [Op.in]: logType }; + } + + const itemLogs = await this.modelWorkspaceLog.findAll({ + where: whereConditions, + include: [ + { + model: UserModel, + as: 'user', + required: true, + }, + { + model: WorkspaceModel, + as: 'workspace', + required: true, + }, + { + model: FileModel, + as: 'file', + required: false, + where: { + uuid: { + [Op.eq]: Sequelize.col('WorkspaceLogModel.entity_id'), + }, + }, + on: { + [Op.and]: [ + Sequelize.where(Sequelize.col('WorkspaceLogModel.type'), { + [Op.or]: ['SHARE_FILE', 'DELETE_FILE'], + }), + Sequelize.where( + Sequelize.col('file.uuid'), + Sequelize.col('WorkspaceLogModel.entity_id'), + ), + ], + }, + }, + { + model: FolderModel, + as: 'folder', + required: false, + where: { + uuid: { + [Op.eq]: Sequelize.col('WorkspaceLogModel.entity_id'), + }, + }, + on: { + [Op.and]: [ + Sequelize.where(Sequelize.col('WorkspaceLogModel.type'), { + [Op.or]: ['SHARE_FOLDER', 'DELETE_FOLDER'], + }), + Sequelize.where( + Sequelize.col('folder.uuid'), + Sequelize.col('WorkspaceLogModel.entity_id'), + ), + ], + }, + }, + ], + ...pagination, + order, + }); + + return itemLogs.map((item) => + summary + ? this.workspaceLogToDomainSummary(item) + : this.workspaceLogToDomain(item), + ); + } + toDomain(model: WorkspaceModel): Workspace { return Workspace.build({ ...model.toJSON(), @@ -496,6 +601,51 @@ export class SequelizeWorkspaceRepository { }); } + workspaceLogToDomain(model: WorkspaceLogModel): WorkspaceLog { + return WorkspaceLog.build({ + ...model.toJSON(), + user: model.user ? User.build(model.user).toJSON() : null, + workspace: model.workspace + ? Workspace.build(model.workspace).toJSON() + : null, + file: model.file ? File.build(model.file).toJSON() : null, + folder: model.folder ? Folder.build(model.folder).toJSON() : null, + }); + } + + workspaceLogToDomainSummary(model: WorkspaceLogModel): WorkspaceLog { + const buildUser = ({ + id, + name, + lastname, + email, + uuid, + }: UserModel | null) => (id ? { id, name, lastname, email, uuid } : null); + + const buildWorkspace = ({ id, name }: WorkspaceModel | null) => + id ? { id, name } : null; + + const buildFile = (file: FileModel | null) => { + if (!file) return null; + const { uuid, plainName, folderUuid, type } = file; + return { uuid, plainName, folderUuid, type }; + }; + + const buildFolder = (folder: FolderModel | null) => { + if (!folder) return null; + const { uuid, plainName, parentId } = folder; + return { uuid, plainName, parentId }; + }; + + return WorkspaceLog.build({ + ...model.toJSON(), + user: buildUser(model.user), + workspace: buildWorkspace(model.workspace), + file: buildFile(model.file), + folder: buildFolder(model.folder), + }); + } + toModel(domain: Workspace): Partial { return domain?.toJSON(); } diff --git a/src/modules/workspaces/workspaces.controller.spec.ts b/src/modules/workspaces/workspaces.controller.spec.ts index 18a29172..f6d30f01 100644 --- a/src/modules/workspaces/workspaces.controller.spec.ts +++ b/src/modules/workspaces/workspaces.controller.spec.ts @@ -1,5 +1,5 @@ import { DeepMocked, createMock } from '@golevelup/ts-jest'; -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { WorkspacesController } from './workspaces.controller'; import { WorkspacesUsecases } from './workspaces.usecase'; import { WorkspaceRole } from './guards/workspace-required-access.decorator'; @@ -16,6 +16,12 @@ import { CreateWorkspaceFolderDto } from './dto/create-workspace-folder.dto'; import { WorkspaceItemType } from './attributes/workspace-items-users.attributes'; import { StorageNotificationService } from '../../externals/notifications/storage.notifications.service'; import { CreateWorkspaceFileDto } from './dto/create-workspace-file.dto'; +import { WorkspaceLog } from './domains/workspace-log.domain'; +import { GetWorkspaceLogsDto } from './dto/get-workspace-logs'; +import { + WorkspaceLogPlatform, + WorkspaceLogType, +} from './attributes/workspace-logs.attributes'; describe('Workspace Controller', () => { let workspacesController: WorkspacesController; @@ -753,4 +759,108 @@ describe('Workspace Controller', () => { ); }); }); + + describe('GET /:workspaceId/access/logs', () => { + const workspaceId = v4(); + const user = newUser({ attributes: { email: 'test@example.com' } }); + const date = new Date(); + const workspaceLogtoJson = { + id: v4(), + workspaceId, + creator: user.uuid, + type: WorkspaceLogType.LOGIN, + platform: WorkspaceLogPlatform.WEB, + entityId: null, + createdAt: date, + updatedAt: date, + }; + const mockLogs: WorkspaceLog[] = [ + { + ...workspaceLogtoJson, + user: { + id: 4, + name: user.name, + lastname: user.lastname, + email: user.email, + uuid: user.uuid, + }, + workspace: { + id: workspaceId, + name: 'My Workspace', + }, + file: null, + folder: null, + toJSON: () => ({ ...workspaceLogtoJson }), + }, + ]; + + it('when valid request is made, then should return access logs successfully', async () => { + const workspaceLogDto: GetWorkspaceLogsDto = { limit: 10, offset: 0 }; + + jest.spyOn(workspacesUsecases, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await workspacesController.accessLogs( + workspaceId, + user, + workspaceLogDto, + ); + + expect(result).toEqual(mockLogs); + expect(workspacesUsecases.accessLogs).toHaveBeenCalledWith( + workspaceId, + { limit: 10, offset: 0 }, + undefined, + undefined, + undefined, + undefined, + ); + }); + + it('when invalid workspaceId is provided, then should throw', async () => { + const invalidWorkspaceId = v4(); + const workspaceLogDto: GetWorkspaceLogsDto = { limit: 10, offset: 0 }; + + jest + .spyOn(workspacesUsecases, 'accessLogs') + .mockRejectedValue(new NotFoundException()); + + await expect( + workspacesController.accessLogs( + invalidWorkspaceId, + user, + workspaceLogDto, + ), + ).rejects.toThrow(); + }); + + it('when query parameters are provided, then should handle them correctly', async () => { + const username = mockLogs[0].user.name; + const workspaceLogDto: GetWorkspaceLogsDto = { + limit: 10, + offset: 0, + member: mockLogs[0].user.name, + activity: [WorkspaceLogType.LOGIN], + lastDays: 7, + orderBy: 'createdAt:DESC', + }; + + jest.spyOn(workspacesUsecases, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await workspacesController.accessLogs( + workspaceId, + user, + workspaceLogDto, + ); + + expect(result).toEqual(mockLogs); + expect(workspacesUsecases.accessLogs).toHaveBeenCalledWith( + workspaceId, + { limit: 10, offset: 0 }, + username, + [WorkspaceLogType.LOGIN], + 7, + [['createdAt', 'DESC']], + ); + }); + }); }); diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index d39e794c..7e89e6d7 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -72,8 +72,9 @@ import { GetWorkspaceFilesQueryDto } from './dto/get-workspace-files.dto'; import { GetWorkspaceFoldersQueryDto } from './dto/get-workspace-folders.dto'; import { StorageNotificationService } from '../../externals/notifications/storage.notifications.service'; import { Client } from '../auth/decorators/client.decorator'; -import { LogAction } from './interceptors/workspacesLogs.interceptor'; import { WorkspaceLogType } from './attributes/workspace-logs.attributes'; +import { WorkspaceLogAction } from './decorators/workspace-log-action.decorator'; +import { GetWorkspaceLogsDto } from './dto/get-workspace-logs'; @ApiTags('Workspaces') @Controller('workspaces') @@ -618,7 +619,6 @@ export class WorkspacesController { @UseGuards(WorkspaceGuard, SharingPermissionsGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) @RequiredSharingPermissions(SharingActionName.UploadFile) - @LogAction(WorkspaceLogType.UPLOAD_FILE) async createFile( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -652,6 +652,7 @@ export class WorkspacesController { }) @UseGuards(WorkspaceGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) + @WorkspaceLogAction(WorkspaceLogType.SHARE) async shareItemWithMember( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -887,6 +888,7 @@ export class WorkspacesController { }) @UseGuards(WorkspaceGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) + @WorkspaceLogAction(WorkspaceLogType.DELETE_ALL) async emptyTrash( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -1160,4 +1162,44 @@ export class WorkspacesController { search, ); } + + @Get(':workspaceId/access/logs') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Access Logs', + }) + @ApiParam({ name: 'workspaceId', type: String, required: true }) + @ApiOkResponse({ + description: 'Access Logs', + }) + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) + async accessLogs( + @Param('workspaceId', ValidateUUIDPipe) + workspaceId: WorkspaceAttributes['id'], + @UserDecorator() user: User, + @Query() workspaceLogDto: GetWorkspaceLogsDto, + ) { + const { + limit, + offset, + member, + activity: logType, + lastDays, + orderBy, + } = workspaceLogDto; + + const order = orderBy + ? [orderBy.split(':') as [string, string]] + : undefined; + + return this.workspaceUseCases.accessLogs( + workspaceId, + { limit, offset }, + member, + logType, + lastDays, + order, + ); + } } diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index b31e31c8..497cdc66 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -58,6 +58,11 @@ import { import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { FuzzySearchResult } from '../fuzzy-search/dto/fuzzy-search-result.dto'; import { FolderStatus } from '../folder/folder.domain'; +import { WorkspaceLog } from './domains/workspace-log.domain'; +import { + WorkspaceLogPlatform, + WorkspaceLogType, +} from './attributes/workspace-logs.attributes'; jest.mock('../../middlewares/passport', () => { const originalModule = jest.requireActual('../../middlewares/passport'); @@ -5934,4 +5939,184 @@ describe('WorkspacesUsecases', () => { ); }); }); + + describe('accessLogs', () => { + const workspaceId = v4(); + const mockWorkspace = newWorkspace({ attributes: { id: workspaceId } }); + const user = newUser({ attributes: { email: 'test@example.com' } }); + const pagination = { limit: 10, offset: 0 }; + const member = 'test@example.com'; + const logType: WorkspaceLog['type'][] = [ + WorkspaceLogType.LOGIN, + WorkspaceLogType.LOGOUT, + ]; + const lastDays = 7; + const order: [string, string][] = [['createdAt', 'DESC']]; + const date = new Date(); + + const workspaceLogtoJson = { + id: v4(), + workspaceId, + creator: user.uuid, + type: WorkspaceLogType.LOGIN, + platform: WorkspaceLogPlatform.WEB, + entityId: null, + createdAt: date, + updatedAt: date, + }; + const mockLogs: WorkspaceLog[] = [ + { + ...workspaceLogtoJson, + user: { + id: 4, + name: user.name, + lastname: user.lastname, + email: user.email, + uuid: user.uuid, + }, + workspace: { + id: workspaceId, + name: 'My Workspace', + }, + file: null, + folder: null, + toJSON: () => ({ ...workspaceLogtoJson }), + }, + ]; + + it('when workspace exists, then should return access logs', async () => { + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValue(mockWorkspace); + jest.spyOn(workspaceRepository, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await service.accessLogs( + workspaceId, + pagination, + member, + logType, + lastDays, + order, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.findById).toHaveBeenCalledWith(workspaceId); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + true, + member, + logType, + pagination, + lastDays, + order, + ); + }); + + it('when workspace does not exist, then should throw NotFoundException', async () => { + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(null); + + await expect( + service.accessLogs( + workspaceId, + pagination, + member, + logType, + lastDays, + order, + ), + ).rejects.toThrow(NotFoundException); + await expect( + service.accessLogs( + workspaceId, + pagination, + member, + logType, + lastDays, + order, + ), + ).rejects.toThrow('Workspace not found'); + }); + + it('when pagination is not provided, then should use default values', async () => { + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValue(mockWorkspace); + jest.spyOn(workspaceRepository, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await service.accessLogs( + workspaceId, + {}, + member, + logType, + lastDays, + order, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.findById).toHaveBeenCalledWith(workspaceId); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + true, + member, + logType, + {}, + lastDays, + order, + ); + }); + + it('when lastDays is not provided, then should call accessLogs without lastDays', async () => { + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValue(mockWorkspace); + jest.spyOn(workspaceRepository, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await service.accessLogs( + workspaceId, + pagination, + member, + logType, + undefined, + order, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + true, + member, + logType, + pagination, + undefined, + order, + ); + }); + + it('when order is not provided, then should call accessLogs without order', async () => { + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValue(mockWorkspace); + jest.spyOn(workspaceRepository, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await service.accessLogs( + workspaceId, + pagination, + member, + logType, + lastDays, + undefined, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + true, + member, + logType, + pagination, + lastDays, + undefined, + ); + }); + }); }); diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 32d79746..1c52ab01 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -73,6 +73,7 @@ import { ChangeUserAssignedSpaceDto } from './dto/change-user-assigned-space.dto import { PaymentsService } from '../../externals/payments/payments.service'; import { SharingAccessTokenData } from '../sharing/guards/sharings-token.interface'; import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; +import { WorkspaceLog } from './domains/workspace-log.domain'; @Injectable() export class WorkspacesUsecases { @@ -2879,4 +2880,32 @@ export class WorkspacesUsecases { return searchResults; } + + async accessLogs( + workspaceId: Workspace['id'], + pagination: { + limit?: number; + offset?: number; + }, + member?: string, + logType?: WorkspaceLog['type'][], + lastDays?: number, + order?: [string, string][], + ) { + const workspace = await this.workspaceRepository.findById(workspaceId); + + if (!workspace) { + throw new NotFoundException('Workspace not found'); + } + + return this.workspaceRepository.accessLogs( + workspace.id, + true, + member, + logType, + pagination, + lastDays, + order, + ); + } } From f607302399ff27fe0e6be7becd461f187ba9e97b Mon Sep 17 00:00:00 2001 From: Ederson Date: Mon, 16 Dec 2024 21:55:14 -0400 Subject: [PATCH 3/8] chore: fixing reliability rating --- .../workspaces-logs.interceptor.spec.ts | 36 +++++++------------ .../workspaces-logs.interceptor.ts | 10 +++--- .../repositories/workspaces.repository.ts | 5 +-- .../workspaces/workspaces.usecase.spec.ts | 1 - 4 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts index a6f78310..867ec1bc 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -6,7 +6,7 @@ import { WorkspaceLogPlatform, } from '../attributes/workspace-logs.attributes'; import { CallHandler, ExecutionContext, Logger } from '@nestjs/common'; -import { of } from 'rxjs'; +import { lastValueFrom, of } from 'rxjs'; import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; describe('WorkspacesLogsInterceptor', () => { @@ -43,23 +43,23 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('intercept()', () => { + const mockHandler = jest.fn(); + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { 'internxt-client': 'drive-web' }, + }), + }), + getHandler: () => mockHandler, + } as any; + it('When log action is valid, then it should call handleAction', async () => { - const mockHandler = jest.fn(); Reflect.defineMetadata( 'workspaceLogAction', WorkspaceLogType.LOGIN, mockHandler, ); - const context: ExecutionContext = { - switchToHttp: () => ({ - getRequest: () => ({ - headers: { 'internxt-client': 'drive-web' }, - }), - }), - getHandler: () => mockHandler, - } as any; - const next: CallHandler = { handle: jest.fn().mockReturnValue(of({})), }; @@ -68,28 +68,18 @@ describe('WorkspacesLogsInterceptor', () => { .spyOn(interceptor, 'handleAction') .mockResolvedValue(undefined); - await interceptor.intercept(context, next).toPromise(); + await lastValueFrom(interceptor.intercept(context, next)); expect(handleActionSpy).toHaveBeenCalled(); }); - it('When log action is invalid, then it should log a debug message', async () => { - const mockHandler = jest.fn(); + it('When log action is invalid, then it should log an invalid action message', async () => { Reflect.defineMetadata( 'workspaceLogAction', 'INVALID_ACTION', mockHandler, ); - const context: ExecutionContext = { - switchToHttp: () => ({ - getRequest: () => ({ - headers: { 'internxt-client': 'drive-web' }, - }), - }), - getHandler: () => mockHandler, - } as any; - const next: CallHandler = { handle: jest.fn().mockReturnValue(of({})), }; diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts index 7a3e7abd..bb47d51d 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -6,7 +6,7 @@ import { Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; +import { mergeMap } from 'rxjs/operators'; import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; import { WorkspaceLogAttributes, @@ -68,10 +68,8 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { const platform = this.determinePlatform(request.headers['internxt-client']); return next.handle().pipe( - tap({ - next: async (res) => { - await this.handleAction(platform, this.logAction, request, res); - }, + mergeMap(async (res) => { + await this.handleAction(platform, this.logAction, request, res); }), ); } @@ -232,7 +230,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { ) { const user: User = res?.user || req?.user; - if (!user || !user.uuid) { + if (!user?.uuid) { Logger.debug('[WORKSPACE/LOGS] User is required'); return; } diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index 377a58bd..6999f4ad 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -23,10 +23,7 @@ import { FileModel } from '../../file/file.model'; import { File, FileAttributes } from '../../file/file.domain'; import { Folder, FolderAttributes } from '../../folder/folder.domain'; import { FolderModel } from '../../folder/folder.model'; -import { - WorkspaceLogAttributes, - WorkspaceLogType, -} from '../attributes/workspace-logs.attributes'; +import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; import { WorkspaceLogModel } from '../models/workspace-logs.model'; import { WorkspaceLog } from '../domains/workspace-log.domain'; diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 497cdc66..46af61dc 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -6104,7 +6104,6 @@ describe('WorkspacesUsecases', () => { member, logType, lastDays, - undefined, ); expect(result).toEqual(mockLogs); From c0a14b0552a2bc1d9f0e50a99138c7cdaa9007f1 Mon Sep 17 00:00:00 2001 From: Ederson Date: Wed, 18 Dec 2024 00:04:30 -0400 Subject: [PATCH 4/8] chore: working in requested changes --- ...41113173746-create-workspace-logs-table.js | 16 +- src/modules/sharing/sharing.controller.ts | 5 +- src/modules/sharing/sharing.module.ts | 2 - src/modules/trash/trash.controller.ts | 4 +- src/modules/trash/trash.module.ts | 3 +- .../attributes/workspace-logs.attributes.ts | 30 ++-- .../workspace-log-action.decorator.ts | 9 +- .../workspaces/dto/get-workspace-logs.ts | 8 +- .../guards/workspaces.guard.spec.ts | 105 ----------- .../workspaces/guards/workspaces.guard.ts | 62 +------ .../workspaces-logs.interceptor.spec.ts | 166 +++++++++--------- .../workspaces-logs.interceptor.ts | 111 +++++++----- .../workspaces.repository.spec.ts | 41 +++-- .../repositories/workspaces.repository.ts | 28 +-- .../workspaces/workspaces.controller.spec.ts | 24 ++- .../workspaces/workspaces.controller.ts | 8 +- src/modules/workspaces/workspaces.module.ts | 2 +- .../workspaces/workspaces.usecase.spec.ts | 68 +++++-- src/modules/workspaces/workspaces.usecase.ts | 39 +++- 19 files changed, 355 insertions(+), 376 deletions(-) diff --git a/migrations/20241113173746-create-workspace-logs-table.js b/migrations/20241113173746-create-workspace-logs-table.js index 63912e5c..2e43f639 100644 --- a/migrations/20241113173746-create-workspace-logs-table.js +++ b/migrations/20241113173746-create-workspace-logs-table.js @@ -34,11 +34,23 @@ module.exports = { onDelete: 'CASCADE', }, type: { - type: Sequelize.DataTypes.STRING, + type: Sequelize.ENUM( + 'login', + 'changed-password', + 'logout', + 'share-file', + 'share-folder', + 'delete-file', + 'delete-folder', + ), allowNull: false, }, platform: { - type: Sequelize.DataTypes.STRING, + type: Sequelize.ENUM( + 'web', + 'mobile', + 'desktop', + ), allowNull: false, }, entity_id: { diff --git a/src/modules/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 97c07e71..a8afdf9b 100644 --- a/src/modules/sharing/sharing.controller.ts +++ b/src/modules/sharing/sharing.controller.ts @@ -18,7 +18,6 @@ import { Headers, Patch, UseFilters, - InternalServerErrorException, } from '@nestjs/common'; import { Response } from 'express'; import { @@ -65,7 +64,7 @@ import { } from '../workspaces/guards/workspaces-resources-in-behalf.decorator'; import { GetDataFromRequest } from '../../common/extract-data-from-request'; import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator'; -import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes'; +import { WorkspaceLogGlobalActionType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('Sharing') @Controller('sharings') @@ -621,7 +620,7 @@ export class SharingController { { sourceKey: 'body', fieldName: 'itemType' }, ]) @WorkspacesInBehalfGuard() - @WorkspaceLogAction(WorkspaceLogType.SHARE) + @WorkspaceLogAction(WorkspaceLogGlobalActionType.Share) createSharing( @UserDecorator() user, @Body() acceptInviteDto: CreateSharingDto, diff --git a/src/modules/sharing/sharing.module.ts b/src/modules/sharing/sharing.module.ts index 8ceec46b..ed49ddb0 100644 --- a/src/modules/sharing/sharing.module.ts +++ b/src/modules/sharing/sharing.module.ts @@ -23,7 +23,6 @@ import { AppSumoModule } from '../app-sumo/app-sumo.module'; import { FeatureLimitModule } from '../feature-limit/feature-limit.module'; import { HttpClientModule } from '../../externals/http/http.module'; import { WorkspacesModule } from '../workspaces/workspaces.module'; -import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; @Module({ imports: [ @@ -49,7 +48,6 @@ import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspa SharingService, SequelizeSharingRepository, SequelizeUserReferralsRepository, - SequelizeWorkspaceRepository, PaymentsService, ], exports: [SharingService, SequelizeSharingRepository, SequelizeModule], diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index 11302e22..26aea014 100644 --- a/src/modules/trash/trash.controller.ts +++ b/src/modules/trash/trash.controller.ts @@ -50,7 +50,7 @@ import { StorageNotificationService } from '../../externals/notifications/storag import { BasicPaginationDto } from '../../common/dto/basic-pagination.dto'; import { Requester } from '../auth/decorators/requester.decorator'; import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator'; -import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes'; +import { WorkspaceLogGlobalActionType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('Trash') @Controller('storage/trash') @@ -268,7 +268,7 @@ export class TrashController { }) @GetDataFromRequest([{ sourceKey: 'body', fieldName: 'items' }]) @WorkspacesInBehalfGuard(WorkspaceResourcesAction.DeleteItemsFromTrash) - @WorkspaceLogAction(WorkspaceLogType.DELETE) + @WorkspaceLogAction(WorkspaceLogGlobalActionType.Delete) async deleteItems( @Body() deleteItemsDto: DeleteItemsDto, @UserDecorator() user: User, diff --git a/src/modules/trash/trash.module.ts b/src/modules/trash/trash.module.ts index d96ed22a..6bb32aa9 100644 --- a/src/modules/trash/trash.module.ts +++ b/src/modules/trash/trash.module.ts @@ -11,7 +11,6 @@ import { ShareModel } from '../share/share.repository'; import { FileModel } from '../file/file.model'; import { WorkspacesModule } from '../workspaces/workspaces.module'; import { SharingModule } from '../sharing/sharing.module'; -import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; @Module({ imports: [ @@ -27,6 +26,6 @@ import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspa NotificationModule, ], controllers: [TrashController], - providers: [Logger, TrashUseCases, SequelizeWorkspaceRepository], + providers: [Logger, TrashUseCases], }) export class TrashModule {} diff --git a/src/modules/workspaces/attributes/workspace-logs.attributes.ts b/src/modules/workspaces/attributes/workspace-logs.attributes.ts index 5194b7d4..8a7cca86 100644 --- a/src/modules/workspaces/attributes/workspace-logs.attributes.ts +++ b/src/modules/workspaces/attributes/workspace-logs.attributes.ts @@ -1,21 +1,23 @@ export enum WorkspaceLogType { - LOGIN = 'LOGIN', - CHANGED_PASSWORD = 'CHANGED_PASSWORD', - LOGOUT = 'LOGOUT', - SHARE = 'SHARE', - SHARE_FILE = 'SHARE_FILE', - SHARE_FOLDER = 'SHARE_FOLDER', - DELETE = 'DELETE', - DELETE_FILE = 'DELETE_FILE', - DELETE_FOLDER = 'DELETE_FOLDER', - DELETE_ALL = 'DELETE_ALL', + Login = 'login', + ChangedPassword = 'changed-password', + Logout = 'logout', + ShareFile = 'share-file', + ShareFolder = 'share-folder', + DeleteFile = 'delete-file', + DeleteFolder = 'delete-folder', +} + +export enum WorkspaceLogGlobalActionType { + Share = 'share', + Delete = 'delete', + DeleteAll = 'delete-all', } export enum WorkspaceLogPlatform { - WEB = 'WEB', - MOBILE = 'MOBILE', - DESKTOP = 'DESKTOP', - UNSPECIFIED = 'UNSPECIFIED', + Web = 'web', + Mobile = 'mobile', + Desktop = 'desktop', } export interface WorkspaceLogAttributes { diff --git a/src/modules/workspaces/decorators/workspace-log-action.decorator.ts b/src/modules/workspaces/decorators/workspace-log-action.decorator.ts index f4345930..f1dc9eb6 100644 --- a/src/modules/workspaces/decorators/workspace-log-action.decorator.ts +++ b/src/modules/workspaces/decorators/workspace-log-action.decorator.ts @@ -1,8 +1,13 @@ import { applyDecorators, SetMetadata, UseInterceptors } from '@nestjs/common'; -import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; +import { + WorkspaceLogGlobalActionType, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; import { WorkspacesLogsInterceptor } from './../interceptors/workspaces-logs.interceptor'; -export const WorkspaceLogAction = (action: WorkspaceLogType) => +export const WorkspaceLogAction = ( + action: WorkspaceLogType | WorkspaceLogGlobalActionType, +) => applyDecorators( SetMetadata('workspaceLogAction', action), UseInterceptors(WorkspacesLogsInterceptor), diff --git a/src/modules/workspaces/dto/get-workspace-logs.ts b/src/modules/workspaces/dto/get-workspace-logs.ts index 0153a2f2..0f12ea52 100644 --- a/src/modules/workspaces/dto/get-workspace-logs.ts +++ b/src/modules/workspaces/dto/get-workspace-logs.ts @@ -2,6 +2,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ArrayNotEmpty, IsArray, + IsBoolean, IsEnum, IsInt, IsOptional, @@ -10,7 +11,7 @@ import { } from 'class-validator'; import { OrderBy } from './../../../common/order.type'; import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; export class GetWorkspaceLogsDto { @IsOptional() @@ -48,4 +49,9 @@ export class GetWorkspaceLogsDto { @Min(0) @Type(() => Number) lastDays?: number; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + summary?: boolean = true; } diff --git a/src/modules/workspaces/guards/workspaces.guard.spec.ts b/src/modules/workspaces/guards/workspaces.guard.spec.ts index 761f8bcf..c8bc1730 100644 --- a/src/modules/workspaces/guards/workspaces.guard.spec.ts +++ b/src/modules/workspaces/guards/workspaces.guard.spec.ts @@ -3,7 +3,6 @@ import { BadRequestException, ExecutionContext, ForbiddenException, - Logger, NotFoundException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; @@ -22,8 +21,6 @@ import { import { WorkspaceUser } from '../domains/workspace-user.domain'; import { WorkspaceTeamUser } from '../domains/workspace-team-user.domain'; import { v4 } from 'uuid'; -import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; -import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; describe('WorkspaceGuard', () => { let guard: WorkspaceGuard; @@ -63,44 +60,6 @@ describe('WorkspaceGuard', () => { expect(canUserAccess).toBeFalsy(); }); - it('When workspaceLogAction is DELETE_ALL, then it should set request.items', async () => { - const mockGetItems = [{ type: WorkspaceItemType.File, uuid: v4() }]; - const workspaceOwner = newUser(); - const workspace = newWorkspace({ owner: workspaceOwner }); - - const mockRequest = { - user: workspaceOwner, - params: { workspaceId: workspace.id }, - }; - - const mockContext = { - switchToHttp: () => ({ - getRequest: () => mockRequest, - }), - getHandler: () => jest.fn(), - } as unknown as ExecutionContext; - - jest.spyOn(reflector, 'get').mockReturnValueOnce({ - requiredRole: WorkspaceRole.OWNER, - accessContext: AccessContext.WORKSPACE, - idSource: 'params', - }); - jest - .spyOn(reflector, 'get') - .mockReturnValueOnce(WorkspaceLogType.DELETE_ALL); - - workspaceUseCases.findUserAndWorkspace.mockResolvedValue({ - workspace, - workspaceUser: {} as WorkspaceUser, - }); - - jest.spyOn(guard, 'getItems').mockResolvedValueOnce(mockGetItems); - - const request = mockContext.switchToHttp().getRequest(); - await guard.canActivate(mockContext); - expect(request.items).toEqual(mockGetItems); - }); - describe('Workspace Permissions', () => { it('When workspace id is not valid, then throw ', async () => { const user = newUser(); @@ -498,70 +457,6 @@ describe('WorkspaceGuard', () => { ); }); }); - - describe('getItems()', () => { - const user = newUser(); - const workspaceId = v4(); - - it('When there are trashed files and folders, then it should return an array of trashed items', async () => { - const mockFiles = [{ uuid: 'file-uuid-1' }, { uuid: 'file-uuid-2' }]; - const mockFolders = [{ uuid: 'folder-uuid-1' }, { uuid: null }]; - - workspaceUseCases.getWorkspaceUserTrashedItems - .mockResolvedValueOnce({ result: mockFiles } as any) - .mockResolvedValueOnce({ result: mockFolders } as any); - - const result = await guard.getItems(user, workspaceId); - - expect(result).toEqual([ - { type: WorkspaceItemType.File, uuid: 'file-uuid-1' }, - { type: WorkspaceItemType.File, uuid: 'file-uuid-2' }, - { type: WorkspaceItemType.Folder, uuid: 'folder-uuid-1' }, - ]); - }); - - it('When there are no trashed items, then it should return an empty array', async () => { - workspaceUseCases.getWorkspaceUserTrashedItems - .mockResolvedValueOnce({ result: [] }) - .mockResolvedValueOnce({ result: [] }); - - const result = await guard.getItems(user, workspaceId); - - expect(result).toEqual([]); - }); - - it('When there are items without a uuid, then it should filter them out', async () => { - const mockFiles = [{ uuid: null }, { uuid: 'file-uuid-1' }]; - const mockFolders = [{ uuid: 'folder-uuid-1' }, { uuid: null }]; - - workspaceUseCases.getWorkspaceUserTrashedItems - .mockResolvedValueOnce({ result: mockFiles } as any) - .mockResolvedValueOnce({ result: mockFolders } as any); - - const result = await guard.getItems(user, workspaceId); - - expect(result).toEqual([ - { type: WorkspaceItemType.File, uuid: 'file-uuid-1' }, - { type: WorkspaceItemType.Folder, uuid: 'folder-uuid-1' }, - ]); - }); - - it('When there is an error fetching trashed items, then it should handle the error gracefully', async () => { - const loggerDebugSpy = jest.spyOn(Logger, 'debug').mockImplementation(); - workspaceUseCases.getWorkspaceUserTrashedItems - .mockRejectedValueOnce(new Error('Error fetching files')) - .mockResolvedValueOnce({ result: [] }); - - const result = await guard.getItems(user, workspaceId); - - expect(result).toEqual(undefined); - expect(loggerDebugSpy).toHaveBeenCalledWith( - '[WORKSPACES/GUARD] Error fetching trashed items:', - expect.any(Error), - ); - loggerDebugSpy.mockRestore(); - }); - }); }); const createMockExecutionContext = ( diff --git a/src/modules/workspaces/guards/workspaces.guard.ts b/src/modules/workspaces/guards/workspaces.guard.ts index cb3b218f..ba5972e4 100644 --- a/src/modules/workspaces/guards/workspaces.guard.ts +++ b/src/modules/workspaces/guards/workspaces.guard.ts @@ -17,13 +17,6 @@ import { } from './workspace-required-access.decorator'; import { User } from '../../user/user.domain'; import { isUUID } from 'class-validator'; -import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; -import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; - -interface TrashItem { - type: WorkspaceItemType; - uuid: string; -} @Injectable() export class WorkspaceGuard implements CanActivate { @@ -64,22 +57,13 @@ export class WorkspaceGuard implements CanActivate { ); } - let verified = false; - const workspaceLogAction = - this.reflector.get('workspaceLogAction', context.getHandler()) || null; - if (accessContext === AccessContext.WORKSPACE) { - verified = await this.verifyWorkspaceAccessByRole(user, id, requiredRole); + return this.verifyWorkspaceAccessByRole(user, id, requiredRole); } else if (accessContext === AccessContext.TEAM) { - verified = await this.verifyTeamAccessByRole(user, id, requiredRole); - } - - if (verified && workspaceLogAction === WorkspaceLogType.DELETE_ALL) { - const items: TrashItem[] = await this.getItems(user, id); - request.items = items; + return this.verifyTeamAccessByRole(user, id, requiredRole); } - return verified; + return false; } private async verifyWorkspaceAccessByRole( @@ -172,44 +156,4 @@ export class WorkspaceGuard implements CanActivate { ): string | undefined { return request[source]?.[field]; } - - async getItems(user: User, workspaceId: string): Promise { - try { - const { result: files } = - await this.workspaceUseCases.getWorkspaceUserTrashedItems( - user, - workspaceId, - WorkspaceItemType.File, - null, - ); - - const { result: folders } = - await this.workspaceUseCases.getWorkspaceUserTrashedItems( - user, - workspaceId, - WorkspaceItemType.Folder, - null, - ); - - const items: TrashItem[] = [ - ...(Array.isArray(files) ? files : []) - .filter((file) => file.uuid != null) - .map((file) => ({ - type: WorkspaceItemType.File, - uuid: file.uuid, - })), - ...(Array.isArray(folders) ? folders : []) - .filter((folder) => folder.uuid != null) - .map((folder) => ({ - type: WorkspaceItemType.Folder, - uuid: folder.uuid, - })), - ]; - - return items; - } catch (error) { - Logger.debug('[WORKSPACES/GUARD] Error fetching trashed items:', error); - return; - } - } } diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts index 867ec1bc..c8136b5a 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -4,6 +4,7 @@ import { SequelizeWorkspaceRepository } from '../repositories/workspaces.reposit import { WorkspaceLogType, WorkspaceLogPlatform, + WorkspaceLogGlobalActionType, } from '../attributes/workspace-logs.attributes'; import { CallHandler, ExecutionContext, Logger } from '@nestjs/common'; import { lastValueFrom, of } from 'rxjs'; @@ -23,22 +24,22 @@ describe('WorkspacesLogsInterceptor', () => { describe('determinePlatform()', () => { it('When client is drive-web, then it should return WEB platform', () => { const platform = interceptor.determinePlatform('drive-web'); - expect(platform).toBe(WorkspaceLogPlatform.WEB); + expect(platform).toBe(WorkspaceLogPlatform.Web); }); it('When client is drive-mobile, then it should return MOBILE platform', () => { const platform = interceptor.determinePlatform('drive-mobile'); - expect(platform).toBe(WorkspaceLogPlatform.MOBILE); + expect(platform).toBe(WorkspaceLogPlatform.Mobile); }); it('When client is drive-desktop, then it should return DESKTOP platform', () => { const platform = interceptor.determinePlatform('drive-desktop'); - expect(platform).toBe(WorkspaceLogPlatform.DESKTOP); + expect(platform).toBe(WorkspaceLogPlatform.Desktop); }); it('When client is unknown, then it should return UNSPECIFIED platform', () => { const platform = interceptor.determinePlatform('unknown-client'); - expect(platform).toBe(WorkspaceLogPlatform.UNSPECIFIED); + expect(platform).toBeUndefined(); }); }); @@ -56,7 +57,7 @@ describe('WorkspacesLogsInterceptor', () => { it('When log action is valid, then it should call handleAction', async () => { Reflect.defineMetadata( 'workspaceLogAction', - WorkspaceLogType.LOGIN, + WorkspaceLogType.Login, mockHandler, ); @@ -99,7 +100,7 @@ describe('WorkspacesLogsInterceptor', () => { const res = {}; it('When action is recognized, then it should call the corresponding method', async () => { - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; const handleUserActionSpy = jest .spyOn(interceptor, 'handleUserAction') @@ -107,21 +108,21 @@ describe('WorkspacesLogsInterceptor', () => { await interceptor.handleAction( platform, - WorkspaceLogType.LOGIN, + WorkspaceLogType.Login, req, res, ); expect(handleUserActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.LOGIN, + WorkspaceLogType.Login, req, res, ); }); it('When action is not recognized, then it should log a debug message', async () => { - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; await interceptor.handleAction( platform, @@ -140,8 +141,8 @@ describe('WorkspacesLogsInterceptor', () => { const payload = { workspaceId: 'workspace-id', creator: 'user-id', - type: WorkspaceLogType.LOGIN, - platform: WorkspaceLogPlatform.WEB, + type: WorkspaceLogType.Login, + platform: WorkspaceLogPlatform.Web, entityId: 'entity-id', }; @@ -164,7 +165,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(loggerDebugSpy).toHaveBeenCalledWith( expect.stringContaining( - 'An error occurred trying to register a log of type LOGIN for the user user-id', + `An error occurred trying to register a log of type ${payload.type} for the user user-id`, ), expect.any(Error), ); @@ -185,8 +186,8 @@ describe('WorkspacesLogsInterceptor', () => { .mockImplementation(); await interceptor.handleUserAction( - WorkspaceLogPlatform.WEB, - WorkspaceLogType.LOGIN, + WorkspaceLogPlatform.Web, + WorkspaceLogType.Login, req, res, ); @@ -196,8 +197,8 @@ describe('WorkspacesLogsInterceptor', () => { expect(registerLogSpy).toHaveBeenCalledWith({ workspaceId, creator: 'user-id', - type: WorkspaceLogType.LOGIN, - platform: WorkspaceLogPlatform.WEB, + type: WorkspaceLogType.Login, + platform: WorkspaceLogPlatform.Web, }); }); }); @@ -207,8 +208,8 @@ describe('WorkspacesLogsInterceptor', () => { const res = {}; await interceptor.handleUserAction( - WorkspaceLogPlatform.WEB, - WorkspaceLogType.LOGIN, + WorkspaceLogPlatform.Web, + WorkspaceLogType.Login, req, res, ); @@ -227,7 +228,7 @@ describe('WorkspacesLogsInterceptor', () => { workspace: { id: 'workspace-id' }, }; const res = { user: { uuid: 'user-id' } }; - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; jest.spyOn(interceptor, 'extractRequestData').mockReturnValue({ ok: true, @@ -240,7 +241,7 @@ describe('WorkspacesLogsInterceptor', () => { await interceptor.handleUserWorkspaceAction( platform, - WorkspaceLogType.SHARE_FILE, + WorkspaceLogType.ShareFile, req, res, ); @@ -248,7 +249,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(registerLogSpy).toHaveBeenCalledWith({ workspaceId: 'workspace-id', creator: 'user-id', - type: WorkspaceLogType.SHARE_FILE, + type: WorkspaceLogType.ShareFile, platform, entityId: 'item-id', }); @@ -257,11 +258,11 @@ describe('WorkspacesLogsInterceptor', () => { it('When request data is invalid, then it should log a debug message', async () => { const req = { body: {}, params: {}, workspace: {} }; const res = { user: { uuid: 'user-id' } }; - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; await interceptor.handleUserWorkspaceAction( platform, - WorkspaceLogType.SHARE_FILE, + WorkspaceLogType.ShareFile, req, res, ); @@ -274,15 +275,15 @@ describe('WorkspacesLogsInterceptor', () => { describe('getItemType()', () => { it('When itemType is in body, then it should return itemType', () => { - const req = { body: { itemType: 'FILE' } }; + const req = { body: { itemType: 'file' } }; const result = interceptor.getItemType(req); - expect(result).toBe('FILE'); + expect(result).toBe('file'); }); it('When itemType is in params, then it should return itemType', () => { - const req = { params: { itemType: 'FOLDER' } }; + const req = { params: { itemType: 'folder' } }; const result = interceptor.getItemType(req); - expect(result).toBe('FOLDER'); + expect(result).toBe('folder'); }); it('When itemType is not present, then it should return undefined', () => { @@ -319,41 +320,41 @@ describe('WorkspacesLogsInterceptor', () => { }); }); - describe('determineAction()', () => { - it('When type is SHARE and itemType is File, then it should return SHARE_FILE', () => { - const action = interceptor.determineAction( - 'SHARE', + describe('getActionForGlobalLogType()', () => { + it('When type is Share and itemType is File, then it should return ShareFile', () => { + const action = interceptor.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Share, WorkspaceItemType.File, ); - expect(action).toBe(WorkspaceLogType.SHARE_FILE); + expect(action).toBe(WorkspaceLogType.ShareFile); }); - it('When type is SHARE and itemType is Folder, then it should return SHARE_FOLDER', () => { - const action = interceptor.determineAction( - 'SHARE', + it('When type is Share and itemType is Folder, then it should return ShareFolder', () => { + const action = interceptor.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Share, WorkspaceItemType.Folder, ); - expect(action).toBe(WorkspaceLogType.SHARE_FOLDER); + expect(action).toBe(WorkspaceLogType.ShareFolder); }); - it('When type is DELETE and itemType is File, then it should return DELETE_FILE', () => { - const action = interceptor.determineAction( - 'DELETE', + it('When type is Delete and itemType is File, then it should return DeleteFile', () => { + const action = interceptor.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Delete, WorkspaceItemType.File, ); - expect(action).toBe(WorkspaceLogType.DELETE_FILE); + expect(action).toBe(WorkspaceLogType.DeleteFile); }); - it('When type is DELETE and itemType is Folder, then it should return DELETE_FOLDER', () => { - const action = interceptor.determineAction( - 'DELETE', + it('When type is Delete and itemType is Folder, then it should return DeleteFolder', () => { + const action = interceptor.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Delete, WorkspaceItemType.Folder, ); - expect(action).toBe(WorkspaceLogType.DELETE_FOLDER); + expect(action).toBe(WorkspaceLogType.DeleteFolder); }); it('When type is invalid, then it should log a debug message', () => { - const action = interceptor.determineAction( + const action = interceptor.getActionForGlobalLogType( 'INVALID_TYPE' as any, WorkspaceItemType.File, ); @@ -364,13 +365,13 @@ describe('WorkspacesLogsInterceptor', () => { }); it('When itemType is invalid, then it should log a debug message', () => { - const action = interceptor.determineAction( - 'SHARE', + const action = interceptor.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Share, 'INVALID_ITEM_TYPE' as any, ); expect(action).toBeNull(); expect(loggerDebugSpy).toHaveBeenCalledWith( - '[WORKSPACE/LOGS] Invalid action type: SHARE or item type: INVALID_ITEM_TYPE', + `[WORKSPACE/LOGS] Invalid action type: ${WorkspaceLogGlobalActionType.Share} or item type: INVALID_ITEM_TYPE`, ); }); }); @@ -466,8 +467,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('logIn()', () => { - it('When called, then it should call handleUser Action with LOGIN type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser Action with logIn type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -479,7 +480,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.LOGIN, + WorkspaceLogType.Login, req, res, ); @@ -487,8 +488,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('changedPassword()', () => { - it('When called, then it should call handleUser Action with CHANGED_PASSWORD type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser Action with ChangedPassword type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -500,7 +501,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.CHANGED_PASSWORD, + WorkspaceLogType.ChangedPassword, req, res, ); @@ -508,8 +509,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('logout()', () => { - it('When called, then it should call handleUser Action with LOGOUT type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser Action with Logout type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -521,7 +522,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.LOGOUT, + WorkspaceLogType.Logout, req, res, ); @@ -530,30 +531,33 @@ describe('WorkspacesLogsInterceptor', () => { describe('share()', () => { it('When itemType is valid, then it should call handleUserWorkspaceAction', async () => { - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; const req = { body: { itemType: 'file' } }; const res = {}; - const determineActionSpy = jest - .spyOn(interceptor, 'determineAction') - .mockReturnValue(WorkspaceLogType.SHARE_FILE); + const getActionForGlobalLogType = jest + .spyOn(interceptor, 'getActionForGlobalLogType') + .mockReturnValue(WorkspaceLogType.ShareFile); const handleUserWorkspaceActionSpy = jest .spyOn(interceptor, 'handleUserWorkspaceAction') .mockImplementation(); await interceptor.share(platform, req, res); - expect(determineActionSpy).toHaveBeenCalledWith('SHARE', 'file'); + expect(getActionForGlobalLogType).toHaveBeenCalledWith( + WorkspaceLogGlobalActionType.Share, + 'file', + ); expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.SHARE_FILE, + WorkspaceLogType.ShareFile, req, res, ); }); it('When itemType is not provided, then it should log a debug message', async () => { - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; const req = { body: {} }; const res = {}; @@ -566,8 +570,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('shareFile()', () => { - it('When called, then it should call handleUser WorkspaceAction with SHARE_FILE type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser WorkspaceAction with ShareFile type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -579,7 +583,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.SHARE_FILE, + WorkspaceLogType.ShareFile, req, res, ); @@ -587,8 +591,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('shareFolder()', () => { - it('When called, then it should call handleUser WorkspaceAction with SHARE_FOLDER type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser WorkspaceAction with ShareFolder type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -600,7 +604,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.SHARE_FOLDER, + WorkspaceLogType.ShareFolder, req, res, ); @@ -609,7 +613,7 @@ describe('WorkspacesLogsInterceptor', () => { describe('delete()', () => { it('When items are provided, then it should register logs for each item', async () => { - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; const req = { body: { items: [{ type: 'file', uuid: 'file-id' }] } }; const res = {}; const registerLogSpy = jest @@ -623,8 +627,8 @@ describe('WorkspacesLogsInterceptor', () => { workspaceId: 'workspace-id', }); jest - .spyOn(interceptor, 'determineAction') - .mockReturnValue(WorkspaceLogType.DELETE_FILE); + .spyOn(interceptor, 'getActionForGlobalLogType') + .mockReturnValue(WorkspaceLogType.DeleteFile); await interceptor.delete(platform, req, res); @@ -632,14 +636,14 @@ describe('WorkspacesLogsInterceptor', () => { expect(registerLogSpy).toHaveBeenCalledWith({ workspaceId: 'workspace-id', creator: 'requester-uuid', - type: WorkspaceLogType.DELETE_FILE, + type: WorkspaceLogType.DeleteFile, platform, entityId: 'file-id', }); }); it('When no items are provided, then it should log a debug message', async () => { - const platform = WorkspaceLogPlatform.WEB; + const platform = WorkspaceLogPlatform.Web; const req = { body: {} }; const res = {}; @@ -652,8 +656,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('deleteFile()', () => { - it('When called, then it should call handleUser WorkspaceAction with DELETE_FILE type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser WorkspaceAction with DeleteFile type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -665,7 +669,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.DELETE_FILE, + WorkspaceLogType.DeleteFile, req, res, ); @@ -673,8 +677,8 @@ describe('WorkspacesLogsInterceptor', () => { }); describe('deleteFolder()', () => { - it('When called, then it should call handleUser WorkspaceAction with DELETE_FOLDER type', async () => { - const platform = WorkspaceLogPlatform.WEB; + it('When called, then it should call handleUser WorkspaceAction with DeleteFolder type', async () => { + const platform = WorkspaceLogPlatform.Web; const req = {}; const res = {}; @@ -686,7 +690,7 @@ describe('WorkspacesLogsInterceptor', () => { expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( platform, - WorkspaceLogType.DELETE_FOLDER, + WorkspaceLogType.DeleteFolder, req, res, ); diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts index bb47d51d..c5670ce6 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -6,89 +6,108 @@ import { Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +import { tap } from 'rxjs/operators'; import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; import { WorkspaceLogAttributes, WorkspaceLogPlatform, WorkspaceLogType, + WorkspaceLogGlobalActionType, } from '../attributes/workspace-logs.attributes'; import { User } from '../../user/user.domain'; import { DeleteItem } from './../../trash/dto/controllers/delete-item.dto'; import { WorkspaceItemType } from '../attributes/workspace-items-users.attributes'; type ActionHandler = { - [key in WorkspaceLogType]: ( + [key in WorkspaceLogType | WorkspaceLogGlobalActionType]: ( platform: WorkspaceLogPlatform, req: any, res: any, ) => Promise; }; +export interface TrashItem { + id?: string; + type: WorkspaceItemType; + uuid: string; +} + @Injectable() export class WorkspacesLogsInterceptor implements NestInterceptor { public actionHandler: ActionHandler; - public logAction: WorkspaceLogType; + public logAction: WorkspaceLogType | WorkspaceLogGlobalActionType; constructor( private readonly workspaceRepository: SequelizeWorkspaceRepository, ) { this.actionHandler = { - [WorkspaceLogType.LOGIN]: this.logIn.bind(this), - [WorkspaceLogType.CHANGED_PASSWORD]: this.changedPassword.bind(this), - [WorkspaceLogType.LOGOUT]: this.logout.bind(this), - [WorkspaceLogType.DELETE]: this.delete.bind(this), - [WorkspaceLogType.DELETE_ALL]: this.delete.bind(this), - [WorkspaceLogType.DELETE_FILE]: this.deleteFile.bind(this), - [WorkspaceLogType.DELETE_FOLDER]: this.deleteFolder.bind(this), - [WorkspaceLogType.SHARE]: this.share.bind(this), - [WorkspaceLogType.SHARE_FILE]: this.shareFile.bind(this), - [WorkspaceLogType.SHARE_FOLDER]: this.shareFolder.bind(this), + [WorkspaceLogType.Login]: this.logIn.bind(this), + [WorkspaceLogType.ChangedPassword]: this.changedPassword.bind(this), + [WorkspaceLogType.Logout]: this.logout.bind(this), + [WorkspaceLogType.DeleteFile]: this.deleteFile.bind(this), + [WorkspaceLogType.DeleteFolder]: this.deleteFolder.bind(this), + [WorkspaceLogType.ShareFile]: this.shareFile.bind(this), + [WorkspaceLogType.ShareFolder]: this.shareFolder.bind(this), + [WorkspaceLogGlobalActionType.Share]: this.share.bind(this), + [WorkspaceLogGlobalActionType.Delete]: this.delete.bind(this), + [WorkspaceLogGlobalActionType.DeleteAll]: this.delete.bind(this), }; } - determinePlatform(client: string): WorkspaceLogPlatform { + determinePlatform(client: string): WorkspaceLogPlatform | undefined { const platforms = { - 'drive-web': WorkspaceLogPlatform.WEB, - 'drive-mobile': WorkspaceLogPlatform.MOBILE, - 'drive-desktop': WorkspaceLogPlatform.DESKTOP, + 'drive-web': WorkspaceLogPlatform.Web, + 'drive-mobile': WorkspaceLogPlatform.Mobile, + 'drive-desktop': WorkspaceLogPlatform.Desktop, }; - return platforms[client] || WorkspaceLogPlatform.UNSPECIFIED; + return platforms[client]; } intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); this.logAction = this.getWorkspaceLogAction(context); - if (!Object.values(WorkspaceLogType).includes(this.logAction)) { + if ( + !Object.values({ + ...WorkspaceLogType, + ...WorkspaceLogGlobalActionType, + }).includes(this.logAction) + ) { Logger.debug(`[WORKSPACE/LOGS] Invalid log action: ${this.logAction}`); return; } const platform = this.determinePlatform(request.headers['internxt-client']); + if (!platform) { + Logger.error( + `[WORKSPACE/LOGS] Platform not specified for log action: ${this.logAction}`, + ); + return; + } + return next.handle().pipe( - mergeMap(async (res) => { - await this.handleAction(platform, this.logAction, request, res); + tap((res) => { + this.handleAction(platform, this.logAction, request, res); }), ); } async logIn(platform: WorkspaceLogPlatform, req: any, res: any) { - await this.handleUserAction(platform, WorkspaceLogType.LOGIN, req, res); + await this.handleUserAction(platform, WorkspaceLogType.Login, req, res); } async changedPassword(platform: WorkspaceLogPlatform, req: any, res: any) { await this.handleUserAction( platform, - WorkspaceLogType.CHANGED_PASSWORD, + WorkspaceLogType.ChangedPassword, req, res, ); } async logout(platform: WorkspaceLogPlatform, req: any, res: any) { - await this.handleUserAction(platform, WorkspaceLogType.LOGOUT, req, res); + await this.handleUserAction(platform, WorkspaceLogType.Logout, req, res); } async share(platform: WorkspaceLogPlatform, req: any, res: any) { @@ -98,8 +117,8 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { return; } - const action = this.determineAction( - 'SHARE', + const action = this.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Share, itemType as unknown as WorkspaceItemType, ); if (action) { @@ -110,7 +129,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { async shareFile(platform: WorkspaceLogPlatform, req: any, res: any) { await this.handleUserWorkspaceAction( platform, - WorkspaceLogType.SHARE_FILE, + WorkspaceLogType.ShareFile, req, res, ); @@ -119,14 +138,14 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { async shareFolder(platform: WorkspaceLogPlatform, req: any, res: any) { await this.handleUserWorkspaceAction( platform, - WorkspaceLogType.SHARE_FOLDER, + WorkspaceLogType.ShareFolder, req, res, ); } async delete(platform: WorkspaceLogPlatform, req: any, res: any) { - const items: DeleteItem[] = req?.body?.items || req?.items; + const items: DeleteItem[] | TrashItem[] = req?.body?.items || res?.items; if (!items || items.length === 0) { Logger.debug('[WORKSPACE/LOGS] The items are required'); return; @@ -136,9 +155,9 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { if (ok) { const deletePromises = items - .map((item) => { - const action = this.determineAction( - 'DELETE', + .map((item: DeleteItem | TrashItem) => { + const action = this.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Delete, item.type as unknown as WorkspaceItemType, ); if (action) { @@ -147,7 +166,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { creator: requesterUuid, type: action, platform, - entityId: item.uuid || item.id, + entityId: item.uuid || item?.id, }); } return null; @@ -160,7 +179,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { async deleteFile(platform: WorkspaceLogPlatform, req: any, res: any) { await this.handleUserWorkspaceAction( platform, - WorkspaceLogType.DELETE_FILE, + WorkspaceLogType.DeleteFile, req, res, ); @@ -169,7 +188,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { async deleteFolder(platform: WorkspaceLogPlatform, req: any, res: any) { await this.handleUserWorkspaceAction( platform, - WorkspaceLogType.DELETE_FOLDER, + WorkspaceLogType.DeleteFolder, req, res, ); @@ -177,7 +196,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { async handleAction( platform: WorkspaceLogPlatform, - action: WorkspaceLogType, + action: WorkspaceLogType | WorkspaceLogGlobalActionType, req: any, res: any, ) { @@ -189,7 +208,9 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { } } - getWorkspaceLogAction(context: ExecutionContext): WorkspaceLogType { + getWorkspaceLogAction( + context: ExecutionContext, + ): WorkspaceLogType | WorkspaceLogGlobalActionType { const handler = context.getHandler(); return Reflect.getMetadata('workspaceLogAction', handler) || null; } @@ -281,18 +302,18 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { return req?.body?.itemId || req?.params?.itemId || res?.itemId; } - determineAction( - type: 'SHARE' | 'DELETE', + getActionForGlobalLogType( + type: WorkspaceLogGlobalActionType, itemType: WorkspaceItemType, ): WorkspaceLogType { const actionMap = { - SHARE: { - [WorkspaceItemType.File]: WorkspaceLogType.SHARE_FILE, - [WorkspaceItemType.Folder]: WorkspaceLogType.SHARE_FOLDER, + [WorkspaceLogGlobalActionType.Share]: { + [WorkspaceItemType.File]: WorkspaceLogType.ShareFile, + [WorkspaceItemType.Folder]: WorkspaceLogType.ShareFolder, }, - DELETE: { - [WorkspaceItemType.File]: WorkspaceLogType.DELETE_FILE, - [WorkspaceItemType.Folder]: WorkspaceLogType.DELETE_FOLDER, + [WorkspaceLogGlobalActionType.Delete]: { + [WorkspaceItemType.File]: WorkspaceLogType.DeleteFile, + [WorkspaceItemType.Folder]: WorkspaceLogType.DeleteFolder, }, }; diff --git a/src/modules/workspaces/repositories/workspaces.repository.spec.ts b/src/modules/workspaces/repositories/workspaces.repository.spec.ts index 0ec1fa3a..9e61d3bb 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.spec.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.spec.ts @@ -393,21 +393,21 @@ describe('SequelizeWorkspaceRepository', () => { const workspaceId = v4(); const user = newUser({ attributes: { email: 'test@example.com' } }); const pagination = { limit: 10, offset: 0 }; - const member = 'test@example.com'; + const mockMembersUuids: string[] = undefined; const logType: WorkspaceLog['type'][] = [ - WorkspaceLogType.LOGIN, - WorkspaceLogType.LOGOUT, + WorkspaceLogType.Login, + WorkspaceLogType.Logout, ]; const lastDays = 7; - const order: [string, string][] = [['createdAt', 'DESC']]; + const order: [string, string][] = [['updatedAt', 'DESC']]; const date = new Date(); const workspaceLogtoJson = { id: v4(), workspaceId, creator: user.uuid, - type: WorkspaceLogType.LOGIN, - platform: WorkspaceLogPlatform.WEB, + type: WorkspaceLogType.Login, + platform: WorkspaceLogPlatform.Web, entityId: null, createdAt: date, updatedAt: date, @@ -439,7 +439,7 @@ describe('SequelizeWorkspaceRepository', () => { const whereConditions = { workspaceId, - createdAt: { [Op.gte]: dateLimit }, + updatedAt: { [Op.gte]: dateLimit }, }; jest @@ -449,7 +449,7 @@ describe('SequelizeWorkspaceRepository', () => { await repository.accessLogs( workspaceId, true, - member, + mockMembersUuids, logType, pagination, lastDays, @@ -465,12 +465,11 @@ describe('SequelizeWorkspaceRepository', () => { }); it('when member is provided, then should filter logs by member email or name', async () => { + const mockMembers = [newWorkspaceUser(), newWorkspaceUser()]; + const mockMembersUuids = mockMembers.map((m) => m.memberId); const whereConditions = { workspaceId, - [Op.or]: [ - { '$user.email$': { [Op.iLike]: `%${member}%` } }, - { '$user.name$': { [Op.iLike]: `%${member}%` } }, - ], + creator: { [Op.in]: mockMembersUuids }, }; jest @@ -480,7 +479,7 @@ describe('SequelizeWorkspaceRepository', () => { await repository.accessLogs( workspaceId, true, - member, + mockMembersUuids, logType, pagination, lastDays, @@ -508,7 +507,7 @@ describe('SequelizeWorkspaceRepository', () => { await repository.accessLogs( workspaceId, true, - member, + mockMembersUuids, logType, pagination, lastDays, @@ -534,7 +533,7 @@ describe('SequelizeWorkspaceRepository', () => { await repository.accessLogs( workspaceId, true, - member, + mockMembersUuids, logType, pagination, lastDays, @@ -558,7 +557,7 @@ describe('SequelizeWorkspaceRepository', () => { await repository.accessLogs( workspaceId, true, - member, + mockMembersUuids, logType, undefined, lastDays, @@ -584,7 +583,7 @@ describe('SequelizeWorkspaceRepository', () => { await repository.accessLogs( workspaceId, true, - member, + mockMembersUuids, logType, pagination, lastDays, @@ -594,7 +593,7 @@ describe('SequelizeWorkspaceRepository', () => { where: expect.objectContaining(whereConditions), include: expect.any(Array), ...pagination, - order: [['createdAt', 'DESC']], + order: [['updatedAt', 'DESC']], }); }); }); @@ -606,7 +605,7 @@ describe('SequelizeWorkspaceRepository', () => { const toJson = { id: v4(), workspaceId: workspaceId, - type: WorkspaceLogType.SHARE_FILE, + type: WorkspaceLogType.ShareFile, createdAt: new Date(), updatedAt: new Date(), creator: v4(), @@ -637,13 +636,13 @@ describe('SequelizeWorkspaceRepository', () => { it('When model is provided, then it should return a summary of WorkspaceLog entity', async () => { const toJson = { id: v4(), - type: WorkspaceLogType.SHARE_FOLDER, + type: WorkspaceLogType.ShareFolder, workspaceId: workspaceId, createdAt: new Date(), updatedAt: new Date(), creator: v4(), entityId: folderId, - platform: WorkspaceLogPlatform.WEB, + platform: WorkspaceLogPlatform.Web, }; const model: WorkspaceLogModel = { user: { id: 20, name: 'John Doe' }, diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index 6999f4ad..85bea0d0 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -23,7 +23,10 @@ import { FileModel } from '../../file/file.model'; import { File, FileAttributes } from '../../file/file.domain'; import { Folder, FolderAttributes } from '../../folder/folder.domain'; import { FolderModel } from '../../folder/folder.model'; -import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; +import { + WorkspaceLogAttributes, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; import { WorkspaceLogModel } from '../models/workspace-logs.model'; import { WorkspaceLog } from '../domains/workspace-log.domain'; @@ -481,14 +484,14 @@ export class SequelizeWorkspaceRepository { async accessLogs( workspaceId: Workspace['id'], summary: boolean = false, - member?: string, + membersUuids?: WorkspaceLog['creator'][], logType?: WorkspaceLog['type'][], pagination?: { limit?: number; offset?: number; }, lastDays?: number, - order: [string, string][] = [['createdAt', 'DESC']], + order: [string, string][] = [['updatedAt', 'DESC']], ) { const dateLimit = new Date(); if (lastDays) { @@ -498,14 +501,11 @@ export class SequelizeWorkspaceRepository { const whereConditions: any = { workspaceId, - ...(lastDays && dateLimit ? { createdAt: { [Op.gte]: dateLimit } } : {}), + ...(lastDays && dateLimit ? { updatedAt: { [Op.gte]: dateLimit } } : {}), }; - if (member) { - whereConditions[Op.or] = [ - { '$user.email$': { [Op.iLike]: `%${member}%` } }, - { '$user.name$': { [Op.iLike]: `%${member}%` } }, - ]; + if (membersUuids) { + whereConditions.creator = { [Op.in]: membersUuids }; } if (logType && logType.length > 0) { @@ -537,7 +537,10 @@ export class SequelizeWorkspaceRepository { on: { [Op.and]: [ Sequelize.where(Sequelize.col('WorkspaceLogModel.type'), { - [Op.or]: ['SHARE_FILE', 'DELETE_FILE'], + [Op.or]: [ + WorkspaceLogType.ShareFile, + WorkspaceLogType.DeleteFile, + ], }), Sequelize.where( Sequelize.col('file.uuid'), @@ -558,7 +561,10 @@ export class SequelizeWorkspaceRepository { on: { [Op.and]: [ Sequelize.where(Sequelize.col('WorkspaceLogModel.type'), { - [Op.or]: ['SHARE_FOLDER', 'DELETE_FOLDER'], + [Op.or]: [ + WorkspaceLogType.ShareFolder, + WorkspaceLogType.DeleteFolder, + ], }), Sequelize.where( Sequelize.col('folder.uuid'), diff --git a/src/modules/workspaces/workspaces.controller.spec.ts b/src/modules/workspaces/workspaces.controller.spec.ts index f6d30f01..560a11eb 100644 --- a/src/modules/workspaces/workspaces.controller.spec.ts +++ b/src/modules/workspaces/workspaces.controller.spec.ts @@ -764,12 +764,13 @@ describe('Workspace Controller', () => { const workspaceId = v4(); const user = newUser({ attributes: { email: 'test@example.com' } }); const date = new Date(); + const summary = true; const workspaceLogtoJson = { id: v4(), workspaceId, creator: user.uuid, - type: WorkspaceLogType.LOGIN, - platform: WorkspaceLogPlatform.WEB, + type: WorkspaceLogType.Login, + platform: WorkspaceLogPlatform.Web, entityId: null, createdAt: date, updatedAt: date, @@ -795,7 +796,11 @@ describe('Workspace Controller', () => { ]; it('when valid request is made, then should return access logs successfully', async () => { - const workspaceLogDto: GetWorkspaceLogsDto = { limit: 10, offset: 0 }; + const workspaceLogDto: GetWorkspaceLogsDto = { + limit: 10, + offset: 0, + summary, + }; jest.spyOn(workspacesUsecases, 'accessLogs').mockResolvedValue(mockLogs); @@ -812,13 +817,18 @@ describe('Workspace Controller', () => { undefined, undefined, undefined, + summary, undefined, ); }); it('when invalid workspaceId is provided, then should throw', async () => { const invalidWorkspaceId = v4(); - const workspaceLogDto: GetWorkspaceLogsDto = { limit: 10, offset: 0 }; + const workspaceLogDto: GetWorkspaceLogsDto = { + limit: 10, + offset: 0, + summary, + }; jest .spyOn(workspacesUsecases, 'accessLogs') @@ -839,9 +849,10 @@ describe('Workspace Controller', () => { limit: 10, offset: 0, member: mockLogs[0].user.name, - activity: [WorkspaceLogType.LOGIN], + activity: [WorkspaceLogType.Login], lastDays: 7, orderBy: 'createdAt:DESC', + summary: false, }; jest.spyOn(workspacesUsecases, 'accessLogs').mockResolvedValue(mockLogs); @@ -857,8 +868,9 @@ describe('Workspace Controller', () => { workspaceId, { limit: 10, offset: 0 }, username, - [WorkspaceLogType.LOGIN], + [WorkspaceLogType.Login], 7, + false, [['createdAt', 'DESC']], ); }); diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index 7e89e6d7..a13390f7 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -72,7 +72,7 @@ import { GetWorkspaceFilesQueryDto } from './dto/get-workspace-files.dto'; import { GetWorkspaceFoldersQueryDto } from './dto/get-workspace-folders.dto'; import { StorageNotificationService } from '../../externals/notifications/storage.notifications.service'; import { Client } from '../auth/decorators/client.decorator'; -import { WorkspaceLogType } from './attributes/workspace-logs.attributes'; +import { WorkspaceLogGlobalActionType } from './attributes/workspace-logs.attributes'; import { WorkspaceLogAction } from './decorators/workspace-log-action.decorator'; import { GetWorkspaceLogsDto } from './dto/get-workspace-logs'; @@ -652,7 +652,7 @@ export class WorkspacesController { }) @UseGuards(WorkspaceGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) - @WorkspaceLogAction(WorkspaceLogType.SHARE) + @WorkspaceLogAction(WorkspaceLogGlobalActionType.Share) async shareItemWithMember( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -888,7 +888,7 @@ export class WorkspacesController { }) @UseGuards(WorkspaceGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) - @WorkspaceLogAction(WorkspaceLogType.DELETE_ALL) + @WorkspaceLogAction(WorkspaceLogGlobalActionType.DeleteAll) async emptyTrash( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -1186,6 +1186,7 @@ export class WorkspacesController { member, activity: logType, lastDays, + summary, orderBy, } = workspaceLogDto; @@ -1199,6 +1200,7 @@ export class WorkspacesController { member, logType, lastDays, + summary, order, ); } diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 7f13ac56..765119e9 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -59,6 +59,6 @@ import { WorkspaceLogModel } from './models/workspace-logs.model'; PaymentsService, FuzzySearchUseCases, ], - exports: [WorkspacesUsecases, SequelizeModule], + exports: [WorkspacesUsecases, SequelizeModule, SequelizeWorkspaceRepository], }) export class WorkspacesModule {} diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 46af61dc..35622355 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -5945,21 +5945,23 @@ describe('WorkspacesUsecases', () => { const mockWorkspace = newWorkspace({ attributes: { id: workspaceId } }); const user = newUser({ attributes: { email: 'test@example.com' } }); const pagination = { limit: 10, offset: 0 }; - const member = 'test@example.com'; + const member = undefined; + const mockMembersUuids: string[] = undefined; const logType: WorkspaceLog['type'][] = [ - WorkspaceLogType.LOGIN, - WorkspaceLogType.LOGOUT, + WorkspaceLogType.Login, + WorkspaceLogType.Logout, ]; const lastDays = 7; const order: [string, string][] = [['createdAt', 'DESC']]; const date = new Date(); + const summary = true; const workspaceLogtoJson = { id: v4(), workspaceId, creator: user.uuid, - type: WorkspaceLogType.LOGIN, - platform: WorkspaceLogPlatform.WEB, + type: WorkspaceLogType.Login, + platform: WorkspaceLogPlatform.Web, entityId: null, createdAt: date, updatedAt: date, @@ -5996,6 +5998,7 @@ describe('WorkspacesUsecases', () => { member, logType, lastDays, + summary, order, ); @@ -6003,8 +6006,8 @@ describe('WorkspacesUsecases', () => { expect(workspaceRepository.findById).toHaveBeenCalledWith(workspaceId); expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( mockWorkspace.id, - true, - member, + summary, + mockMembersUuids, logType, pagination, lastDays, @@ -6022,6 +6025,7 @@ describe('WorkspacesUsecases', () => { member, logType, lastDays, + summary, order, ), ).rejects.toThrow(NotFoundException); @@ -6032,11 +6036,48 @@ describe('WorkspacesUsecases', () => { member, logType, lastDays, + summary, order, ), ).rejects.toThrow('Workspace not found'); }); + it('when member exist, then should return members logs', async () => { + const member = 'jhon@doe.com'; + const mockMembers = [newWorkspaceUser()]; + const mockMembersUuids = mockMembers.map((m) => m.memberId); + + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValue(mockWorkspace); + jest + .spyOn(workspaceRepository, 'findWorkspaceUsers') + .mockResolvedValue(mockMembers); + jest.spyOn(workspaceRepository, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await service.accessLogs( + workspaceId, + pagination, + member, + logType, + lastDays, + summary, + order, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.findById).toHaveBeenCalledWith(workspaceId); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + summary, + mockMembersUuids, + logType, + pagination, + lastDays, + order, + ); + }); + it('when pagination is not provided, then should use default values', async () => { jest .spyOn(workspaceRepository, 'findById') @@ -6049,6 +6090,7 @@ describe('WorkspacesUsecases', () => { member, logType, lastDays, + summary, order, ); @@ -6057,7 +6099,7 @@ describe('WorkspacesUsecases', () => { expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( mockWorkspace.id, true, - member, + mockMembersUuids, logType, {}, lastDays, @@ -6077,14 +6119,15 @@ describe('WorkspacesUsecases', () => { member, logType, undefined, + summary, order, ); expect(result).toEqual(mockLogs); expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( mockWorkspace.id, - true, - member, + summary, + mockMembersUuids, logType, pagination, undefined, @@ -6104,13 +6147,14 @@ describe('WorkspacesUsecases', () => { member, logType, lastDays, + summary, ); expect(result).toEqual(mockLogs); expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( mockWorkspace.id, - true, - member, + summary, + mockMembersUuids, logType, pagination, lastDays, diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 1c52ab01..04984e89 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -74,6 +74,7 @@ import { PaymentsService } from '../../externals/payments/payments.service'; import { SharingAccessTokenData } from '../sharing/guards/sharings-token.interface'; import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { WorkspaceLog } from './domains/workspace-log.domain'; +import { TrashItem } from './interceptors/workspaces-logs.interceptor'; @Injectable() export class WorkspacesUsecases { @@ -769,12 +770,15 @@ export class WorkspacesUsecases { getItems: (offset: number) => Promise, deleteItems: (items: (File | Folder)[]) => Promise, ) => { + const allItems = []; const promises = []; for (let i = 0; i < itemCount; i += chunkSize) { const items = await getItems(i); + allItems.push(...items); promises.push(deleteItems(items)); } await Promise.all(promises); + return allItems; }; const [filesCount, foldersCount] = await Promise.all([ @@ -799,7 +803,7 @@ export class WorkspacesUsecases { const emptyTrashChunkSize = 100; - await emptyTrashItems( + const folders = await emptyTrashItems( foldersCount, emptyTrashChunkSize, (offset) => @@ -813,7 +817,7 @@ export class WorkspacesUsecases { this.folderUseCases.deleteByUser(workspaceUser, folders), ); - await emptyTrashItems( + const files = await emptyTrashItems( filesCount, emptyTrashChunkSize, (offset) => @@ -825,6 +829,23 @@ export class WorkspacesUsecases { ), (files: File[]) => this.fileUseCases.deleteByUser(workspaceUser, files), ); + + const items: TrashItem[] = [ + ...(Array.isArray(files) ? files : []) + .filter((file) => file.uuid != null) + .map((file) => ({ + type: WorkspaceItemType.File, + uuid: file.uuid, + })), + ...(Array.isArray(folders) ? folders : []) + .filter((folder) => folder.uuid != null) + .map((folder) => ({ + type: WorkspaceItemType.Folder, + uuid: folder.uuid, + })), + ]; + + return { items }; } async createFile( @@ -2890,18 +2911,28 @@ export class WorkspacesUsecases { member?: string, logType?: WorkspaceLog['type'][], lastDays?: number, + summary: boolean = true, order?: [string, string][], ) { + let membersUuids: string[]; const workspace = await this.workspaceRepository.findById(workspaceId); if (!workspace) { throw new NotFoundException('Workspace not found'); } + if (member) { + const workspaceUsers = await this.workspaceRepository.findWorkspaceUsers( + workspace.id, + member, + ); + membersUuids = workspaceUsers.map((user: WorkspaceUser) => user.memberId); + } + return this.workspaceRepository.accessLogs( workspace.id, - true, - member, + summary, + membersUuids, logType, pagination, lastDays, From 9b6d5f749ced7c5959481d4d6c35af3746f05475 Mon Sep 17 00:00:00 2001 From: Ederson Date: Wed, 18 Dec 2024 00:24:12 -0400 Subject: [PATCH 5/8] chore: requested change --- .../workspaces-logs.interceptor.spec.ts | 20 +++++++++---------- .../workspaces-logs.interceptor.ts | 12 +++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts index c8136b5a..c50affd2 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -137,7 +137,7 @@ describe('WorkspacesLogsInterceptor', () => { }); }); - describe('registerWorkspaceLog()', () => { + describe('logWorkspaceAction()', () => { const payload = { workspaceId: 'workspace-id', creator: 'user-id', @@ -147,7 +147,7 @@ describe('WorkspacesLogsInterceptor', () => { }; it('When registerLog is called, then it should call the repository method', async () => { - await interceptor.registerWorkspaceLog(payload); + await interceptor.logWorkspaceAction(payload); expect(workspaceRepository.registerLog).toHaveBeenCalledWith({ ...payload, @@ -161,7 +161,7 @@ describe('WorkspacesLogsInterceptor', () => { .spyOn(workspaceRepository, 'registerLog') .mockRejectedValue(new Error('Database error')); - await interceptor.registerWorkspaceLog(payload); + await interceptor.logWorkspaceAction(payload); expect(loggerDebugSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -179,10 +179,10 @@ describe('WorkspacesLogsInterceptor', () => { const workspaceIds = ['workspace-id-1', 'workspace-id-2']; jest - .spyOn(interceptor, 'getUserWorkspaces') + .spyOn(interceptor, 'fetchUserWorkspacesIds') .mockResolvedValue(workspaceIds); const registerLogSpy = jest - .spyOn(interceptor, 'registerWorkspaceLog') + .spyOn(interceptor, 'logWorkspaceAction') .mockImplementation(); await interceptor.handleUserAction( @@ -236,7 +236,7 @@ describe('WorkspacesLogsInterceptor', () => { workspaceId: 'workspace-id', }); const registerLogSpy = jest - .spyOn(interceptor, 'registerWorkspaceLog') + .spyOn(interceptor, 'logWorkspaceAction') .mockImplementation(); await interceptor.handleUserWorkspaceAction( @@ -423,7 +423,7 @@ describe('WorkspacesLogsInterceptor', () => { }); }); - describe('getUserWorkspaces()', () => { + describe('fetchUserWorkspacesIds()', () => { it('When user has workspaces, then it should return their IDs', async () => { const uuid = 'user-id'; @@ -441,7 +441,7 @@ describe('WorkspacesLogsInterceptor', () => { .spyOn(workspaceRepository, 'findUserAvailableWorkspaces') .mockResolvedValue(workspaces); - const result = await interceptor.getUserWorkspaces(uuid); + const result = await interceptor.fetchUserWorkspacesIds(uuid); expect(result).toEqual(['workspace-id-1', 'workspace-id-2']); }); @@ -461,7 +461,7 @@ describe('WorkspacesLogsInterceptor', () => { .spyOn(workspaceRepository, 'findUserAvailableWorkspaces') .mockResolvedValue(workspaces); - const result = await interceptor.getUserWorkspaces(uuid); + const result = await interceptor.fetchUserWorkspacesIds(uuid); expect(result).toEqual([]); }); }); @@ -617,7 +617,7 @@ describe('WorkspacesLogsInterceptor', () => { const req = { body: { items: [{ type: 'file', uuid: 'file-id' }] } }; const res = {}; const registerLogSpy = jest - .spyOn(interceptor, 'registerWorkspaceLog') + .spyOn(interceptor, 'logWorkspaceAction') .mockResolvedValue(undefined); const extractRequestDataSpy = jest .spyOn(interceptor, 'extractRequestData') diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts index c5670ce6..27daf0aa 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -161,7 +161,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { item.type as unknown as WorkspaceItemType, ); if (action) { - return this.registerWorkspaceLog({ + return this.logWorkspaceAction({ workspaceId: workspaceId, creator: requesterUuid, type: action, @@ -215,7 +215,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { return Reflect.getMetadata('workspaceLogAction', handler) || null; } - async registerWorkspaceLog( + async logWorkspaceAction( payload: Omit, ) { try { @@ -232,7 +232,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { } } - async getUserWorkspaces(uuid: string) { + async fetchUserWorkspacesIds(uuid: string) { const availableWorkspaces = await this.workspaceRepository.findUserAvailableWorkspaces(uuid); return availableWorkspaces @@ -256,10 +256,10 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { return; } - const workspaceIds = await this.getUserWorkspaces(user.uuid); + const workspaceIds = await this.fetchUserWorkspacesIds(user.uuid); await Promise.all( workspaceIds.map((workspaceId) => - this.registerWorkspaceLog({ + this.logWorkspaceAction({ workspaceId, creator: user.uuid, type: actionType, @@ -284,7 +284,7 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { } if (ok && requesterUuid && workspaceId && entityId) { - await this.registerWorkspaceLog({ + await this.logWorkspaceAction({ workspaceId, creator: requesterUuid, type: actionType, From ec427998373c4ed3418f9196696d101c1ace12f0 Mon Sep 17 00:00:00 2001 From: Ederson Date: Wed, 18 Dec 2024 16:39:35 -0400 Subject: [PATCH 6/8] feat: requested changes --- .../workspaces/dto/get-workspace-logs.ts | 3 ++ .../workspaces-logs.interceptor.spec.ts | 5 --- .../repositories/workspaces.repository.ts | 36 +++++++++---------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/modules/workspaces/dto/get-workspace-logs.ts b/src/modules/workspaces/dto/get-workspace-logs.ts index 0f12ea52..bcb20f79 100644 --- a/src/modules/workspaces/dto/get-workspace-logs.ts +++ b/src/modules/workspaces/dto/get-workspace-logs.ts @@ -8,6 +8,7 @@ import { IsOptional, IsString, Min, + Max, } from 'class-validator'; import { OrderBy } from './../../../common/order.type'; import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; @@ -31,6 +32,7 @@ export class GetWorkspaceLogsDto { @IsOptional() @IsInt() @Min(1) + @Max(25) @Type(() => Number) limit?: number; @@ -47,6 +49,7 @@ export class GetWorkspaceLogsDto { @IsOptional() @IsInt() @Min(0) + @Max(90) @Type(() => Number) lastDays?: number; diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts index c50affd2..e061d24d 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -36,11 +36,6 @@ describe('WorkspacesLogsInterceptor', () => { const platform = interceptor.determinePlatform('drive-desktop'); expect(platform).toBe(WorkspaceLogPlatform.Desktop); }); - - it('When client is unknown, then it should return UNSPECIFIED platform', () => { - const platform = interceptor.determinePlatform('unknown-client'); - expect(platform).toBeUndefined(); - }); }); describe('intercept()', () => { diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index 85bea0d0..5e87ed4f 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -617,35 +617,35 @@ export class SequelizeWorkspaceRepository { } workspaceLogToDomainSummary(model: WorkspaceLogModel): WorkspaceLog { - const buildUser = ({ + const buildUser = ({ id, name, lastname, email, uuid }: UserModel) => ({ id, name, lastname, email, uuid, - }: UserModel | null) => (id ? { id, name, lastname, email, uuid } : null); + }); - const buildWorkspace = ({ id, name }: WorkspaceModel | null) => - id ? { id, name } : null; + const buildWorkspace = ({ id, name }: WorkspaceModel) => ({ id, name }); - const buildFile = (file: FileModel | null) => { - if (!file) return null; - const { uuid, plainName, folderUuid, type } = file; - return { uuid, plainName, folderUuid, type }; - }; + const buildFile = ({ uuid, plainName, folderUuid, type }: FileModel) => ({ + uuid, + plainName, + folderUuid, + type, + }); - const buildFolder = (folder: FolderModel | null) => { - if (!folder) return null; - const { uuid, plainName, parentId } = folder; - return { uuid, plainName, parentId }; - }; + const buildFolder = ({ uuid, plainName, parentId }: FolderModel) => ({ + uuid, + plainName, + parentId, + }); return WorkspaceLog.build({ ...model.toJSON(), - user: buildUser(model.user), - workspace: buildWorkspace(model.workspace), - file: buildFile(model.file), - folder: buildFolder(model.folder), + user: model.user ? buildUser(model.user) : null, + workspace: model.workspace ? buildWorkspace(model.workspace) : null, + file: model.file ? buildFile(model.file) : null, + folder: model.folder ? buildFolder(model.folder) : null, }); } From 9dedc009b0e666a942186ecfb448ae61776b2b0a Mon Sep 17 00:00:00 2001 From: Ederson Date: Thu, 19 Dec 2024 11:04:35 -0400 Subject: [PATCH 7/8] feat: add WorkspaceLogAction interceptor to required authentication endpoints --- src/modules/auth/auth.controller.ts | 4 ++++ src/modules/user/user.controller.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ec9ed665..04d5070d 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -33,6 +33,8 @@ import { Client } from './decorators/client.decorator'; import { TwoFactorAuthService } from './two-factor-auth.service'; import { DeleteTfaDto } from './dto/delete-tfa.dto'; import { UpdateTfaDto } from './dto/update-tfa.dto'; +import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator'; +import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('Auth') @Controller('auth') @@ -89,6 +91,7 @@ export class AuthController { description: 'User successfully accessed their account', }) @Public() + @WorkspaceLogAction(WorkspaceLogType.Login) async loginAccess(@Body() body: LoginAccessDto) { return this.userUseCases.loginAccess(body); } @@ -100,6 +103,7 @@ export class AuthController { summary: 'Log out of the account', }) @ApiOkResponse({ description: 'Successfully logged out' }) + @WorkspaceLogAction(WorkspaceLogType.Logout) async logout(@UserDecorator() user: User) { return { logout: true }; } diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 81f02464..2f055f4b 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -69,6 +69,8 @@ import { HttpExceptionFilter } from '../../lib/http/http-exception.filter'; import { RequestAccountUnblock } from './dto/account-unblock.dto'; import { RegisterNotificationTokenDto } from './dto/register-notification-token.dto'; import { getFutureIAT } from '../../middlewares/passport'; +import { WorkspaceLogAction } from '../workspaces/decorators/workspace-log-action.decorator'; +import { WorkspaceLogType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('User') @Controller('users') @@ -422,6 +424,7 @@ export class UserController { @Patch('password') @ApiBearerAuth() + @WorkspaceLogAction(WorkspaceLogType.ChangedPassword) async updatePassword( @RequestDecorator() req, @Body() updatePasswordDto: UpdatePasswordDto, From c3c4fecbb845bc9211a4ee4d10b8fb081068df46 Mon Sep 17 00:00:00 2001 From: Ederson Date: Thu, 19 Dec 2024 13:48:45 -0400 Subject: [PATCH 8/8] feat: catch handleAction in workspace log interceptor --- .../workspaces-logs.interceptor.spec.ts | 31 +++++++++++++++++++ .../workspaces-logs.interceptor.ts | 8 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts index e061d24d..bd6bf368 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -88,6 +88,37 @@ describe('WorkspacesLogsInterceptor', () => { '[WORKSPACE/LOGS] Invalid log action: INVALID_ACTION', ); }); + + it('When handleAction fails then should log an error', async () => { + Reflect.defineMetadata( + 'workspaceLogAction', + WorkspaceLogType.Login, + mockHandler, + ); + + const next: CallHandler = { + handle: jest.fn().mockReturnValue(of({})), + }; + + const handleActionSpy = jest + .spyOn(interceptor, 'handleAction') + .mockRejectedValueOnce(new Error('Logging failed')); + + const logErrorSpy = jest.spyOn(Logger, 'error').mockImplementation(); + + await lastValueFrom(interceptor.intercept(context, next)); + + expect(handleActionSpy).toHaveBeenCalled(); + expect(logErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error logging action: Logging failed'), + ); + expect(logErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`Platform: ${WorkspaceLogPlatform.Web}`), + ); + expect(logErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(`Action: ${WorkspaceLogType.Login}`), + ); + }); }); describe('handleAction()', () => { diff --git a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts index 27daf0aa..e19eb624 100644 --- a/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -88,7 +88,13 @@ export class WorkspacesLogsInterceptor implements NestInterceptor { return next.handle().pipe( tap((res) => { - this.handleAction(platform, this.logAction, request, res); + this.handleAction(platform, this.logAction, request, res).catch((err) => + Logger.error( + `[WORKSPACE/LOGS] Error logging action: ${ + err.message || err + }. Platform: ${platform}, Action: ${this.logAction}.`, + ), + ); }), ); }