diff --git a/.env.template b/.env.template index e7d5b71dc..9e74cd5b1 100644 --- a/.env.template +++ b/.env.template @@ -42,4 +42,7 @@ STRIPE_SK_TEST=sk_test_y GATEWAY_USER=user GATEWAY_PASS=gatewaypass -PAYMENTS_API_URL=http://host.docker.internal:8003 \ No newline at end of file +PAYMENTS_API_URL=http://host.docker.internal:8003 + +WORKSPACES_USER_INVITATION_EMAIL_ID=d-de1ed6df4a9947129c0bf592c808b58d +WORKSPACES_GUEST_USER_INVITATION_EMAIL_ID=d-41b4608fc94a41bca65aab7ed6ccad15 \ No newline at end of file diff --git a/migrations/20240319130531-create-workspace-invites-table.js b/migrations/20240319130531-create-workspace-invites-table.js new file mode 100644 index 000000000..06ef1d018 --- /dev/null +++ b/migrations/20240319130531-create-workspace-invites-table.js @@ -0,0 +1,53 @@ +'use strict'; + +const tableName = 'workspaces_invites'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable(tableName, { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + allowNull: false, + primaryKey: true, + }, + workspace_id: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'workspaces', + key: 'id', + }, + }, + invited_user: { + type: Sequelize.UUID, + allowNull: false, + }, + encryption_algorithm: { + type: Sequelize.STRING, + allowNull: false, + }, + encryption_key: { + type: Sequelize.STRING(800), + allowNull: false, + }, + space_limit: { + type: Sequelize.BIGINT.UNSIGNED, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + }, + down: async (queryInterface) => { + await queryInterface.dropTable(tableName); + }, +}; diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 07e9dfb33..aa30889e5 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -99,6 +99,10 @@ export default () => ({ process.env.SENDGRID_TEMPLATE_DRIVE_UPDATE_USER_EMAIL || '', unblockAccountEmail: process.env.SENDGRID_TEMPLATE_DRIVE_UNBLOCK_ACCOUNT || '', + invitationToWorkspaceUser: + process.env.WORKSPACES_USER_INVITATION_EMAIL_ID || '', + invitationToWorkspaceGuestUser: + process.env.WORKSPACES_GUEST_USER_INVITATION_EMAIL_ID || '', }, }, newsletter: { diff --git a/src/externals/mailer/mailer.service.ts b/src/externals/mailer/mailer.service.ts index 38d672b7f..1c607a079 100644 --- a/src/externals/mailer/mailer.service.ts +++ b/src/externals/mailer/mailer.service.ts @@ -4,6 +4,7 @@ import sendgrid from '@sendgrid/mail'; import { User } from '../../modules/user/user.domain'; import { Folder } from '../../modules/folder/folder.domain'; import { File } from '../../modules/file/file.domain'; +import { Workspace } from '../../modules/workspaces/domains/workspaces.domain'; type SendInvitationToSharingContext = { notification_message: string; @@ -116,6 +117,62 @@ export class MailerService { ); } + async sendWorkspaceUserInvitation( + senderName: User['name'], + invitedUserEmail: User['email'], + workspaceName: Workspace['name'], + mailInfo: { + acceptUrl: string; + declineUrl: string; + }, + avatar?: { + pictureUrl: string; + initials: string; + }, + ): Promise { + const context = { + sender_name: senderName, + workspace_name: workspaceName, + avatar: { + picture_url: avatar.pictureUrl, + initials: avatar.initials, + }, + signup_url: mailInfo.acceptUrl, + decline_url: mailInfo.declineUrl, + }; + await this.send( + invitedUserEmail, + this.configService.get('mailer.templates.invitationToWorkspaceUser'), + context, + ); + } + + async sendWorkspaceUserExternalInvitation( + senderName: User['name'], + invitedUserEmail: User['email'], + workspaceName: Workspace['name'], + signUpUrl: string, + avatar?: { + pictureUrl: string; + initials: string; + }, + ): Promise { + const context = { + sender_name: senderName, + workspace_name: workspaceName, + avatar: { + picture_url: avatar.pictureUrl, + initials: avatar.initials, + }, + signup_url: signUpUrl, + }; + await this.send( + invitedUserEmail, + this.configService.get('mailer.templates.invitationToWorkspaceGuestUser'), + context, + ); + } + async sendRemovedFromSharingEmail( userRemovedFromSharingEmail: User['email'], itemName: File['plainName'] | Folder['plainName'], diff --git a/src/modules/workspaces/attributes/workspace-invite.attribute.ts b/src/modules/workspaces/attributes/workspace-invite.attribute.ts new file mode 100644 index 000000000..a517d6a46 --- /dev/null +++ b/src/modules/workspaces/attributes/workspace-invite.attribute.ts @@ -0,0 +1,10 @@ +export interface WorkspaceInviteAttributes { + id: string; + workspaceId: string; + invitedUser: string; + encryptionAlgorithm: string; + encryptionKey: string; + spaceLimit: bigint; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/workspaces/domains/workspace-invite.domain.ts b/src/modules/workspaces/domains/workspace-invite.domain.ts new file mode 100644 index 000000000..ff7e4da6e --- /dev/null +++ b/src/modules/workspaces/domains/workspace-invite.domain.ts @@ -0,0 +1,49 @@ +import { WorkspaceInviteAttributes } from '../attributes/workspace-invite.attribute'; + +export class WorkspaceInvite implements WorkspaceInviteAttributes { + id: string; + workspaceId: string; + invitedUser: string; + encryptionAlgorithm: string; + encryptionKey: string; + spaceLimit: bigint; + createdAt: Date; + updatedAt: Date; + + constructor({ + id, + workspaceId, + invitedUser, + encryptionAlgorithm, + encryptionKey, + spaceLimit, + createdAt, + updatedAt, + }: WorkspaceInviteAttributes) { + this.id = id; + this.workspaceId = workspaceId; + this.invitedUser = invitedUser; + this.encryptionAlgorithm = encryptionAlgorithm; + this.encryptionKey = encryptionKey; + this.spaceLimit = spaceLimit; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + static build(attributes: WorkspaceInviteAttributes): WorkspaceInvite { + return new WorkspaceInvite(attributes); + } + + toJSON() { + return { + id: this.id, + workspaceId: this.workspaceId, + invitedUser: this.invitedUser, + encryptionAlgorithm: this.encryptionAlgorithm, + encryptionKey: this.encryptionKey, + spaceLimit: this.spaceLimit.toString(), + createdAt: this.createdAt, + updatedAt: this.updatedAt, + }; + } +} diff --git a/src/modules/workspaces/dto/create-workspace-invite.dto.ts b/src/modules/workspaces/dto/create-workspace-invite.dto.ts new file mode 100644 index 000000000..1df00487d --- /dev/null +++ b/src/modules/workspaces/dto/create-workspace-invite.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsPositive } from 'class-validator'; +import { User } from '../../user/user.domain'; +import { WorkspaceInvite } from '../domains/workspace-invite.domain'; + +export class CreateWorkspaceInviteDto { + @ApiProperty({ + example: 'invited_user@internxt.com', + description: 'The email of the user you want to invite', + }) + @IsNotEmpty() + invitedUser: User['email']; + + @ApiProperty({ + example: '1073741824', + description: 'Space assigned to user in bytes', + }) + @IsNotEmpty() + @IsPositive() + spaceLimit: WorkspaceInvite['spaceLimit']; + + @ApiProperty({ + example: 'encrypted encryption key', + description: + "Owner's encryption key encrypted with the invited user's public key.", + }) + @IsNotEmpty() + encryptionKey: WorkspaceInvite['encryptionKey']; + + @ApiProperty({ + example: 'aes-256-gcm', + description: 'Encryption algorithm used to encrypt the encryption key.', + }) + @IsNotEmpty() + encryptionAlgorithm: WorkspaceInvite['encryptionAlgorithm']; +} diff --git a/src/modules/workspaces/models/workspace-invite.model.ts b/src/modules/workspaces/models/workspace-invite.model.ts new file mode 100644 index 000000000..b5d4d4885 --- /dev/null +++ b/src/modules/workspaces/models/workspace-invite.model.ts @@ -0,0 +1,54 @@ +import { + Model, + Table, + Column, + DataType, + PrimaryKey, + ForeignKey, + BelongsTo, +} from 'sequelize-typescript'; +import { WorkspaceModel } from './workspace.model'; +import { WorkspaceInviteAttributes } from '../attributes/workspace-invite.attribute'; + +@Table({ + underscored: true, + timestamps: true, + tableName: 'workspaces_invites', +}) +export class WorkspaceInviteModel + extends Model + implements WorkspaceInviteAttributes +{ + @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; + + @Column({ type: DataType.UUID, allowNull: false }) + invitedUser: string; + + @Column({ type: DataType.STRING, allowNull: false }) + encryptionAlgorithm: string; + + @Column({ type: DataType.STRING, allowNull: false }) + encryptionKey: string; + + @Column({ type: DataType.BIGINT.UNSIGNED, allowNull: false }) + spaceLimit: bigint; + + @Column({ allowNull: false, defaultValue: DataType.NOW }) + createdAt: Date; + + @Column({ allowNull: false, defaultValue: DataType.NOW }) + updatedAt: Date; +} diff --git a/src/modules/workspaces/repositories/workspaces.repository.spec.ts b/src/modules/workspaces/repositories/workspaces.repository.spec.ts new file mode 100644 index 000000000..13c6a2c34 --- /dev/null +++ b/src/modules/workspaces/repositories/workspaces.repository.spec.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getModelToken } from '@nestjs/sequelize'; +import { SequelizeWorkspaceRepository } from './workspaces.repository'; +import { WorkspaceModel } from '../models/workspace.model'; +import { WorkspaceUserModel } from '../models/workspace-users.model'; +import { WorkspaceInviteModel } from '../models/workspace-invite.model'; +import { createMock } from '@golevelup/ts-jest'; +import { WorkspaceInvite } from '../domains/workspace-invite.domain'; +import { WorkspaceUser } from '../domains/workspace-user.domain'; +import { + newWorkspaceInvite, + newWorkspaceUser, +} from '../../../../test/fixtures'; +import { Workspace } from '../domains/workspaces.domain'; + +describe('SequelizeWorkspaceRepository', () => { + let repository: SequelizeWorkspaceRepository; + let workspaceModel: typeof WorkspaceModel; + let workspaceUserModel: typeof WorkspaceUserModel; + let workspaceInviteModel: typeof WorkspaceInviteModel; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SequelizeWorkspaceRepository], + }) + .useMocker(() => createMock()) + .compile(); + + repository = module.get( + SequelizeWorkspaceRepository, + ); + workspaceModel = module.get( + getModelToken(WorkspaceModel), + ); + workspaceUserModel = module.get( + getModelToken(WorkspaceUserModel), + ); + workspaceInviteModel = module.get( + getModelToken(WorkspaceInviteModel), + ); + }); + + describe('findInvite', () => { + it('When a workspace invitation is searched and it is found, it should return the respective invitation', async () => { + const mockInvite = newWorkspaceInvite(); + + jest + .spyOn(workspaceInviteModel, 'findOne') + .mockResolvedValueOnce(mockInvite as WorkspaceInviteModel); + + const result = await repository.findInvite({ id: '1' }); + expect(result).toBeInstanceOf(WorkspaceInvite); + expect(result.id).toEqual(mockInvite.id); + }); + + it('When a workspace invitation is searched and it is not found, it should return null', async () => { + jest.spyOn(workspaceInviteModel, 'findOne').mockResolvedValueOnce(null); + + const result = await repository.findInvite({ id: '1' }); + expect(result).toBeNull(); + }); + }); + + describe('getWorkspaceInvitationsCount', () => { + it('When a workspace invitations number is searched, then it should return the respective number', async () => { + jest.spyOn(workspaceInviteModel, 'count').mockResolvedValueOnce(5); + const count = await repository.getWorkspaceInvitationsCount('1'); + expect(count).toEqual(5); + }); + }); + + describe('findWorkspaceUser', () => { + it('When a workspace user is searched and found, it should return the respective user', async () => { + const workspaceUser = newWorkspaceUser(); + const mockWorkspaceUser = { + memberId: workspaceUser.id, + workspaceId: workspaceUser.workspaceId, + ...workspaceUser.toJSON(), + }; + + jest.spyOn(workspaceUserModel, 'findOne').mockResolvedValueOnce({ + ...mockWorkspaceUser, + toJSON: jest.fn().mockReturnValue(mockWorkspaceUser), + } as any); + + const result = await repository.findWorkspaceUser({ memberId: '1' }); + expect(result).toBeInstanceOf(WorkspaceUser); + expect(result).toEqual( + expect.objectContaining({ + ...workspaceUser.toJSON(), + }), + ); + }); + + it('When a workspace user is searched and not found, it should return nothing', async () => { + jest.spyOn(workspaceUserModel, 'findOne').mockResolvedValueOnce(null); + const result = await repository.findWorkspaceUser({ memberId: '1' }); + expect(result).toBeNull(); + }); + }); + + describe('getSpaceLimitInInvitations', () => { + it('When the result is null, it should return 0', async () => { + jest.spyOn(workspaceInviteModel, 'sum').mockResolvedValueOnce(null); + + const totalSpace = await repository.getSpaceLimitInInvitations('1'); + expect(totalSpace).toStrictEqual(BigInt(0)); + }); + }); + + describe('getTotalSpaceLimitInWorkspaceUsers', () => { + it('When the total is calculated, the respective space should be returned', async () => { + jest.spyOn(workspaceUserModel, 'sum').mockResolvedValueOnce(10); + + const total = await repository.getTotalSpaceLimitInWorkspaceUsers('1'); + expect(total).toStrictEqual(BigInt(10)); + }); + }); + + describe('findWorkspaceAndUser', () => { + it('When workspace and user in workspace are found, it should return both', async () => { + const userUuid = 'user-uuid'; + const workspaceId = 'workspace-id'; + const mockWorkspaceUser = newWorkspaceUser({ + attributes: { memberId: userUuid, workspaceId }, + }); + const mockWorkspace = { + id: workspaceId, + toJSON: jest.fn().mockReturnValue({ + id: workspaceId, + }), + workspaceUsers: [mockWorkspaceUser], + }; + + jest + .spyOn(workspaceModel, 'findOne') + .mockResolvedValueOnce(mockWorkspace as any); + + const result = await repository.findWorkspaceAndUser( + userUuid, + workspaceId, + ); + + expect(result).toEqual({ + workspace: expect.any(Workspace), + workspaceUser: expect.any(WorkspaceUser), + }); + + expect(result.workspace.id).toEqual(workspaceId); + expect(result.workspaceUser.id).toEqual(mockWorkspaceUser.id); + }); + + it('When workspace is not found, it should return null for both values', async () => { + jest.spyOn(workspaceModel, 'findOne').mockResolvedValueOnce(null); + + const result = await repository.findWorkspaceAndUser( + 'user-uuid', + 'workspace-id', + ); + + expect(result).toEqual({ + workspace: null, + workspaceUser: null, + }); + }); + + it('When workspace is found but no user is found, it should return null user', async () => { + const workspaceId = 'workspace-id'; + const mockWorkspace = { + id: workspaceId, + toJSON: jest.fn().mockReturnValue({ + id: workspaceId, + }), + workspaceUsers: [], + }; + + jest + .spyOn(workspaceModel, 'findOne') + .mockResolvedValueOnce(mockWorkspace as any); + + const result = await repository.findWorkspaceAndUser( + 'user-uuid', + 'workspace-id', + ); + + expect(result).toEqual({ + workspace: expect.any(Workspace), + workspaceUser: null, + }); + }); + }); +}); diff --git a/src/modules/workspaces/repositories/workspaces.repository.ts b/src/modules/workspaces/repositories/workspaces.repository.ts index 5c4def2e5..f89b5c6e8 100644 --- a/src/modules/workspaces/repositories/workspaces.repository.ts +++ b/src/modules/workspaces/repositories/workspaces.repository.ts @@ -6,6 +6,10 @@ import { Workspace } from '../domains/workspaces.domain'; import { WorkspaceModel } from '../models/workspace.model'; import { WorkspaceUserModel } from '../models/workspace-users.model'; import { WorkspaceUser } from '../domains/workspace-user.domain'; +import { WorkspaceInviteModel } from '../models/workspace-invite.model'; +import { WorkspaceInvite } from '../domains/workspace-invite.domain'; +import { WorkspaceInviteAttributes } from '../attributes/workspace-invite.attribute'; +import { WorkspaceUserAttributes } from '../attributes/workspace-users.attributes'; export interface WorkspaceRepository { findById(id: WorkspaceAttributes['id']): Promise; @@ -20,6 +24,9 @@ export interface WorkspaceRepository { offset?: number, ): Promise; findOne(where: Partial): Promise; + findInvite( + where: Partial, + ): Promise; updateById( id: WorkspaceAttributes['id'], update: Partial, @@ -39,6 +46,8 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { private modelWorkspace: typeof WorkspaceModel, @InjectModel(WorkspaceUserModel) private modelWorkspaceUser: typeof WorkspaceUserModel, + @InjectModel(WorkspaceInviteModel) + private modelWorkspaceInvite: typeof WorkspaceInviteModel, ) {} async findById(id: WorkspaceAttributes['id']): Promise { const workspace = await this.modelWorkspace.findByPk(id); @@ -69,6 +78,76 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { return workspace ? this.toDomain(workspace) : null; } + async findInvite( + where: Partial, + ): Promise { + const invite = await this.modelWorkspaceInvite.findOne({ where }); + + return invite ? WorkspaceInvite.build(invite) : null; + } + + async getWorkspaceInvitationsCount( + workspaceId: WorkspaceAttributes['id'], + ): Promise { + const totalInvites = await this.modelWorkspaceInvite.count({ + where: { workspaceId: workspaceId }, + }); + + return totalInvites; + } + + async findWorkspaceUser( + where: Partial, + ): Promise { + const workspaceUser = await this.modelWorkspaceUser.findOne({ + where, + }); + + return workspaceUser ? this.workspaceUserToDomain(workspaceUser) : null; + } + + async deleteWorkspaceInviteById( + inviteId: WorkspaceInviteAttributes['id'], + ): Promise { + await this.modelWorkspaceInvite.destroy({ where: { id: inviteId } }); + } + + async getWorkspaceUsersCount( + workspaceId: WorkspaceAttributes['id'], + ): Promise { + const totalUsers = await this.modelWorkspaceUser.count({ + where: { workspaceId: workspaceId }, + }); + + return totalUsers; + } + async getSpaceLimitInInvitations( + workspaceId: WorkspaceAttributes['id'], + ): Promise { + const totalSpaceLimit = await this.modelWorkspaceInvite.sum('spaceLimit', { + where: { workspaceId: workspaceId }, + }); + + return BigInt(totalSpaceLimit || 0); + } + + async getTotalSpaceLimitInWorkspaceUsers( + workspaceId: WorkspaceAttributes['id'], + ): Promise { + const total = await this.modelWorkspaceUser.sum('spaceLimit', { + where: { workspaceId }, + }); + return BigInt(total); + } + + async createInvite( + invite: Omit, + ): Promise { + const raw = await this.modelWorkspaceInvite.create(invite); + + return raw ? WorkspaceInvite.build(raw) : null; + } + async create(workspace: any): Promise { const dbWorkspace = await this.modelWorkspace.create(workspace); return this.toDomain(dbWorkspace); @@ -148,4 +227,3 @@ export class SequelizeWorkspaceRepository implements WorkspaceRepository { return domain?.toJSON(); } } -export { WorkspaceModel }; diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index 1bec64a29..7793a6dee 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -22,6 +22,7 @@ import { WorkspaceRequiredAccess, WorkspaceRole, } from './guards/workspace-required-access.decorator'; +import { CreateWorkspaceInviteDto } from './dto/create-workspace-invite.dto'; @ApiTags('Workspaces') @Controller('workspaces') @@ -37,6 +38,21 @@ export class WorkspacesController { throw new NotImplementedException(); } + @Post('/:workspaceId/members/invite') + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) + async inviteUsersToWorkspace( + @Param('workspaceId') workspaceId: WorkspaceAttributes['id'], + @Body() createInviteDto: CreateWorkspaceInviteDto, + @UserDecorator() user: User, + ) { + return this.workspaceUseCases.inviteUserToWorkspace( + user, + workspaceId, + createInviteDto, + ); + } + @Post('/:workspaceId/teams') @ApiOperation({ summary: 'Creates a team in a workspace', diff --git a/src/modules/workspaces/workspaces.module.ts b/src/modules/workspaces/workspaces.module.ts index 0c4de72ab..02fec1513 100644 --- a/src/modules/workspaces/workspaces.module.ts +++ b/src/modules/workspaces/workspaces.module.ts @@ -12,6 +12,8 @@ import { BridgeModule } from '../../externals/bridge/bridge.module'; import { WorkspaceTeamModel } from './models/workspace-team.model'; import { WorkspaceTeamUserModel } from './models/workspace-team-users.model'; import { WorkspaceGuard } from './guards/workspaces.guard'; +import { WorkspaceInviteModel } from './models/workspace-invite.model'; +import { MailerModule } from '../../externals/mailer/mailer.module'; @Module({ imports: [ @@ -21,9 +23,11 @@ import { WorkspaceGuard } from './guards/workspaces.guard'; WorkspaceTeamModel, WorkspaceTeamUserModel, WorkspaceUserModel, + WorkspaceInviteModel, ]), UserModule, BridgeModule, + MailerModule, ], controllers: [WorkspacesController], providers: [ diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts new file mode 100644 index 000000000..5341ee04d --- /dev/null +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -0,0 +1,333 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SequelizeWorkspaceRepository } from './repositories/workspaces.repository'; +import { SequelizeUserRepository } from '../user/user.repository'; +import { UserUseCases } from '../user/user.usecase'; +import { MailerService } from '../../externals/mailer/mailer.service'; +import { ConfigService } from '@nestjs/config'; +import { createMock } from '@golevelup/ts-jest'; +import { WorkspacesUsecases } from './workspaces.usecase'; +import { + newUser, + newWorkspace, + newWorkspaceInvite, + newWorkspaceUser, +} from '../../../test/fixtures'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { PreCreatedUser } from '../user/pre-created-user.domain'; +import { BridgeService } from '../../externals/bridge/bridge.service'; + +jest.mock('../../middlewares/passport', () => { + const originalModule = jest.requireActual('../../middlewares/passport'); + return { + __esModule: true, + ...originalModule, + Sign: jest.fn(() => 'newToken'), + SignEmail: jest.fn(() => 'token'), + }; +}); + +describe('WorkspacesUsecases', () => { + let service: WorkspacesUsecases; + let workspaceRepository: SequelizeWorkspaceRepository; + let userRepository: SequelizeUserRepository; + let userUsecases: UserUseCases; + let mailerService: MailerService; + let networkService: BridgeService; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkspacesUsecases], + }) + .useMocker(createMock) + .compile(); + + service = module.get(WorkspacesUsecases); + workspaceRepository = module.get( + SequelizeWorkspaceRepository, + ); + userRepository = module.get( + SequelizeUserRepository, + ); + userUsecases = module.get(UserUseCases); + mailerService = module.get(MailerService); + networkService = module.get(BridgeService); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('inviteUserToWorkspace', () => { + const user = newUser(); + + it('When workspace does not exist, then it should throw', async () => { + jest.spyOn(workspaceRepository, 'findById').mockResolvedValueOnce(null); + + await expect( + service.inviteUserToWorkspace(user, 'workspace-id', { + invitedUser: 'test@example.com', + spaceLimit: BigInt(1024), + encryptionKey: 'Dasdsadas', + encryptionAlgorithm: 'dadads', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('When user is not registered or precreated, then it should throw', async () => { + jest.spyOn(workspaceRepository, 'findById').mockResolvedValueOnce(null); + jest.spyOn(userUsecases, 'findByEmail').mockResolvedValueOnce(null); + jest + .spyOn(userUsecases, 'findPreCreatedByEmail') + .mockResolvedValueOnce(null); + + await expect( + service.inviteUserToWorkspace(user, 'workspace-id', { + invitedUser: 'test@example.com', + spaceLimit: BigInt(1024), + encryptionKey: 'Dasdsadas', + encryptionAlgorithm: 'dadads', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('When user is precreated, then it should be successfully invited', async () => { + const workspace = newWorkspace(); + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValueOnce(workspace); + jest.spyOn(userUsecases, 'findByEmail').mockResolvedValueOnce(null); + jest.spyOn(userUsecases, 'findPreCreatedByEmail').mockResolvedValueOnce({ + uuid: user.uuid, + email: user.email, + } as PreCreatedUser); + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(null); + jest.spyOn(workspaceRepository, 'findInvite').mockResolvedValueOnce(null); + jest.spyOn(service, 'isWorkspaceFull').mockResolvedValueOnce(false); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValueOnce(user); + jest + .spyOn(service, 'getAssignableSpaceInWorkspace') + .mockResolvedValueOnce(BigInt(6000000)); + jest.spyOn(configService, 'get').mockResolvedValueOnce('secret' as never); + jest + .spyOn(mailerService, 'sendWorkspaceUserExternalInvitation') + .mockResolvedValueOnce(undefined); + + await expect( + service.inviteUserToWorkspace(user, 'workspace-id', { + invitedUser: 'test@example.com', + spaceLimit: BigInt(1024), + encryptionKey: '', + encryptionAlgorithm: '', + }), + ).resolves.not.toThrow(); + expect( + mailerService.sendWorkspaceUserExternalInvitation, + ).toHaveBeenCalled(); + }); + + it('When user is already registered, then it should be successfully invited', async () => { + const workspace = newWorkspace(); + const invitedUser = newUser(); + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValueOnce(workspace); + jest + .spyOn(userUsecases, 'findByEmail') + .mockResolvedValueOnce(invitedUser); + jest + .spyOn(userUsecases, 'findPreCreatedByEmail') + .mockResolvedValueOnce(null); + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(null); + jest.spyOn(workspaceRepository, 'findInvite').mockResolvedValueOnce(null); + jest.spyOn(service, 'isWorkspaceFull').mockResolvedValueOnce(false); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValueOnce(user); + jest + .spyOn(service, 'getAssignableSpaceInWorkspace') + .mockResolvedValueOnce(BigInt(6000000)); + jest + .spyOn(mailerService, 'sendWorkspaceUserInvitation') + .mockResolvedValueOnce(undefined); + jest.spyOn(configService, 'get').mockResolvedValue('secret' as never); + + await expect( + service.inviteUserToWorkspace(user, 'workspace-id', { + invitedUser: 'test@example.com', + spaceLimit: BigInt(1024), + encryptionKey: '', + encryptionAlgorithm: '', + }), + ).resolves.not.toThrow(); + expect(mailerService.sendWorkspaceUserInvitation).toHaveBeenCalled(); + }); + + it('When workspace has no more slots left, then it should throw', async () => { + const workspace = newWorkspace(); + const user = newUser(); + + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValueOnce(workspace); + jest.spyOn(userUsecases, 'findByEmail').mockResolvedValueOnce(user); + jest + .spyOn(userUsecases, 'findPreCreatedByEmail') + .mockResolvedValueOnce(null); + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(null); + jest.spyOn(workspaceRepository, 'findInvite').mockResolvedValueOnce(null); + jest.spyOn(service, 'isWorkspaceFull').mockResolvedValueOnce(true); + + await expect( + service.inviteUserToWorkspace(user, workspace.id, { + invitedUser: 'test@example.com', + spaceLimit: BigInt(1024), + encryptionKey: 'encryptionKey', + encryptionAlgorithm: 'RSA', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('When user is already part of the workspace, then it should throw', async () => { + const user = newUser(); + const workspace = newWorkspace(); + const invitedUser = newUser(); + + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValueOnce(workspace); + jest + .spyOn(userUsecases, 'findByEmail') + .mockResolvedValueOnce(invitedUser); + jest + .spyOn(workspaceRepository, 'findWorkspaceUser') + .mockResolvedValueOnce(newWorkspaceUser()); + + await expect( + service.inviteUserToWorkspace(user, workspace.id, { + invitedUser: invitedUser.email, + spaceLimit: BigInt(1024), + encryptionKey: 'encryptionKey', + encryptionAlgorithm: 'RSA', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('When user is already invited to the workspace, then it should throw', async () => { + const invitedUser = newUser(); + const workspace = newWorkspace(); + const invitedUserEmail = 'alreadyInvited@example.com'; + const existingInvite = newWorkspaceInvite({ + invitedUser: invitedUserEmail, + }); + + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValueOnce(workspace); + jest + .spyOn(userUsecases, 'findByEmail') + .mockResolvedValueOnce(invitedUser); + jest + .spyOn(userUsecases, 'findPreCreatedByEmail') + .mockResolvedValueOnce(null); + jest + .spyOn(workspaceRepository, 'findInvite') + .mockResolvedValueOnce(existingInvite); + + await expect( + service.inviteUserToWorkspace(user, workspace.id, { + invitedUser: invitedUserEmail, + spaceLimit: BigInt(1024), + encryptionKey: 'encryptionKey', + encryptionAlgorithm: 'RSA', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('When invitation space limit exceeds the assignable space, then it should throw', async () => { + const invitedUser = newUser(); + const workspace = newWorkspace(); + const invitedUserEmail = 'newUser@example.com'; + const spaceLeft = 1024 * 1024 * 10; + + jest + .spyOn(workspaceRepository, 'findById') + .mockResolvedValueOnce(workspace); + jest + .spyOn(userUsecases, 'findByEmail') + .mockResolvedValueOnce(invitedUser); + jest + .spyOn(userUsecases, 'findPreCreatedByEmail') + .mockResolvedValueOnce(null); + jest.spyOn(service, 'isWorkspaceFull').mockResolvedValueOnce(false); + jest + .spyOn(service, 'getAssignableSpaceInWorkspace') + .mockResolvedValueOnce(BigInt(spaceLeft)); + jest.spyOn(workspaceRepository, 'findInvite').mockResolvedValueOnce(null); + + await expect( + service.inviteUserToWorkspace(user, workspace.id, { + invitedUser: invitedUserEmail, + spaceLimit: BigInt(spaceLeft + 1), + encryptionKey: 'encryptionKey', + encryptionAlgorithm: 'RSA', + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('isWorkspaceFull', () => { + const workspaceId = 'workspace-id'; + it('When workspace has slots left, then workspace is not full', async () => { + jest + .spyOn(workspaceRepository, 'getWorkspaceUsersCount') + .mockResolvedValue(5); + jest + .spyOn(workspaceRepository, 'getWorkspaceInvitationsCount') + .mockResolvedValue(4); + + const isFull = await service.isWorkspaceFull(workspaceId); + expect(isFull).toBe(false); + }); + it('When workspace does not have slots left, then workspace is full', async () => { + jest + .spyOn(workspaceRepository, 'getWorkspaceUsersCount') + .mockResolvedValue(10); + jest + .spyOn(workspaceRepository, 'getWorkspaceInvitationsCount') + .mockResolvedValue(0); + + const isFull = await service.isWorkspaceFull(workspaceId); + expect(isFull).toBe(true); + }); + }); + + describe('getAssignableSpaceInWorkspace', () => { + const workspace = newWorkspace(); + const workspaceDefaultUser = newUser(); + it('When there is space left, then it should return the correct space left', async () => { + jest.spyOn(networkService, 'getLimit').mockResolvedValue(1000000); + jest + .spyOn(workspaceRepository, 'getTotalSpaceLimitInWorkspaceUsers') + .mockResolvedValue(BigInt(500000)); + jest + .spyOn(workspaceRepository, 'getSpaceLimitInInvitations') + .mockResolvedValue(BigInt(200000)); + + const assignableSpace = await service.getAssignableSpaceInWorkspace( + workspace, + workspaceDefaultUser, + ); + expect(assignableSpace).toBe(BigInt(300000)); + }); + }); +}); diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 5c6571abc..6eb654a66 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { User } from '../user/user.domain'; import { CreateTeamDto } from './dto/create-team.dto'; import { WorkspaceAttributes } from './attributes/workspace.attributes'; @@ -13,6 +18,12 @@ import { UserAttributes } from '../user/user.attributes'; import { WorkspaceTeam } from './domains/workspace-team.domain'; import { WorkspaceUser } from './domains/workspace-user.domain'; import { WorkspaceTeamUser } from './domains/workspace-team-user.domain'; +import { UserUseCases } from '../user/user.usecase'; +import { WorkspaceInvite } from './domains/workspace-invite.domain'; +import { CreateWorkspaceInviteDto } from './dto/create-workspace-invite.dto'; +import { MailerService } from '../../externals/mailer/mailer.service'; +import { ConfigService } from '@nestjs/config'; +import { Sign } from '../../middlewares/passport'; @Injectable() export class WorkspacesUsecases { @@ -21,6 +32,9 @@ export class WorkspacesUsecases { private readonly workspaceRepository: SequelizeWorkspaceRepository, private networkService: BridgeService, private userRepository: SequelizeUserRepository, + private userUsecases: UserUseCases, + private configService: ConfigService, + private mailerService: MailerService, ) {} async initiateWorkspace( @@ -88,6 +102,174 @@ export class WorkspacesUsecases { //return this.teamRepository.createTeam(newTeam); } + async inviteUserToWorkspace( + user: User, + workspaceId: Workspace['id'], + createInviteDto: CreateWorkspaceInviteDto, + ) { + const workspace = await this.workspaceRepository.findById(workspaceId); + + if (!workspace) { + throw new NotFoundException('Workspace does not exist'); + } + + const [existentUser, preCreatedUser] = await Promise.all([ + this.userUsecases.findByEmail(createInviteDto.invitedUser), + this.userUsecases.findPreCreatedByEmail(createInviteDto.invitedUser), + ]); + + const userJoining = existentUser ?? preCreatedUser; + + if (!userJoining) { + throw new NotFoundException('Invited user not found'); + } + + const isUserPreCreated = !existentUser; + + if (!isUserPreCreated) { + const isUserAlreadyInWorkspace = + await this.workspaceRepository.findWorkspaceUser({ + workspaceId, + memberId: userJoining.uuid, + }); + if (isUserAlreadyInWorkspace) { + throw new BadRequestException('User is already part of the workspace'); + } + } + + const invitation = await this.workspaceRepository.findInvite({ + invitedUser: userJoining.uuid, + workspaceId, + }); + if (invitation) { + throw new BadRequestException('User is already invited to workspace'); + } + + const isWorkspaceFull = await this.isWorkspaceFull(workspaceId); + + if (isWorkspaceFull) { + throw new BadRequestException( + 'You can not invite more users to this workspace', + ); + } + + const workspaceUser = await this.userRepository.findByUuid( + workspace.workspaceUserId, + ); + + const spaceLeft = await this.getAssignableSpaceInWorkspace( + workspace, + workspaceUser, + ); + + if (createInviteDto.spaceLimit > spaceLeft) { + throw new BadRequestException( + 'Space limit set for the invitation is superior to the space assignable in workspace', + ); + } + + const newInvite = WorkspaceInvite.build({ + id: v4(), + workspaceId: workspaceId, + invitedUser: userJoining.uuid, + encryptionAlgorithm: createInviteDto.encryptionAlgorithm, + encryptionKey: createInviteDto.encryptionKey, + spaceLimit: BigInt(createInviteDto.spaceLimit), + createdAt: new Date(), + updatedAt: new Date(), + }); + + await this.workspaceRepository.createInvite(newInvite); + const inviterName = `${user.name} ${user.lastname}`; + + if (isUserPreCreated) { + const encodedUserEmail = encodeURIComponent(userJoining.email); + try { + await this.mailerService.sendWorkspaceUserExternalInvitation( + inviterName, + userJoining.email, + workspace.name, + `${this.configService.get( + 'clients.drive.web', + )}/workspace-guest?invitation=${ + newInvite.id + }&email=${encodedUserEmail}`, + { initials: user.name[0] + user.lastname[0], pictureUrl: null }, + ); + } catch (error) { + Logger.error( + `[WORKSPACE/GUESTUSEREMAIL] Error sending email pre created userId: ${userJoining.uuid}, error: ${error.message}`, + ); + await this.workspaceRepository.deleteWorkspaceInviteById(newInvite.id); + throw error; + } + } else { + try { + const authToken = Sign( + this.userUsecases.getNewTokenPayload(userJoining), + this.configService.get('secrets.jwt'), + ); + await this.mailerService.sendWorkspaceUserInvitation( + inviterName, + userJoining.email, + workspace.name, + { + acceptUrl: `${this.configService.get( + 'clients.drive.web', + )}/workspaces/${newInvite.id}/accept?token=${authToken}`, + declineUrl: `${this.configService.get( + 'clients.drive.web', + )}/workspaces/${newInvite.id}/decline?token=${authToken}`, + }, + { initials: user.name[0] + user.lastname[0], pictureUrl: null }, + ); + } catch (error) { + Logger.error( + `[WORKSPACE/USEREMAIL] Error sending email invitation to existent user userId: ${userJoining.uuid}, error: ${error.message}`, + ); + } + } + + return newInvite.toJSON(); + } + + async getAssignableSpaceInWorkspace( + workspace: Workspace, + workpaceDefaultUser: User, + ): Promise { + const [ + spaceLimit, + totalSpaceLimitAssigned, + totalSpaceAssignedInInvitations, + ] = await Promise.all([ + this.networkService.getLimit( + workpaceDefaultUser.bridgeUser, + workpaceDefaultUser.userId, + ), + this.workspaceRepository.getTotalSpaceLimitInWorkspaceUsers(workspace.id), + this.workspaceRepository.getSpaceLimitInInvitations(workspace.id), + ]); + + const spaceLeft = + BigInt(spaceLimit) - + totalSpaceLimitAssigned - + totalSpaceAssignedInInvitations; + + return spaceLeft; + } + + async isWorkspaceFull(workspaceId: Workspace['id']): Promise { + const [workspaceUsersCount, workspaceInvitationsCount] = await Promise.all([ + this.workspaceRepository.getWorkspaceUsersCount(workspaceId), + this.workspaceRepository.getWorkspaceInvitationsCount(workspaceId), + ]); + + const limit = 10; // Temporal limit + const count = workspaceUsersCount + workspaceInvitationsCount; + + return count >= limit; + } + async createTeam( user: User, workspaceId: WorkspaceAttributes['id'], diff --git a/test/fixtures.spec.ts b/test/fixtures.spec.ts index 49a203ea7..3d1c0242b 100644 --- a/test/fixtures.spec.ts +++ b/test/fixtures.spec.ts @@ -389,4 +389,41 @@ describe('Testing fixtures tests', () => { expect(user.spaceLimit).toBe(customAttributes.spaceLimit); }); }); + + describe("WorkspaceInvite's fixture", () => { + it('When it generates a workspace invite, then the identifier should be random', () => { + const invite = fixtures.newWorkspaceInvite(); + const otherInvite = fixtures.newWorkspaceInvite(); + expect(invite.id).toBeTruthy(); + expect(invite.id).not.toBe(otherInvite.id); + }); + + it('When it generates a workspace invite, then the workspaceId should be random', () => { + const invite = fixtures.newWorkspaceInvite(); + const otherInvite = fixtures.newWorkspaceInvite(); + expect(invite.workspaceId).toBeTruthy(); + expect(invite.workspaceId).not.toBe(otherInvite.workspaceId); + }); + + it('When it generates a workspace invite with a specified invitedUser, then the invitedUser should match', () => { + const invitedUser = 'test@example.com'; + const invite = fixtures.newWorkspaceInvite({ invitedUser }); + expect(invite.invitedUser).toBe(invitedUser); + }); + + it('When it generates a workspace invite with custom attributes, then those attributes are set correctly', () => { + const customAttributes = { + encryptionAlgorithm: 'AES-256', + spaceLimit: BigInt(2048), + }; + const invite = fixtures.newWorkspaceInvite({ + attributes: customAttributes, + }); + + expect(invite.encryptionAlgorithm).toBe( + customAttributes.encryptionAlgorithm, + ); + expect(invite.spaceLimit).toBe(customAttributes.spaceLimit); + }); + }); }); diff --git a/test/fixtures.ts b/test/fixtures.ts index af33d106c..d9a3bd3cc 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -19,6 +19,8 @@ import { Limit } from '../src/modules/feature-limit/limit.domain'; import { Workspace } from '../src/modules/workspaces/domains/workspaces.domain'; import { WorkspaceTeam } from '../src/modules/workspaces/domains/workspace-team.domain'; import { WorkspaceUser } from '../src/modules/workspaces/domains/workspace-user.domain'; +import { WorkspaceInvite } from '../src/modules/workspaces/domains/workspace-invite.domain'; +import { WorkspaceInviteAttributes } from '../src/modules/workspaces/attributes/workspace-invite.attribute'; export const constants = { BUCKET_ID_LENGTH: 24, @@ -341,3 +343,31 @@ export const newWorkspaceUser = (params?: { return workspaceUser; }; + +export const newWorkspaceInvite = (params?: { + workspaceId?: string; + invitedUser?: string; + attributes?: Partial; +}): WorkspaceInvite => { + const defaultCreatedAt = new Date(randomDataGenerator.date({ year: 2024 })); + + const workspaceInvite = WorkspaceInvite.build({ + id: v4(), + workspaceId: params?.workspaceId || v4(), + invitedUser: params?.invitedUser || randomDataGenerator.email(), + encryptionAlgorithm: 'AES-256', + encryptionKey: randomDataGenerator.string({ length: 32 }), + spaceLimit: BigInt( + randomDataGenerator.natural({ min: 1024, max: 1048576 }), + ), + createdAt: defaultCreatedAt, + updatedAt: new Date(randomDataGenerator.date({ min: defaultCreatedAt })), + }); + + params?.attributes && + Object.keys(params.attributes).forEach((key) => { + workspaceInvite[key] = params.attributes[key]; + }); + + return workspaceInvite; +};