diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index d77e995db..6c75e78ae 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -8,6 +8,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module'; import { UserEnvironmentsModule } from './user-environment/user-environments.module'; import { UserHistoryModule } from './user-history/user-history.module'; import { subscriptionContextCookieParser } from './auth/helper'; +import { TeamModule } from './team/team.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { subscriptionContextCookieParser } from './auth/helper'; UserSettingsModule, UserEnvironmentsModule, UserHistoryModule, + TeamModule, ], providers: [GQLComplexityPlugin], }) diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index cee338602..18f41bda1 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -2,6 +2,7 @@ import { User } from 'src/user/user.model'; import { UserSettings } from 'src/user-settings/user-settings.model'; import { UserEnvironment } from '../user-environment/user-environments.model'; import { UserHistory } from '../user-history/user-history.model'; +import { TeamMember } from 'src/team/team.model'; // A custom message type that defines the topic and the corresponding payload. // For every module that publishes a subscription add its type def and the possible subscription type. @@ -15,5 +16,8 @@ export type TopicDef = { [ topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}` ]: UserHistory; + [topic: `team/${string}/member_removed`]: string; + [topic: `team/${string}/member_added`]: TeamMember; + [topic: `team/${string}/member_updated`]: TeamMember; [topic: `user_history/${string}/deleted_many`]: number; }; diff --git a/packages/hoppscotch-backend/src/team/decorators/requires-team-role.decorator.ts b/packages/hoppscotch-backend/src/team/decorators/requires-team-role.decorator.ts new file mode 100644 index 000000000..9eaf9f406 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/decorators/requires-team-role.decorator.ts @@ -0,0 +1,5 @@ +import { TeamMemberRole } from '@prisma/client'; +import { SetMetadata } from '@nestjs/common'; + +export const RequiresTeamRole = (...roles: TeamMemberRole[]) => + SetMetadata('requiresTeamRole', roles); diff --git a/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts b/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts new file mode 100644 index 000000000..b3036e02b --- /dev/null +++ b/packages/hoppscotch-backend/src/team/guards/gql-team-member.guard.ts @@ -0,0 +1,47 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TeamService } from '../team.service'; +import { TeamMemberRole } from '../team.model'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { User } from '../../user/user.model'; +import { + TEAM_NOT_REQUIRED_ROLE, + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_NO_REQUIRE_TEAM_ROLE, + BUG_TEAM_NO_TEAM_ID, +} from 'src/errors'; + +@Injectable() +export class GqlTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + + if (!requireRoles) throw new Error(BUG_TEAM_NO_REQUIRE_TEAM_ROLE); + + const gqlExecCtx = GqlExecutionContext.create(context); + + const { user } = gqlExecCtx.getContext().req; + + if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); + + const { teamID } = gqlExecCtx.getArgs<{ teamID: string }>(); + + if (!teamID) throw new Error(BUG_TEAM_NO_TEAM_ID); + + const status = await this.teamService.getTeamMember(teamID, user.uid); + + if (!status) throw new Error('team/member_not_found'); + + if (requireRoles.includes(status.role)) return true; + + throw new Error(TEAM_NOT_REQUIRED_ROLE); + } +} diff --git a/packages/hoppscotch-backend/src/team/team-member.resolver.ts b/packages/hoppscotch-backend/src/team/team-member.resolver.ts new file mode 100644 index 000000000..c8b9b2207 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team-member.resolver.ts @@ -0,0 +1,24 @@ +import { Resolver, ResolveField, Parent } from '@nestjs/graphql'; +import { TeamMember } from './team.model'; +import { UserService } from 'src/user/user.service'; +import { User } from '../user/user.model'; +import { throwErr } from 'src/utils'; +import { USER_NOT_FOUND } from 'src/errors'; +import * as O from 'fp-ts/Option'; + +@Resolver(() => TeamMember) +export class TeamMemberResolver { + constructor(private readonly userService: UserService) {} + + @ResolveField(() => User) + async user(@Parent() teamMember: TeamMember): Promise { + const member = await this.userService.findUserById(teamMember.userUid); + if (O.isNone(member)) throwErr(USER_NOT_FOUND); + + return { + ...member.value, + currentRESTSession: JSON.stringify(member.value.currentRESTSession), + currentGQLSession: JSON.stringify(member.value.currentGQLSession), + }; + } +} diff --git a/packages/hoppscotch-backend/src/team/team.model.ts b/packages/hoppscotch-backend/src/team/team.model.ts new file mode 100644 index 000000000..f047da40e --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.model.ts @@ -0,0 +1,39 @@ +import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql'; + +@ObjectType() +export class Team { + @Field(() => ID, { + description: 'ID of the team', + }) + id: string; + + @Field(() => String, { + description: 'Displayed name of the team', + }) + name: string; +} + +@ObjectType() +export class TeamMember { + @Field(() => ID, { + description: 'Membership ID of the Team Member' + }) + membershipID: string; + + userUid: string; + + @Field(() => TeamMemberRole, { + description: 'Role of the given team member in the given team', + }) + role: TeamMemberRole; +} + +export enum TeamMemberRole { + OWNER = 'OWNER', + VIEWER = 'VIEWER', + EDITOR = 'EDITOR', +} + +registerEnumType(TeamMemberRole, { + name: 'TeamMemberRole', +}); diff --git a/packages/hoppscotch-backend/src/team/team.module.ts b/packages/hoppscotch-backend/src/team/team.module.ts new file mode 100644 index 000000000..01df035f9 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TeamService } from './team.service'; +import { TeamResolver } from './team.resolver'; +import { UserModule } from '../user/user.module'; +import { TeamMemberResolver } from './team-member.resolver'; +import { GqlTeamMemberGuard } from './guards/gql-team-member.guard'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PubSubModule } from '../pubsub/pubsub.module'; + +@Module({ + imports: [UserModule ,PubSubModule , PrismaModule ], + providers: [ + TeamService, + TeamResolver, + TeamMemberResolver, + GqlTeamMemberGuard, + ], + exports: [TeamService, GqlTeamMemberGuard], +}) +export class TeamModule {} diff --git a/packages/hoppscotch-backend/src/team/team.resolver.ts b/packages/hoppscotch-backend/src/team/team.resolver.ts new file mode 100644 index 000000000..8fad4b339 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.resolver.ts @@ -0,0 +1,364 @@ +import { Team, TeamMember, TeamMemberRole } from './team.model'; +import { + Resolver, + ResolveField, + Args, + Parent, + Query, + Mutation, + Int, + Subscription, + ID, +} from '@nestjs/graphql'; +import { TeamService } from './team.service'; +import { User } from '../user/user.model'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { UseGuards } from '@nestjs/common'; +import { RequiresTeamRole } from './decorators/requires-team-role.decorator'; +import { GqlTeamMemberGuard } from './guards/gql-team-member.guard'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { AuthUser } from 'src/types/AuthUser'; + +@Resolver(() => Team) +export class TeamResolver { + constructor( + private readonly teamService: TeamService, + private readonly pubsub: PubSubService, + ) {} + + // Field Resolvers + // TODO: Deprecate this + @ResolveField(() => [TeamMember], { + description: 'Returns the list of members of a team', + complexity: 10, + }) + members( + @Parent() team: Team, + @Args({ + name: 'cursor', + type: () => ID, + description: + 'The ID of the last returned team member entry (used for pagination)', + nullable: true, + }) + cursor?: string, + ): Promise { + return this.teamService.getMembersOfTeam(team.id, cursor ?? null); + } + + @ResolveField(() => [TeamMember], { + description: 'Returns the list of members of a team', + complexity: 10, + }) + teamMembers(@Parent() team: Team): Promise { + return this.teamService.getTeamMembers(team.id); + } + + @ResolveField(() => TeamMemberRole, { + description: 'The role of the current user in the team', + }) + @UseGuards(GqlAuthGuard) + myRole( + @Parent() team: Team, + @GqlUser() user: AuthUser, + ): Promise { + return this.teamService.getRoleOfUserInTeam(team.id, user.uid); + } + + @ResolveField(() => Int, { + description: 'The number of users with the OWNER role in the team', + }) + ownersCount(@Parent() team: Team): Promise { + return this.teamService.getCountOfUsersWithRoleInTeam( + team.id, + TeamMemberRole.OWNER, + ); + } + + @ResolveField(() => Int, { + description: 'The number of users with the EDITOR role in the team', + }) + editorsCount(@Parent() team: Team): Promise { + return this.teamService.getCountOfUsersWithRoleInTeam( + team.id, + TeamMemberRole.EDITOR, + ); + } + + @ResolveField(() => Int, { + description: 'The number of users with the VIEWER role in the team', + }) + viewersCount(@Parent() team: Team): Promise { + return this.teamService.getCountOfUsersWithRoleInTeam( + team.id, + TeamMemberRole.VIEWER, + ); + } + + // Query + @Query(() => [Team], { + description: 'List of teams that the executing user belongs to.', + }) + @UseGuards(GqlAuthGuard) + myTeams( + @GqlUser() user: AuthUser, + @Args({ + name: 'cursor', + type: () => ID, + description: + 'The ID of the last returned team entry (used for pagination)', + nullable: true, + }) + cursor?: string, + ): Promise { + return this.teamService.getTeamsOfUser(user.uid, cursor ?? null); + } + + @Query(() => Team, { + description: 'Returns the detail of the team with the given ID', + nullable: true, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.VIEWER, + TeamMemberRole.EDITOR, + TeamMemberRole.OWNER, + ) + team( + @Args({ + name: 'teamID', + type: () => ID, + description: 'ID of the team to check', + }) + teamID: string, + ): Promise { + return this.teamService.getTeamWithID(teamID); + } + + // Mutation + @Mutation(() => Team, { + description: 'Creates a team owned by the executing user', + }) + @UseGuards(GqlAuthGuard) + async createTeam( + @GqlUser() user: AuthUser, + @Args({ name: 'name', description: 'Displayed name of the team' }) + name: string, + ): Promise { + const team = await this.teamService.createTeam(name, user.uid); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @Mutation(() => Boolean, { + description: 'Leaves a team the executing user is a part of', + }) + @UseGuards(GqlAuthGuard) + async leaveTeam( + @GqlUser() user: AuthUser, + @Args({ + name: 'teamID', + description: 'ID of the Team to leave', + type: () => ID, + }) + teamID: string, + ): Promise { + const isUserLeft = await this.teamService.leaveTeam(teamID, user.uid); + if (E.isLeft(isUserLeft)) throwErr(isUserLeft.left); + return isUserLeft.right; + } + + @Mutation(() => Boolean, { + description: 'Removes the team member from the team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.OWNER) + async removeTeamMember( + @GqlUser() _user: AuthUser, + @Args({ + name: 'teamID', + description: 'ID of the Team to remove user from', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'userUid', + description: 'ID of the user to remove from the given team', + type: () => ID, + }) + userUid: string, + ): Promise { + const isRemoved = await this.teamService.leaveTeam(teamID, userUid); + if (E.isLeft(isRemoved)) throwErr(isRemoved.left); + return isRemoved.right; + } + + @Mutation(() => Team, { + description: 'Renames a team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.OWNER) + async renameTeam( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + @Args({ name: 'newName', description: 'The updated name of the team' }) + newName: string, + ): Promise { + const team = await this.teamService.renameTeam(teamID, newName); + if (E.isLeft(team)) throwErr(team.left); + return team.right; + } + + @Mutation(() => Boolean, { + description: 'Deletes the team', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.OWNER) + async deleteTeam( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + ): Promise { + const isDeleted = await this.teamService.deleteTeam(teamID); + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; + } + + @Mutation(() => TeamMember, { + description: 'Adds a team member to the team via email', + deprecationReason: + 'This is only present for backwards compatibility and will be removed soon use team invitations instead', + }) + @RequiresTeamRole(TeamMemberRole.OWNER) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + async addTeamMemberByEmail( + @Args({ + name: 'teamID', + description: 'ID of the team to add to', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'userEmail', + description: 'Email of the user to add to team', + }) + userEmail: string, + @Args({ + name: 'userRole', + description: 'The role of the user to add in the team', + type: () => TeamMemberRole, + }) + role: TeamMemberRole, + ): Promise { + const teamMember = await this.teamService.addMemberToTeamWithEmail( + teamID, + userEmail, + role, + ); + if (E.isLeft(teamMember)) throwErr(teamMember.left); + return teamMember.right; + } + + @Mutation(() => TeamMember, { + description: 'Update role of a team member the executing user owns', + }) + @RequiresTeamRole(TeamMemberRole.OWNER) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + async updateTeamMemberRole( + @Args({ + name: 'teamID', + description: 'ID of the affected team', + type: () => ID, + }) + teamID: string, + @Args({ + name: 'userUid', + description: 'UID of the affected user', + type: () => ID, + }) + userUid: string, + @Args({ + name: 'newRole', + description: 'Updated role value', + type: () => TeamMemberRole, + }) + newRole: TeamMemberRole, + ): Promise { + const teamMember = await this.teamService.updateTeamMemberRole( + teamID, + userUid, + newRole, + ); + if (E.isLeft(teamMember)) throwErr(teamMember.left); + return teamMember.right; + } + + // Subscriptions + @Subscription(() => TeamMember, { + description: + 'Listen to when a new team member being added to the team. The emitted value is the new team member added.', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamMemberAdded( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/member_added`); + } + + @Subscription(() => TeamMember, { + description: + 'Listen to when a team member status has been updated. The emitted value is the new team member status', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamMemberUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/member_updated`); + } + + @Subscription(() => ID, { + description: + 'Listen to when a team member has been removed. The emitted value is the uid of the user removed', + resolve: (value) => value, + }) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + teamMemberRemoved( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/member_removed`); + } +} diff --git a/packages/hoppscotch-backend/src/team/team.service.spec.ts b/packages/hoppscotch-backend/src/team/team.service.spec.ts new file mode 100644 index 000000000..7096dc681 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.service.spec.ts @@ -0,0 +1,920 @@ +import { TeamService } from './team.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { Team, TeamMember, TeamMemberRole } from './team.model'; +import { TeamMember as DbTeamMember } from '@prisma/client'; +import { + USER_NOT_FOUND, + TEAM_INVALID_ID, + TEAM_NAME_INVALID, + TEAM_ONLY_ONE_OWNER, + TEAM_INVALID_ID_OR_USER, +} from '../errors'; +import { mockDeep, mockReset } from 'jest-mock-extended'; + +const mockPrisma = mockDeep(); + +const mockUserService = { + getUserWithEmail: jest.fn(), + getUserForUID: jest.fn(), + authenticateWithIDToken: jest.fn(), +}; + +const mockPubSub = { + publish: jest.fn().mockResolvedValue(null), +}; + +const teamService = new TeamService( + mockPrisma as any, + mockUserService as any, + mockPubSub as any, +); + +beforeEach(async () => { + mockReset(mockPrisma); +}); + +const team: Team = { + id: 'teamID', + name: 'teamName', +}; +const dbTeamMember: DbTeamMember = { + id: 'teamMemberID', + role: TeamMemberRole.VIEWER, + userUid: 'userUid', + teamID: team.id, +}; +const teamMember: TeamMember = { + membershipID: dbTeamMember.id, + role: TeamMemberRole[dbTeamMember.role], + userUid: dbTeamMember.userUid, +}; + +describe('getCountOfUsersWithRoleInTeam', () => { + test('resolves to the correct count of owners in a team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + + await expect( + teamService.getCountOfUsersWithRoleInTeam( + dbTeamMember.teamID, + TeamMemberRole.OWNER, + ), + ).resolves.toEqual(2); + + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: dbTeamMember.teamID, + role: TeamMemberRole.OWNER, + }, + }); + }); + + test('resolves to the correct count of viewers in a team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + + await expect( + teamService.getCountOfUsersWithRoleInTeam( + dbTeamMember.teamID, + TeamMemberRole.VIEWER, + ), + ).resolves.toEqual(2); + + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: dbTeamMember.teamID, + role: TeamMemberRole.VIEWER, + }, + }); + }); + + test('resolves to the correct count of editors in a team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + + await expect( + teamService.getCountOfUsersWithRoleInTeam( + dbTeamMember.teamID, + TeamMemberRole.EDITOR, + ), + ).resolves.toEqual(2); + + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: dbTeamMember.teamID, + role: TeamMemberRole.EDITOR, + }, + }); + }); +}); + +describe('addMemberToTeam', () => { + test('resolves when proper team id is given', () => { + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + expect( + teamService.addMemberToTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamMemberRole[dbTeamMember.role], + ), + ).resolves.toEqual(expect.objectContaining(teamMember)); + }); + + test('makes the update in the database', async () => { + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + await teamService.addMemberToTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamMemberRole[dbTeamMember.role], + ); + + expect(mockPrisma.teamMember.create).toHaveBeenCalledWith({ + data: { + userUid: dbTeamMember.userUid, + team: { + connect: { + id: dbTeamMember.teamID, + }, + }, + role: TeamMemberRole[dbTeamMember.role], + }, + }); + }); + + test('fires "team//member_added" pubsub message with correct payload', async () => { + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + const member = await teamService.addMemberToTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamMemberRole[dbTeamMember.role], + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_added`, + member, + ); + }); +}); + +describe('addMemberToTeamWithEmail', () => { + afterEach(() => { + mockUserService.getUserWithEmail.mockClear(); + mockUserService.authenticateWithIDToken.mockClear(); + mockUserService.authenticateWithIDToken.mockClear(); + }); + + test('resolves when user with email exists', () => { + mockUserService.getUserWithEmail.mockResolvedValueOnce({ + uid: dbTeamMember.userUid, + }); + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + const result = teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamMemberRole[dbTeamMember.role], + ); + return expect(result).resolves.toBeDefined(); + }); + + test("rejects with user with email doesn't exist with USER_NOT_FOUND", () => { + mockUserService.getUserWithEmail.mockResolvedValue(null); + + const result = teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamMemberRole[dbTeamMember.role], + ); + return expect(result).resolves.toEqualLeft(USER_NOT_FOUND); + }); + + test('makes update in the database', async () => { + mockUserService.getUserWithEmail.mockResolvedValueOnce({ + uid: dbTeamMember.userUid, + }); + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + await teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamMemberRole[dbTeamMember.role], + ); + + expect(mockPrisma.teamMember.create).toHaveBeenCalledWith({ + data: { + userUid: dbTeamMember.userUid, + team: { + connect: { + id: dbTeamMember.teamID, + }, + }, + role: TeamMemberRole[dbTeamMember.role], + }, + }); + }); + + test('fires "team//member_added" pubsub message with correct payload', async () => { + mockUserService.getUserWithEmail.mockResolvedValueOnce({ + uid: dbTeamMember.userUid, + }); + mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember); + + await teamService.addMemberToTeamWithEmail( + dbTeamMember.teamID, + 'test@hoppscotch.io', + TeamMemberRole[dbTeamMember.role], + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_added`, + teamMember, + ); + }); +}); + +describe('deleteTeam', () => { + test('resolves for proper deletion', async () => { + mockPrisma.team.findUnique.mockResolvedValue(team); + mockPrisma.teamMember.deleteMany.mockResolvedValue({ + count: 10, + }); + mockPrisma.team.delete.mockResolvedValue(team); + + const result = await teamService.deleteTeam(team.id); + return expect(result).toEqualRight(true); + }); + + test('performs deletion on database', async () => { + mockPrisma.team.findUnique.mockResolvedValue(team); + mockPrisma.teamMember.deleteMany.mockResolvedValue({ + count: 10, + }); + mockPrisma.team.delete.mockResolvedValue(team); + + await teamService.deleteTeam(team.id); + + expect(mockPrisma.team.delete).toHaveBeenCalledWith({ + where: { + id: team.id, + }, + }); + }); + + test('rejects for invalid team id', async () => { + mockPrisma.team.findUnique.mockResolvedValue(null); + + // If invalid team ID, team member deletes nothing (count 0) + mockPrisma.teamMember.deleteMany.mockResolvedValue({ + count: 0, + }); + + // TODO: Confirm RecordNotFound works like this + mockPrisma.team.delete.mockRejectedValue('RecordNotFound'); + + // Team will not find and reject + const result = await teamService.deleteTeam(team.id); + return expect(result).toEqualLeft(TEAM_INVALID_ID); + }); +}); + +describe('renameTeam', () => { + test('resolves for proper rename', () => { + const newTeamName = 'Rename'; + + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toBeDefined(); + }); + + test('resolves with team structure', () => { + const newTeamName = 'Rename'; + + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toEqualRight( + expect.objectContaining({ + ...team, + name: newTeamName, + }), + ); + }); + + test('performs rename on database', async () => { + const newTeamName = 'Rename'; + + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + await teamService.renameTeam(team.id, newTeamName); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { + id: team.id, + }, + data: { + name: newTeamName, + }, + }); + }); + + test('rejects for invalid team id with TEAM_INVALID_ID', () => { + const newTeamName = 'Rename'; + // If invalid team id, update fails with RecordNotFound + mockPrisma.team.update.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toEqualLeft(TEAM_INVALID_ID); + }); + + test('rejects for new team name length < 6 with TEAM_NAME_INVALID', () => { + const newTeamName = 'smol'; + + // Prisma doesn't care about the team name length, so it will resolve + mockPrisma.team.update.mockResolvedValue({ + ...team, + name: newTeamName, + }); + + return expect( + teamService.renameTeam(team.id, newTeamName), + ).resolves.toEqualLeft(TEAM_NAME_INVALID); + }); +}); + +describe('updateTeamMemberRole', () => { + /** + * Test Scenario: + * 3 users (testuid1 thru 3) having each of the roles + * (OWNER, VIEWER, EDITOR) + * in Team with id 3170 + */ + + test('updates the role', async () => { + const newRole = TeamMemberRole.EDITOR; + + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole[dbTeamMember.role], + }); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: newRole, + }); + + await teamService.updateTeamMemberRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + newRole, + ); + + expect(mockPrisma.teamMember.update).toHaveBeenCalledWith({ + where: { + teamID_userUid: { + teamID: dbTeamMember.teamID, + userUid: dbTeamMember.userUid, + }, + }, + data: { + role: newRole, + }, + }); + }); + + test('returns the updated details', () => { + const newRole = TeamMemberRole.EDITOR; + + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: newRole, + }); + + return expect( + teamService.updateTeamMemberRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + newRole, + ), + ).resolves.toEqualRight({ ...teamMember, role: newRole }); + }); + + test('rejects if you change the status of the sole owner to non-owner status with TEAM_ONLY_ONE_OWNER', () => { + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + + // Prisma doesn't care if it goes through + mockPrisma.teamMember.update.mockResolvedValue(dbTeamMember); + + return expect( + teamService.updateTeamMemberRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamMemberRole[dbTeamMember.role], + ), + ).resolves.toEqualLeft(TEAM_ONLY_ONE_OWNER); + }); + + test('resolves if you change the status of the sole owner to owner status (no change)', () => { + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + + return expect( + teamService.updateTeamMemberRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamMemberRole[TeamMemberRole.OWNER], + ), + ).resolves.toBeDefined(); + }); + + test('resolves if you change the status of an owner but there are other owners', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + mockPrisma.teamMember.update.mockResolvedValue(dbTeamMember); + + // Set another user as the owner + await teamService.updateTeamMemberRole( + dbTeamMember.teamID, + 'testuid2', + TeamMemberRole.OWNER, + ); + + await expect( + teamService.updateTeamMemberRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + TeamMemberRole[dbTeamMember.role], + ), + ).resolves.toBeDefined(); + }); + + test('fires "team//member_updated" pubsub message with correct payload', async () => { + const newRole = TeamMemberRole.EDITOR; + + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.update.mockResolvedValue({ + ...dbTeamMember, + role: newRole, + }); + + await teamService.updateTeamMemberRole( + dbTeamMember.teamID, + dbTeamMember.userUid, + newRole, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_updated`, + { + ...teamMember, + role: newRole, + }, + ); + }); +}); + +describe('leaveTeam', () => { + /* + Same scenario as above: + 3 users (testuid1 thru 3) with respectively + OWNER, VIEWER and EDITOR roles in team with id 3170 + */ + + test('removes the user if valid credentials given', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.delete.mockResolvedValue(dbTeamMember); + + await teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid); + + expect(mockPrisma.teamMember.delete).toHaveBeenCalledWith({ + where: { + teamID_userUid: { + teamID: dbTeamMember.teamID, + userUid: dbTeamMember.userUid, + }, + }, + }); + }); + + test('rejects if invalid teamId with TEAM_INVALID_ID_OR_USER', () => { + // Invalid team id will return 0 count + mockPrisma.teamMember.count.mockResolvedValue(0); + + // getTeamMember returns null if no match + mockPrisma.teamMember.findUnique.mockResolvedValue(null); + + // Deletion rejects with RecordNotFound when no match + mockPrisma.teamMember.delete.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.leaveTeam('31700', dbTeamMember.userUid), + ).resolves.toEqualLeft(TEAM_INVALID_ID_OR_USER); + }); + + test('rejects if invalid userUid with TEAM_INVALID_ID_OR_USER', () => { + // Invalid team id will return proper count + mockPrisma.teamMember.count.mockResolvedValue(1); + + // getTeamMember returns null if no match + mockPrisma.teamMember.findUnique.mockResolvedValue(null); + + // Deletion rejects with RecordNotFound when no match + mockPrisma.teamMember.delete.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.leaveTeam(dbTeamMember.teamID, 'testuid3'), + ).resolves.toEqualLeft(TEAM_INVALID_ID_OR_USER); + }); + + test('rejects if the removed user is the sole owner of the team with TEAM_ONLY_ONE_OWNER', () => { + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + + // Prisma does not care + mockPrisma.teamMember.delete.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + + return expect( + teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid), + ).resolves.toEqualLeft(TEAM_ONLY_ONE_OWNER); + }); + + test('resolves if the removed user is an owner (but not the sole) of the team', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + mockPrisma.teamMember.delete.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + + await expect( + teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid), + ).resolves.toEqualRight(true); + }); + + test('fires "team//member_removed" pubsub message with correct payload', async () => { + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + mockPrisma.teamMember.delete.mockResolvedValue(dbTeamMember); + + await teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `team/${dbTeamMember.teamID}/member_removed`, + dbTeamMember.userUid, + ); + }); +}); + +describe('createTeam', () => { + test('adds the new team to the db', async () => { + mockPrisma.team.create.mockResolvedValue(team); + + await teamService.createTeam(team.name, dbTeamMember.userUid); + + expect(mockPrisma.team.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + name: team.name, + }), + }), + ); + }); + + test('adds the creator to team and set them as OWNER', async () => { + mockPrisma.team.create.mockResolvedValue(team); + + await teamService.createTeam(team.name, dbTeamMember.userUid); + + expect(mockPrisma.team.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + members: { + create: { + userUid: dbTeamMember.userUid, + role: TeamMemberRole.OWNER, + }, + }, + }), + }), + ); + }); + + test('resolves with the team info', () => { + mockPrisma.team.create.mockResolvedValue(team); + + return expect( + teamService.createTeam(team.name, dbTeamMember.userUid), + ).resolves.toEqualRight(expect.objectContaining(team)); + }); + + test('rejects for team name length < 6 with TEAM_NAME_INVALID', () => { + const newName = 'smol'; + + // Prisma doesn't care + mockPrisma.team.create.mockResolvedValue({ + ...team, + name: newName, + }); + + return expect( + teamService.createTeam(newName, dbTeamMember.userUid), + ).resolves.toEqualLeft(TEAM_NAME_INVALID); + }); +}); + +describe('getTeamWithID', () => { + test('resolves for a proper team id with the proper details', () => { + mockPrisma.team.findUnique.mockResolvedValue(team); + + return expect(teamService.getTeamWithID(team.id)).resolves.toEqual( + expect.objectContaining(team), + ); + }); + + test('resolves for a invalid team id as null', () => { + // Prisma would reject with RecordNotFound + mockPrisma.team.findUnique.mockRejectedValue('RecordNotFound'); + + return expect(teamService.getTeamWithID('3171')).resolves.toBeNull(); + }); +}); + +describe('getTeamMember', () => { + test('resolves for a proper team id and user uid and returns the info', () => { + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + + return expect( + teamService.getTeamMember(dbTeamMember.teamID, dbTeamMember.userUid), + ).resolves.toEqual(expect.objectContaining(teamMember)); + }); + + test('resolves for a invalid team id and proper uid and returns null', () => { + // If not found, prisma rejects with RecordNotFound + mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.getTeamMember(dbTeamMember.teamID, 'testuid'), + ).resolves.toBeNull(); + }); +}); + +describe('getRoleOfUserInTeam', () => { + test('resolves with the correct role value', () => { + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + + return expect( + teamService.getRoleOfUserInTeam( + dbTeamMember.teamID, + dbTeamMember.userUid, + ), + ).resolves.toEqual(dbTeamMember.role); + }); + + test('resolves with null if user is not found in team', () => { + mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.getRoleOfUserInTeam(dbTeamMember.teamID, 'nottestuid'), + ).resolves.toBeNull(); + }); + + test('resolves with null if team does not exist', () => { + mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound'); + + return expect( + teamService.getRoleOfUserInTeam('invalidteam', dbTeamMember.userUid), + ).resolves.toBeNull(); + }); +}); + +describe('getMembersOfTeam', () => { + test('resolves for the team id and null cursor with the first page', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + await teamService.getMembersOfTeam(team.id, null); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + take: 10, + where: { + teamID: team.id, + }, + }); + }); + + test('resolves for the team id and proper cursor with pagination', async () => { + const cursor = 'secondpage'; + + mockPrisma.teamMember.findMany.mockResolvedValue([]); + await teamService.getMembersOfTeam(team.id, cursor); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + take: 10, + skip: 1, + cursor: { + id: cursor, + }, + where: { + teamID: team.id, + }, + }); + }); + + test('resolves with an empty array for invalid team id and null cursor', () => { + // findMany returns an empty array if no matches are found + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getMembersOfTeam('invalidteamid', null), + ).resolves.toHaveLength(0); + }); + + test('resolves with an empty array for an invalid team id and invalid cursor', () => { + // findMany returns an empty array if no matches are found + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getMembersOfTeam('invalidteamid', 'invalidcursor'), + ).resolves.toHaveLength(0); + }); +}); + +describe('getTeamsOfUser', () => { + test('resolves with the first 10 elements when no cursor is given', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + await teamService.getTeamsOfUser(dbTeamMember.userUid, null); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + take: 10, + where: { + userUid: dbTeamMember.userUid, + }, + include: { + team: true, + }, + }); + }); + + test('resolves as expected for paginated requests with cursor', async () => { + const cursor = 'secondpage'; + + mockPrisma.teamMember.findMany.mockResolvedValue([]); + await teamService.getTeamsOfUser(dbTeamMember.userUid, cursor); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + take: 10, + skip: 1, + cursor: { + teamID_userUid: { + teamID: cursor, + userUid: dbTeamMember.userUid, + }, + }, + where: { + userUid: dbTeamMember.userUid, + }, + include: { + team: true, + }, + }); + }); + + test('resolves with an empty array for an invalid cursor', () => { + // Invalid cursors return an empty array + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getTeamsOfUser(dbTeamMember.userUid, 'invalidcursor'), + ).resolves.toHaveLength(0); + }); + + test('resolves with an empty array for invalid user id and null cursor', () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getTeamsOfUser('invalidid', null), + ).resolves.toHaveLength(0); + }); + + test('resolves with an empty array for invalid user id and invalid cursor', () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + return expect( + teamService.getTeamsOfUser('invalidId', 'invalidCursor'), + ).resolves.toHaveLength(0); + }); +}); + +describe('deleteUserFromAllTeams', () => { + test('should return undefined when a valid uid is passed and user is deleted from all teams', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([dbTeamMember]); + mockPrisma.teamMember.count.mockResolvedValue(2); + mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember); + + const result = await teamService.deleteUserFromAllTeams( + dbTeamMember.userUid, + )(); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + + expect(result).toBeUndefined(); + }); + + test('should return undefined when user has no data or the uid is invalid', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([]); + + const result = await teamService.deleteUserFromAllTeams( + dbTeamMember.userUid, + )(); + + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + + expect(result).toBeUndefined(); + }); + + test('should reject when user is an OWNER in a team with only 1 member', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([dbTeamMember]); + mockPrisma.teamMember.count.mockResolvedValue(1); + mockPrisma.teamMember.findUnique.mockResolvedValue({ + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }); + + const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid)(); + + await expect(result).rejects.toThrowError(TEAM_ONLY_ONE_OWNER); + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + }); + + test('should reject when a valid uid is passed but fetching teamMember details errors out', async () => { + mockPrisma.teamMember.findMany.mockResolvedValue([ + { + ...dbTeamMember, + role: TeamMemberRole.OWNER, + }, + ]); + mockPrisma.teamMember.count.mockResolvedValue(2); + + // findUnique while getTeamMember() is called errors out + mockPrisma.teamMember.findUnique.mockRejectedValueOnce('NotFoundError'); + + const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid); + + await expect(result).rejects.toThrowError(TEAM_INVALID_ID_OR_USER); + expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + where: { + userUid: dbTeamMember.userUid, + }, + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team/team.service.ts b/packages/hoppscotch-backend/src/team/team.service.ts new file mode 100644 index 000000000..b16524d30 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/team.service.ts @@ -0,0 +1,492 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { TeamMember, TeamMemberRole, Team } from './team.model'; +import { PrismaService } from '../prisma/prisma.service'; +import { TeamMember as DbTeamMember } from '@prisma/client'; +import { UserService } from '../user/user.service'; +import { UserDataHandler } from 'src/user/user.data.handler'; +import { User } from 'src/user/user.model'; +import { + TEAM_NAME_INVALID, + TEAM_ONLY_ONE_OWNER, + USER_NOT_FOUND, + TEAM_INVALID_ID, + TEAM_INVALID_ID_OR_USER, + TEAM_MEMBER_NOT_FOUND, + USER_IS_OWNER, +} from '../errors'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { flow, pipe } from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import * as TO from 'fp-ts/TaskOption'; +import * as O from 'fp-ts/Option'; +import * as E from 'fp-ts/Either'; +import * as T from 'fp-ts/Task'; +import * as A from 'fp-ts/Array'; +import { throwErr } from 'src/utils'; + +@Injectable() +export class TeamService implements UserDataHandler, OnModuleInit { + constructor( + private readonly prisma: PrismaService, + private readonly userService: UserService, + private readonly pubsub: PubSubService, + ) {} + + onModuleInit() { + this.userService.registerUserDataHandler(this); + } + + canAllowUserDeletion(user: User): TO.TaskOption { + return pipe( + this.isUserOwnerRoleInTeams(user.uid), + TO.fromTask, + TO.chain((isOwner) => (isOwner ? TO.some(USER_IS_OWNER) : TO.none)), + ); + } + + onUserDelete(user: User): T.Task { + return this.deleteUserFromAllTeams(user.uid); + } + + async getCountOfUsersWithRoleInTeam( + teamID: string, + role: TeamMemberRole, + ): Promise { + return await this.prisma.teamMember.count({ + where: { + teamID, + role, + }, + }); + } + + async addMemberToTeamWithEmail( + teamID: string, + email: string, + role: TeamMemberRole, + ): Promise | E.Right> { + const user = await this.userService.findUserByEmail(email); + if(O.isNone(user)) return E.left(USER_NOT_FOUND); + + const teamMember = await this.addMemberToTeam(teamID, user.value.uid, role); + return E.right(teamMember); + } + + async addMemberToTeam( + teamID: string, + uid: string, + role: TeamMemberRole, + ): Promise { + const teamMember = await this.prisma.teamMember.create({ + data: { + userUid: uid, + team: { + connect: { + id: teamID, + }, + }, + role: role, + }, + }); + + const member: TeamMember = { + membershipID: teamMember.id, + userUid: teamMember.userUid, + role: TeamMemberRole[teamMember.role], + }; + + this.pubsub.publish(`team/${teamID}/member_added`, member); + + return member; + } + + async deleteTeam(teamID: string): Promise | E.Right> { + const team = await this.prisma.team.findUnique({ + where: { + id: teamID, + }, + }); + if (!team) return E.left(TEAM_INVALID_ID); + + await this.prisma.teamMember.deleteMany({ + where: { + teamID: teamID, + }, + }); + + await this.prisma.team.delete({ + where: { + id: teamID, + }, + }); + + return E.right(true); + } + + validateTeamName(title: string): E.Left | E.Right { + if (!title || title.length < 6) return E.left(TEAM_NAME_INVALID); + return E.right(true); + } + + async renameTeam( + teamID: string, + newName: string, + ): Promise | E.Right> { + const isValidTitle = this.validateTeamName(newName); + if (E.isLeft(isValidTitle)) return isValidTitle; + + try { + const updatedTeam = await this.prisma.team.update({ + where: { + id: teamID, + }, + data: { + name: newName, + }, + }); + return E.right(updatedTeam); + } catch (e) { + // Prisma update errors out if it can't find the record + return E.left(TEAM_INVALID_ID); + } + } + + async updateTeamMemberRole( + teamID: string, + userUid: string, + newRole: TeamMemberRole, + ): Promise | E.Right> { + const ownerCount = await this.prisma.teamMember.count({ + where: { + teamID, + role: TeamMemberRole.OWNER, + }, + }); + + const member = await this.prisma.teamMember.findUnique({ + where: { + teamID_userUid: { + teamID, + userUid, + }, + }, + }); + + if (!member) return E.left(TEAM_MEMBER_NOT_FOUND); + if ( + member.role === TeamMemberRole.OWNER && + newRole != TeamMemberRole.OWNER && + ownerCount === 1 + ) { + return E.left(TEAM_ONLY_ONE_OWNER); + } + + const result = await this.prisma.teamMember.update({ + where: { + teamID_userUid: { + teamID, + userUid, + }, + }, + data: { + role: newRole, + }, + }); + + const updatedMember: TeamMember = { + membershipID: result.id, + userUid: result.userUid, + role: TeamMemberRole[result.role], + }; + + this.pubsub.publish(`team/${teamID}/member_updated`, updatedMember); + + return E.right(updatedMember); + } + + async leaveTeam( + teamID: string, + userUid: string, + ): Promise | E.Right> { + const ownerCount = await this.prisma.teamMember.count({ + where: { + teamID, + role: TeamMemberRole.OWNER, + }, + }); + + const member = await this.getTeamMember(teamID, userUid); + if (!member) return E.left(TEAM_INVALID_ID_OR_USER); + + if (ownerCount === 1 && member.role === TeamMemberRole.OWNER) { + return E.left(TEAM_ONLY_ONE_OWNER); + } + + try { + await this.prisma.teamMember.delete({ + where: { + teamID_userUid: { + userUid, + teamID, + }, + }, + }); + } catch (e) { + // Record not found + return E.left(TEAM_INVALID_ID_OR_USER); + } + + this.pubsub.publish(`team/${teamID}/member_removed`, userUid); + + return E.right(true); + } + + async createTeam( + name: string, + creatorUid: string, + ): Promise | E.Right> { + const isValidName = this.validateTeamName(name); + if (E.isLeft(isValidName)) return isValidName; + + const team = await this.prisma.team.create({ + data: { + name: name, + members: { + create: { + userUid: creatorUid, + role: TeamMemberRole.OWNER, + }, + }, + }, + }); + + return E.right(team); + } + + async getTeamsOfUser(uid: string, cursor: string | null): Promise { + if (!cursor) { + const entries = await this.prisma.teamMember.findMany({ + take: 10, + where: { + userUid: uid, + }, + include: { + team: true, + }, + }); + + return entries.map((entry) => entry.team); + } else { + const entries = await this.prisma.teamMember.findMany({ + take: 10, + skip: 1, + cursor: { + teamID_userUid: { + teamID: cursor, + userUid: uid, + }, + }, + where: { + userUid: uid, + }, + include: { + team: true, + }, + }); + + return entries.map((entry) => entry.team); + } + } + + async getTeamWithID(teamID: string): Promise { + try { + const team = await this.prisma.team.findUnique({ + where: { + id: teamID, + }, + }); + + return team; + } catch (_e) { + return null; + } + } + + getTeamWithIDTE(teamID: string): TE.TaskEither<'team/invalid_id', Team> { + return pipe( + () => this.getTeamWithID(teamID), + TE.fromTask, + TE.chain( + TE.fromPredicate( + (x): x is Team => !!x, + () => TEAM_INVALID_ID, + ), + ), + ); + } + + /** + * Filters out team members that we weren't able to match + * (also deletes the membership) + * @param members Members to filter against + */ + async filterMismatchedUsers( + teamID: string, + members: TeamMember[], + ): Promise { + const memberUsers = await Promise.all( + members.map(async (member) => { + const user = await this.userService.findUserById(member.userUid); + + // // TODO:Investigate if a race condition exists that deletes user from teams. + // // Delete the membership if the user doesnt exist + // if (!user) this.leaveTeam(teamID, member.userUid); + + if (O.isSome(user)) return member; + else return null; + }), + ); + + return memberUsers.filter((x) => x !== null) as TeamMember[]; + } + + async getTeamMember( + teamID: string, + userUid: string, + ): Promise { + try { + const teamMember = await this.prisma.teamMember.findUnique({ + where: { + teamID_userUid: { + teamID, + userUid, + }, + }, + }); + + if (!teamMember) return null; + + return { + membershipID: teamMember.id, + userUid: userUid, + role: TeamMemberRole[teamMember.role], + }; + } catch (e) { + return null; + } + } + + getTeamMemberTE(teamID: string, userUid: string) { + return pipe( + () => this.getTeamMember(teamID, userUid), + TE.fromTask, + TE.chain( + TE.fromPredicate( + (x): x is TeamMember => !!x, + () => TEAM_MEMBER_NOT_FOUND, + ), + ), + ); + } + + async getRoleOfUserInTeam( + teamID: string, + userUid: string, + ): Promise { + const teamMember = await this.getTeamMember(teamID, userUid); + return teamMember ? teamMember.role : null; + } + + isUserOwnerRoleInTeams(uid: string): T.Task { + return pipe( + () => + this.prisma.teamMember.count({ + where: { + userUid: uid, + role: TeamMemberRole.OWNER, + }, + take: 1, + }), + T.map((count) => count > 0), + ); + } + + deleteUserFromAllTeams(uid: string) { + return pipe( + () => + this.prisma.teamMember.findMany({ + where: { + userUid: uid, + }, + }), + T.chainFirst( + flow( + A.map((member) => async () => { + const res = await this.leaveTeam(member.teamID, uid); + if (E.isLeft(res)) throwErr(res.left); + return E.right(res); + }), + T.sequenceArray, + ), + ), + T.map(() => undefined), + ); + } + + async getTeamMembers(teamID: string): Promise { + const dbTeamMembers = await this.prisma.teamMember.findMany({ + where: { + teamID, + }, + }); + + const members = dbTeamMembers.map( + (entry) => + { + membershipID: entry.id, + userUid: entry.userUid, + role: TeamMemberRole[entry.role], + }, + ); + + return this.filterMismatchedUsers(teamID, members); + } + + async getMembersOfTeam( + teamID: string, + cursor: string | null, + ): Promise { + let teamMembers: DbTeamMember[]; + + if (!cursor) { + teamMembers = await this.prisma.teamMember.findMany({ + take: 10, + where: { + teamID, + }, + }); + } else { + teamMembers = await this.prisma.teamMember.findMany({ + take: 10, + skip: 1, + cursor: { + id: cursor, + }, + where: { + teamID, + }, + }); + } + + const members = teamMembers.map( + (entry) => + { + membershipID: entry.id, + userUid: entry.userUid, + role: TeamMemberRole[entry.role], + }, + ); + + return this.filterMismatchedUsers(teamID, members); + } +} diff --git a/packages/hoppscotch-backend/src/user/user.data.handler.ts b/packages/hoppscotch-backend/src/user/user.data.handler.ts new file mode 100644 index 000000000..eee0b75c9 --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.data.handler.ts @@ -0,0 +1,11 @@ +import * as T from "fp-ts/Task" +import * as TO from "fp-ts/TaskOption" +import { User } from "src/user/user.model" + +/** + * Defines how external services should handle User Data and User data related operations and actions. + */ +export interface UserDataHandler { + canAllowUserDeletion: (user: User) => TO.TaskOption + onUserDelete: (user: User) => T.Task +} \ No newline at end of file diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index a712f3ac4..a758e79f3 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -8,6 +8,7 @@ import { SessionType, User } from './user.model'; import { USER_UPDATE_FAILED } from 'src/errors'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { stringToJson } from 'src/utils'; +import { UserDataHandler } from './user.data.handler'; @Injectable() export class UserService { @@ -16,13 +17,19 @@ export class UserService { private readonly pubsub: PubSubService, ) {} + private userDataHandlers: UserDataHandler[] = []; + + registerUserDataHandler(handler: UserDataHandler) { + this.userDataHandlers.push(handler); + } + /** * Find User with given email id * * @param email User's email * @returns Option of found User */ - async findUserByEmail(email: string) { + async findUserByEmail(email: string): Promise> { try { const user = await this.prisma.user.findUniqueOrThrow({ where: { @@ -41,7 +48,7 @@ export class UserService { * @param userUid User ID * @returns Option of found User */ - async findUserById(userUid: string) { + async findUserById(userUid: string): Promise> { try { const user = await this.prisma.user.findUniqueOrThrow({ where: {