diff --git a/migrations/20241113173746-create-workspace-logs-table.js b/migrations/20241113173746-create-workspace-logs-table.js new file mode 100644 index 00000000..2e43f639 --- /dev/null +++ b/migrations/20241113173746-create-workspace-logs-table.js @@ -0,0 +1,96 @@ +'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.ENUM( + 'login', + 'changed-password', + 'logout', + 'share-file', + 'share-folder', + 'delete-file', + 'delete-folder', + ), + allowNull: false, + }, + platform: { + type: Sequelize.ENUM( + 'web', + 'mobile', + 'desktop', + ), + 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: ['platform'], + 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/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/sharing/sharing.controller.ts b/src/modules/sharing/sharing.controller.ts index 6bdf93bd..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 { @@ -64,6 +63,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 { WorkspaceLogGlobalActionType } from '../workspaces/attributes/workspace-logs.attributes'; @ApiTags('Sharing') @Controller('sharings') @@ -619,6 +620,7 @@ export class SharingController { { sourceKey: 'body', fieldName: 'itemType' }, ]) @WorkspacesInBehalfGuard() + @WorkspaceLogAction(WorkspaceLogGlobalActionType.Share) createSharing( @UserDecorator() user, @Body() acceptInviteDto: CreateSharingDto, diff --git a/src/modules/trash/trash.controller.ts b/src/modules/trash/trash.controller.ts index 63688f3f..26aea014 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 { WorkspaceLogGlobalActionType } 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(WorkspaceLogGlobalActionType.Delete) async deleteItems( @Body() deleteItemsDto: DeleteItemsDto, @UserDecorator() user: User, 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, 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..8a7cca86 --- /dev/null +++ b/src/modules/workspaces/attributes/workspace-logs.attributes.ts @@ -0,0 +1,36 @@ +export enum WorkspaceLogType { + 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', +} + +export interface 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; +} 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..f1dc9eb6 --- /dev/null +++ b/src/modules/workspaces/decorators/workspace-log-action.decorator.ts @@ -0,0 +1,14 @@ +import { applyDecorators, SetMetadata, UseInterceptors } from '@nestjs/common'; +import { + WorkspaceLogGlobalActionType, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; +import { WorkspacesLogsInterceptor } from './../interceptors/workspaces-logs.interceptor'; + +export const WorkspaceLogAction = ( + action: WorkspaceLogType | WorkspaceLogGlobalActionType, +) => + 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..bcb20f79 --- /dev/null +++ b/src/modules/workspaces/dto/get-workspace-logs.ts @@ -0,0 +1,60 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Min, + Max, +} from 'class-validator'; +import { OrderBy } from './../../../common/order.type'; +import { WorkspaceLogType } from '../attributes/workspace-logs.attributes'; +import { Transform, 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) + @Max(25) + @Type(() => Number) + limit?: number; + + @IsOptional() + @IsInt() + @Min(0) + @Type(() => Number) + offset?: number; + + @IsOptional() + @IsString() + member?: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(90) + @Type(() => Number) + lastDays?: number; + + @IsOptional() + @Transform(({ value }) => value === 'true') + @IsBoolean() + summary?: boolean = true; +} 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..bd6bf368 --- /dev/null +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.spec.ts @@ -0,0 +1,725 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { WorkspacesLogsInterceptor } from './workspaces-logs.interceptor'; +import { SequelizeWorkspaceRepository } from '../repositories/workspaces.repository'; +import { + WorkspaceLogType, + WorkspaceLogPlatform, + WorkspaceLogGlobalActionType, +} from '../attributes/workspace-logs.attributes'; +import { CallHandler, ExecutionContext, Logger } from '@nestjs/common'; +import { lastValueFrom, 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); + }); + }); + + 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 () => { + Reflect.defineMetadata( + 'workspaceLogAction', + WorkspaceLogType.Login, + mockHandler, + ); + + const next: CallHandler = { + handle: jest.fn().mockReturnValue(of({})), + }; + + const handleActionSpy = jest + .spyOn(interceptor, 'handleAction') + .mockResolvedValue(undefined); + + await lastValueFrom(interceptor.intercept(context, next)); + + expect(handleActionSpy).toHaveBeenCalled(); + }); + + it('When log action is invalid, then it should log an invalid action message', async () => { + Reflect.defineMetadata( + 'workspaceLogAction', + 'INVALID_ACTION', + mockHandler, + ); + + 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', + ); + }); + + 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()', () => { + 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('logWorkspaceAction()', () => { + 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.logWorkspaceAction(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.logWorkspaceAction(payload); + + expect(loggerDebugSpy).toHaveBeenCalledWith( + expect.stringContaining( + `An error occurred trying to register a log of type ${payload.type} 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, 'fetchUserWorkspacesIds') + .mockResolvedValue(workspaceIds); + const registerLogSpy = jest + .spyOn(interceptor, 'logWorkspaceAction') + .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, 'logWorkspaceAction') + .mockImplementation(); + + await interceptor.handleUserWorkspaceAction( + platform, + WorkspaceLogType.ShareFile, + req, + res, + ); + + expect(registerLogSpy).toHaveBeenCalledWith({ + workspaceId: 'workspace-id', + creator: 'user-id', + type: WorkspaceLogType.ShareFile, + 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.ShareFile, + 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('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.ShareFile); + }); + + 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.ShareFolder); + }); + + 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.DeleteFile); + }); + + 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.DeleteFolder); + }); + + it('When type is invalid, then it should log a debug message', () => { + const action = interceptor.getActionForGlobalLogType( + '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.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Share, + 'INVALID_ITEM_TYPE' as any, + ); + expect(action).toBeNull(); + expect(loggerDebugSpy).toHaveBeenCalledWith( + `[WORKSPACE/LOGS] Invalid action type: ${WorkspaceLogGlobalActionType.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('fetchUserWorkspacesIds()', () => { + 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.fetchUserWorkspacesIds(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.fetchUserWorkspacesIds(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 ChangedPassword 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.ChangedPassword, + 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 getActionForGlobalLogType = jest + .spyOn(interceptor, 'getActionForGlobalLogType') + .mockReturnValue(WorkspaceLogType.ShareFile); + const handleUserWorkspaceActionSpy = jest + .spyOn(interceptor, 'handleUserWorkspaceAction') + .mockImplementation(); + + await interceptor.share(platform, req, res); + + expect(getActionForGlobalLogType).toHaveBeenCalledWith( + WorkspaceLogGlobalActionType.Share, + 'file', + ); + expect(handleUserWorkspaceActionSpy).toHaveBeenCalledWith( + platform, + WorkspaceLogType.ShareFile, + 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 ShareFile 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.ShareFile, + req, + res, + ); + }); + }); + + describe('shareFolder()', () => { + it('When called, then it should call handleUser WorkspaceAction with ShareFolder 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.ShareFolder, + 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, 'logWorkspaceAction') + .mockResolvedValue(undefined); + const extractRequestDataSpy = jest + .spyOn(interceptor, 'extractRequestData') + .mockReturnValue({ + ok: true, + requesterUuid: 'requester-uuid', + workspaceId: 'workspace-id', + }); + jest + .spyOn(interceptor, 'getActionForGlobalLogType') + .mockReturnValue(WorkspaceLogType.DeleteFile); + + await interceptor.delete(platform, req, res); + + expect(extractRequestDataSpy).toHaveBeenCalledWith(req); + expect(registerLogSpy).toHaveBeenCalledWith({ + workspaceId: 'workspace-id', + creator: 'requester-uuid', + 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 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 DeleteFile 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.DeleteFile, + req, + res, + ); + }); + }); + + describe('deleteFolder()', () => { + it('When called, then it should call handleUser WorkspaceAction with DeleteFolder 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.DeleteFolder, + 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..e19eb624 --- /dev/null +++ b/src/modules/workspaces/interceptors/workspaces-logs.interceptor.ts @@ -0,0 +1,359 @@ +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, + 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 | 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 | WorkspaceLogGlobalActionType; + + constructor( + private readonly workspaceRepository: SequelizeWorkspaceRepository, + ) { + this.actionHandler = { + [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 | undefined { + const platforms = { + 'drive-web': WorkspaceLogPlatform.Web, + 'drive-mobile': WorkspaceLogPlatform.Mobile, + 'drive-desktop': WorkspaceLogPlatform.Desktop, + }; + return platforms[client]; + } + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + this.logAction = this.getWorkspaceLogAction(context); + + 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( + tap((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}.`, + ), + ); + }), + ); + } + + 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.ChangedPassword, + 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.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.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.ShareFile, + req, + res, + ); + } + + async shareFolder(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserWorkspaceAction( + platform, + WorkspaceLogType.ShareFolder, + req, + res, + ); + } + + async delete(platform: WorkspaceLogPlatform, req: any, res: any) { + const items: DeleteItem[] | TrashItem[] = req?.body?.items || res?.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: DeleteItem | TrashItem) => { + const action = this.getActionForGlobalLogType( + WorkspaceLogGlobalActionType.Delete, + item.type as unknown as WorkspaceItemType, + ); + if (action) { + return this.logWorkspaceAction({ + 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.DeleteFile, + req, + res, + ); + } + + async deleteFolder(platform: WorkspaceLogPlatform, req: any, res: any) { + await this.handleUserWorkspaceAction( + platform, + WorkspaceLogType.DeleteFolder, + req, + res, + ); + } + + async handleAction( + platform: WorkspaceLogPlatform, + action: WorkspaceLogType | WorkspaceLogGlobalActionType, + 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 | WorkspaceLogGlobalActionType { + const handler = context.getHandler(); + return Reflect.getMetadata('workspaceLogAction', handler) || null; + } + + async logWorkspaceAction( + 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 fetchUserWorkspacesIds(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?.uuid) { + Logger.debug('[WORKSPACE/LOGS] User is required'); + return; + } + + const workspaceIds = await this.fetchUserWorkspacesIds(user.uuid); + await Promise.all( + workspaceIds.map((workspaceId) => + this.logWorkspaceAction({ + 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.logWorkspaceAction({ + 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; + } + + getActionForGlobalLogType( + type: WorkspaceLogGlobalActionType, + itemType: WorkspaceItemType, + ): WorkspaceLogType { + const actionMap = { + [WorkspaceLogGlobalActionType.Share]: { + [WorkspaceItemType.File]: WorkspaceLogType.ShareFile, + [WorkspaceItemType.Folder]: WorkspaceLogType.ShareFolder, + }, + [WorkspaceLogGlobalActionType.Delete]: { + [WorkspaceItemType.File]: WorkspaceLogType.DeleteFile, + [WorkspaceItemType.Folder]: WorkspaceLogType.DeleteFolder, + }, + }; + + 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/models/workspace-logs.model.ts b/src/modules/workspaces/models/workspace-logs.model.ts new file mode 100644 index 00000000..dcedb963 --- /dev/null +++ b/src/modules/workspaces/models/workspace-logs.model.ts @@ -0,0 +1,79 @@ +import { FolderModel } from './../../folder/folder.model'; +import { FileModel } from './../../file/file.model'; +import { + Model, + Table, + Column, + DataType, + PrimaryKey, + ForeignKey, + BelongsTo, +} from 'sequelize-typescript'; +import { UserModel } from '../../user/user.model'; +import { WorkspaceLogAttributes } from '../attributes/workspace-logs.attributes'; +import { WorkspaceModel } from './workspace.model'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'workspace_logs', +}) +export class WorkspaceLogModel extends Model { + @PrimaryKey + @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; + + @BelongsTo(() => UserModel, { + foreignKey: 'creator', + targetKey: 'uuid', + as: 'user', + }) + user: UserModel; + + @Column({ + type: DataType.STRING, + allowNull: false, + }) + type: WorkspaceLogAttributes['type']; + + @Column(DataType.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; + + @Column + updatedAt: Date; +} diff --git a/src/modules/workspaces/repositories/workspaces.repository.spec.ts b/src/modules/workspaces/repositories/workspaces.repository.spec.ts index 5a0a5cc1..9e61d3bb 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,281 @@ 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 mockMembersUuids: string[] = undefined; + const logType: WorkspaceLog['type'][] = [ + WorkspaceLogType.Login, + WorkspaceLogType.Logout, + ]; + const lastDays = 7; + const order: [string, string][] = [['updatedAt', '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, + updatedAt: { [Op.gte]: dateLimit }, + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + mockMembersUuids, + 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 mockMembers = [newWorkspaceUser(), newWorkspaceUser()]; + const mockMembersUuids = mockMembers.map((m) => m.memberId); + const whereConditions = { + workspaceId, + creator: { [Op.in]: mockMembersUuids }, + }; + + jest + .spyOn(workspaceLogModel, 'findAll') + .mockResolvedValue(mockLogs as WorkspaceLogModel[]); + + await repository.accessLogs( + workspaceId, + true, + mockMembersUuids, + 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, + mockMembersUuids, + 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, + mockMembersUuids, + 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, + mockMembersUuids, + 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, + mockMembersUuids, + logType, + pagination, + lastDays, + ); + + expect(workspaceLogModel.findAll).toHaveBeenCalledWith({ + where: expect.objectContaining(whereConditions), + include: expect.any(Array), + ...pagination, + order: [['updatedAt', '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.ShareFile, + 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.ShareFolder, + 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 f673eff2..5e87ed4f 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,9 +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, + WorkspaceLogType, +} from '../attributes/workspace-logs.attributes'; +import { WorkspaceLogModel } from '../models/workspace-logs.model'; +import { WorkspaceLog } from '../domains/workspace-log.domain'; @Injectable() export class SequelizeWorkspaceRepository { @@ -35,6 +41,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 +477,114 @@ export class SequelizeWorkspaceRepository { }); } + async registerLog(log: Omit): Promise { + await this.modelWorkspaceLog.create(log); + } + + async accessLogs( + workspaceId: Workspace['id'], + summary: boolean = false, + membersUuids?: WorkspaceLog['creator'][], + logType?: WorkspaceLog['type'][], + pagination?: { + limit?: number; + offset?: number; + }, + lastDays?: number, + order: [string, string][] = [['updatedAt', 'DESC']], + ) { + const dateLimit = new Date(); + if (lastDays) { + dateLimit.setDate(dateLimit.getDate() - lastDays); + dateLimit.setMilliseconds(0); + } + + const whereConditions: any = { + workspaceId, + ...(lastDays && dateLimit ? { updatedAt: { [Op.gte]: dateLimit } } : {}), + }; + + if (membersUuids) { + whereConditions.creator = { [Op.in]: membersUuids }; + } + + 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]: [ + WorkspaceLogType.ShareFile, + WorkspaceLogType.DeleteFile, + ], + }), + 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]: [ + WorkspaceLogType.ShareFolder, + WorkspaceLogType.DeleteFolder, + ], + }), + 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(), @@ -488,6 +604,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) => ({ + id, + name, + lastname, + email, + uuid, + }); + + const buildWorkspace = ({ id, name }: WorkspaceModel) => ({ id, name }); + + const buildFile = ({ uuid, plainName, folderUuid, type }: FileModel) => ({ + uuid, + plainName, + folderUuid, + type, + }); + + const buildFolder = ({ uuid, plainName, parentId }: FolderModel) => ({ + uuid, + plainName, + parentId, + }); + + return WorkspaceLog.build({ + ...model.toJSON(), + 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, + }); + } + 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..560a11eb 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,120 @@ describe('Workspace Controller', () => { ); }); }); + + describe('GET /:workspaceId/access/logs', () => { + 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, + 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, + summary, + }; + + 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, + summary, + undefined, + ); + }); + + it('when invalid workspaceId is provided, then should throw', async () => { + const invalidWorkspaceId = v4(); + const workspaceLogDto: GetWorkspaceLogsDto = { + limit: 10, + offset: 0, + summary, + }; + + 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', + summary: false, + }; + + 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, + false, + [['createdAt', 'DESC']], + ); + }); + }); }); diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index 36b82a3d..a13390f7 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -72,6 +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 { WorkspaceLogGlobalActionType } from './attributes/workspace-logs.attributes'; +import { WorkspaceLogAction } from './decorators/workspace-log-action.decorator'; +import { GetWorkspaceLogsDto } from './dto/get-workspace-logs'; @ApiTags('Workspaces') @Controller('workspaces') @@ -649,6 +652,7 @@ export class WorkspacesController { }) @UseGuards(WorkspaceGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) + @WorkspaceLogAction(WorkspaceLogGlobalActionType.Share) async shareItemWithMember( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -884,6 +888,7 @@ export class WorkspacesController { }) @UseGuards(WorkspaceGuard) @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.MEMBER) + @WorkspaceLogAction(WorkspaceLogGlobalActionType.DeleteAll) async emptyTrash( @Param('workspaceId', ValidateUUIDPipe) workspaceId: WorkspaceAttributes['id'], @@ -1157,4 +1162,46 @@ 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, + summary, + orderBy, + } = workspaceLogDto; + + const order = orderBy + ? [orderBy.split(':') as [string, string]] + : undefined; + + return this.workspaceUseCases.accessLogs( + workspaceId, + { limit, offset }, + member, + logType, + lastDays, + summary, + order, + ); + } } diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 683f936d..765119e9 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -24,6 +24,7 @@ import { CryptoModule } from '../../externals/crypto/crypto.module'; import { FuzzySearchUseCases } from '../fuzzy-search/fuzzy-search.usecase'; import { FuzzySearchModule } from '../fuzzy-search/fuzzy-search.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; +import { WorkspaceLogModel } from './models/workspace-logs.model'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { NotificationModule } from '../../externals/notifications/notifications. WorkspaceTeamUserModel, WorkspaceUserModel, WorkspaceInviteModel, + WorkspaceLogModel, ]), forwardRef(() => UserModule), forwardRef(() => FolderModule), @@ -57,6 +59,6 @@ import { NotificationModule } from '../../externals/notifications/notifications. 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 b31e31c8..35622355 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,227 @@ 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 = undefined; + const mockMembersUuids: string[] = undefined; + const logType: WorkspaceLog['type'][] = [ + 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, + 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, + 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 workspace does not exist, then should throw NotFoundException', async () => { + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(null); + + await expect( + service.accessLogs( + workspaceId, + pagination, + member, + logType, + lastDays, + summary, + order, + ), + ).rejects.toThrow(NotFoundException); + await expect( + service.accessLogs( + workspaceId, + pagination, + 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') + .mockResolvedValue(mockWorkspace); + jest.spyOn(workspaceRepository, 'accessLogs').mockResolvedValue(mockLogs); + + const result = await service.accessLogs( + workspaceId, + {}, + member, + logType, + lastDays, + summary, + order, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.findById).toHaveBeenCalledWith(workspaceId); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + true, + mockMembersUuids, + 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, + summary, + order, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + summary, + mockMembersUuids, + 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, + summary, + ); + + expect(result).toEqual(mockLogs); + expect(workspaceRepository.accessLogs).toHaveBeenCalledWith( + mockWorkspace.id, + summary, + mockMembersUuids, + logType, + pagination, + lastDays, + undefined, + ); + }); + }); }); diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 32d79746..04984e89 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -73,6 +73,8 @@ 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'; +import { TrashItem } from './interceptors/workspaces-logs.interceptor'; @Injectable() export class WorkspacesUsecases { @@ -768,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([ @@ -798,7 +803,7 @@ export class WorkspacesUsecases { const emptyTrashChunkSize = 100; - await emptyTrashItems( + const folders = await emptyTrashItems( foldersCount, emptyTrashChunkSize, (offset) => @@ -812,7 +817,7 @@ export class WorkspacesUsecases { this.folderUseCases.deleteByUser(workspaceUser, folders), ); - await emptyTrashItems( + const files = await emptyTrashItems( filesCount, emptyTrashChunkSize, (offset) => @@ -824,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( @@ -2879,4 +2901,42 @@ export class WorkspacesUsecases { return searchResults; } + + async accessLogs( + workspaceId: Workspace['id'], + pagination: { + limit?: number; + offset?: number; + }, + 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, + summary, + membersUuids, + logType, + pagination, + lastDays, + order, + ); + } }