diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index c88c7f16f..c09463566 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -101,6 +101,7 @@ model User { currentRESTSession Json? currentGQLSession Json? createdOn DateTime @default(now()) @db.Timestamp(3) + invitedUsers InvitedUsers[] } model Account { @@ -160,6 +161,14 @@ model UserEnvironment { isGlobal Boolean } +model InvitedUsers { + adminUid String + user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade) + adminEmail String + inviteeEmail String @unique + invitedOn DateTime @default(now()) @db.Timestamp(3) +} + model UserRequest { id String @id @default(cuid()) userCollection UserCollection @relation(fields: [collectionID], references: [id]) diff --git a/packages/hoppscotch-backend/src/admin/admin.model.ts b/packages/hoppscotch-backend/src/admin/admin.model.ts new file mode 100644 index 000000000..2c58d3721 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.model.ts @@ -0,0 +1,4 @@ +import { ObjectType, ID, Field, ResolveField } from '@nestjs/graphql'; + +@ObjectType() +export class Admin {} diff --git a/packages/hoppscotch-backend/src/admin/admin.module.ts b/packages/hoppscotch-backend/src/admin/admin.module.ts new file mode 100644 index 000000000..d7eecc64d --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { AdminResolver } from './admin.resolver'; +import { AdminService } from './admin.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PubSubModule } from '../pubsub/pubsub.module'; +import { UserModule } from '../user/user.module'; +import { MailerModule } from '../mailer/mailer.module'; +import { TeamModule } from '../team/team.module'; +import { TeamInvitationModule } from '../team-invitation/team-invitation.module'; +import { TeamEnvironmentsModule } from '../team-environments/team-environments.module'; +import {TeamCollectionModule} from "../team-collection/team-collection.module"; +import {TeamRequestModule} from "../team-request/team-request.module"; + +@Module({ + imports: [ + PrismaModule, + PubSubModule, + UserModule, + MailerModule, + TeamModule, + TeamInvitationModule, + TeamEnvironmentsModule, + TeamCollectionModule, + TeamRequestModule, + ], + providers: [AdminResolver, AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/packages/hoppscotch-backend/src/admin/admin.resolver.ts b/packages/hoppscotch-backend/src/admin/admin.resolver.ts new file mode 100644 index 000000000..1f2d6b9ef --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.resolver.ts @@ -0,0 +1,402 @@ +import { + Args, + ID, + Mutation, + Parent, + Query, + ResolveField, + Resolver, + Subscription, +} from '@nestjs/graphql'; +import { Admin } from './admin.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlAdminGuard } from './guards/gql-admin.guard'; +import { GqlAdmin } from './decorators/gql-admin.decorator'; +import { AdminService } from './admin.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from '../utils'; +import { AuthUser } from '../types/AuthUser'; +import { InvitedUser } from './invited-user.model'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { Team, TeamMember } from '../team/team.model'; +import { User } from '../user/user.model'; +import { TeamInvitation } from '../team-invitation/team-invitation.model'; +import { PaginationArgs } from '../types/input-types.args'; +import { + AddUserToTeamArgs, + ChangeUserRoleInTeamArgs, +} from './input-types.args'; + +@Resolver(() => Admin) +export class AdminResolver { + constructor( + private adminService: AdminService, + private readonly pubsub: PubSubService, + ) {} + // Query + @Query(() => Admin, { + description: 'Gives details of the admin executing this query', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + admin(@GqlAdmin() admin: Admin) { + return admin; + } + + @ResolveField(() => [User], { + description: 'Returns a list of all admin users in infra', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async admins() { + const admins = await this.adminService.fetchAdmins(); + return admins; + } + @ResolveField(() => User, { + description: 'Returns a user info by UID', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async userInfo( + @Args({ + name: 'userUid', + type: () => ID, + description: 'The user UID', + }) + userUid: string, + ): Promise { + const user = await this.adminService.fetchUserInfo(userUid); + if (E.isLeft(user)) throwErr(user.left); + return user.right; + } + + @ResolveField(() => [User], { + description: 'Returns a list of all the users in infra', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async allUsers( + @Parent() admin: Admin, + @Args() args: PaginationArgs, + ): Promise { + const users = await this.adminService.fetchUsers(args.cursor, args.take); + return users; + } + + @ResolveField(() => [InvitedUser], { + description: 'Returns a list of all the invited users', + }) + async invitedUsers(@Parent() admin: Admin): Promise { + const users = await this.adminService.fetchInvitedUsers(); + return users; + } + + @ResolveField(() => [Team], { + description: 'Returns a list of all the teams in the infra', + }) + async allTeams( + @Parent() admin: Admin, + @Args() args: PaginationArgs, + ): Promise { + const teams = await this.adminService.fetchAllTeams(args.cursor, args.take); + return teams; + } + + @ResolveField(() => Number, { + description: 'Return count of all the members in a team', + }) + async membersCountInTeam( + @Parent() admin: Admin, + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + nullable: false, + }) + teamID: string, + ): Promise { + const teamMembersCount = await this.adminService.membersCountInTeam(teamID); + return teamMembersCount; + } + + @ResolveField(() => Number, { + description: 'Return count of all the stored collections in a team', + }) + async collectionCountInTeam( + @Parent() admin: Admin, + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ): Promise { + const teamCollCount = await this.adminService.collectionCountInTeam(teamID); + return teamCollCount; + } + @ResolveField(() => Number, { + description: 'Return count of all the stored requests in a team', + }) + async requestCountInTeam( + @Parent() admin: Admin, + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ): Promise { + const teamReqCount = await this.adminService.requestCountInTeam(teamID); + return teamReqCount; + } + + @ResolveField(() => Number, { + description: 'Return count of all the stored environments in a team', + }) + async environmentCountInTeam( + @Parent() admin: Admin, + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ): Promise { + const envsCount = await this.adminService.environmentCountInTeam(teamID); + return envsCount; + } + + @ResolveField(() => [TeamInvitation], { + description: 'Return all the pending invitations in a team', + }) + async pendingInvitationCountInTeam( + @Parent() admin: Admin, + @Args({ + name: 'teamID', + type: () => ID, + description: 'Team ID for which team members to fetch', + }) + teamID: string, + ) { + const invitations = await this.adminService.pendingInvitationCountInTeam( + teamID, + ); + return invitations; + } + + @ResolveField(() => Number, { + description: 'Return total number of Users in organization', + }) + async usersCount() { + return this.adminService.getUsersCount(); + } + + @ResolveField(() => Number, { + description: 'Return total number of Teams in organization', + }) + async teamsCount() { + return this.adminService.getTeamsCount(); + } + + @ResolveField(() => Number, { + description: 'Return total number of Team Collections in organization', + }) + async teamCollectionsCount() { + return this.adminService.getTeamCollectionsCount(); + } + + @ResolveField(() => Number, { + description: 'Return total number of Team Requests in organization', + }) + async teamRequestsCount() { + return this.adminService.getTeamRequestsCount(); + } + + // Mutations + @Mutation(() => InvitedUser, { + description: 'Invite a user to the infra using email', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async inviteNewUser( + @GqlUser() adminUser: AuthUser, + @Args({ + name: 'inviteeEmail', + description: 'invitee email', + }) + inviteeEmail: string, + ): Promise { + const invitedUser = await this.adminService.inviteUserToSignInViaEmail( + adminUser.uid, + adminUser.email, + inviteeEmail, + ); + if (E.isLeft(invitedUser)) throwErr(invitedUser.left); + return invitedUser.right; + } + + @Mutation(() => Boolean, { + description: 'Delete an user account from infra', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUserByAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + ): Promise { + const invitedUser = await this.adminService.removeUserAccount(userUID); + if (E.isLeft(invitedUser)) throwErr(invitedUser.left); + return invitedUser.right; + } + @Mutation(() => Boolean, { + description: 'Make user an admin', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async makeUserAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + ): Promise { + const admin = await this.adminService.makeUserAdmin(userUID); + if (E.isLeft(admin)) throwErr(admin.left); + return admin.right; + } + + @Mutation(() => Boolean, { + description: 'Remove user as admin', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUserAsAdmin( + @Args({ + name: 'userUID', + description: 'users UID', + type: () => ID, + }) + userUID: string, + ): Promise { + const admin = await this.adminService.removeUserAsAdmin(userUID); + if (E.isLeft(admin)) throwErr(admin.left); + return admin.right; + } + + @Mutation(() => Team, { + description: + 'Create a new team by providing the user uid to nominate as Team owner', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async createTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args({ + name: 'userUid', + description: 'users uid to make team owner', + type: () => ID, + }) + userUid: string, + @Args({ name: 'name', description: 'Displayed name of the team' }) + name: string, + ): Promise { + const createdTeam = await this.adminService.createATeam(userUid, name); + if (E.isLeft(createdTeam)) throwErr(createdTeam.left); + return createdTeam.right; + } + @Mutation(() => TeamMember, { + description: 'Change the role of a user in a team', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async changeUserRoleInTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args() args: ChangeUserRoleInTeamArgs, + ): Promise { + const updatedRole = await this.adminService.changeRoleOfUserTeam( + args.userUID, + args.teamID, + args.newRole, + ); + if (E.isLeft(updatedRole)) throwErr(updatedRole.left); + return updatedRole.right; + } + @Mutation(() => Boolean, { + description: 'Remove the user from a team', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async removeUserFromTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args({ + name: 'userUid', + description: 'users UID', + type: () => ID, + }) + userUid: string, + @Args({ + name: 'teamID', + description: 'team ID', + type: () => ID, + }) + teamID: string, + ): Promise { + const removedUser = await this.adminService.removeUserFromTeam( + userUid, + teamID, + ); + if (E.isLeft(removedUser)) throwErr(removedUser.left); + return removedUser.right; + } + @Mutation(() => TeamMember, { + description: 'Add a user to a team with email and team member role', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async addUserToTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @Args() args: AddUserToTeamArgs, + ): Promise { + const addedUser = await this.adminService.addUserToTeam( + args.teamID, + args.userEmail, + args.role, + ); + if (E.isLeft(addedUser)) throwErr(addedUser.left); + return addedUser.right; + } + + @Mutation(() => Team, { + description: 'Change a team name', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async renameTeamByAdmin( + @GqlAdmin() adminUser: Admin, + @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 renamedTeam = await this.adminService.renameATeam(teamID, newName); + if (E.isLeft(renamedTeam)) throwErr(renamedTeam.left); + return renamedTeam.right; + } + @Mutation(() => Boolean, { + description: 'Delete a team', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async deleteTeamByAdmin( + @Args({ name: 'teamID', description: 'ID of the team', type: () => ID }) + teamID: string, + ): Promise { + const deletedTeam = await this.adminService.deleteATeam(teamID); + if (E.isLeft(deletedTeam)) throwErr(deletedTeam.left); + return deletedTeam.right; + } + + /* Subscriptions */ + + @Subscription(() => InvitedUser, { + description: 'Listen for User Invitation', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + userInvited(@GqlUser() admin: AuthUser) { + return this.pubsub.asyncIterator(`admin/${admin.uid}/invited`); + } +} diff --git a/packages/hoppscotch-backend/src/admin/admin.service.spec.ts b/packages/hoppscotch-backend/src/admin/admin.service.spec.ts new file mode 100644 index 000000000..e3b3af022 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.service.spec.ts @@ -0,0 +1,168 @@ +import { AdminService } from './admin.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { mockDeep } from 'jest-mock-extended'; +import { InvitedUsers } from '@prisma/client'; +import { UserService } from '../user/user.service'; +import { TeamService } from '../team/team.service'; +import { TeamEnvironmentsService } from '../team-environments/team-environments.service'; +import { TeamRequestService } from '../team-request/team-request.service'; +import { TeamInvitationService } from '../team-invitation/team-invitation.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { MailerService } from '../mailer/mailer.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + DUPLICATE_EMAIL, + INVALID_EMAIL, + USER_ALREADY_INVITED, +} from '../errors'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +const mockUserService = mockDeep(); +const mockTeamService = mockDeep(); +const mockTeamEnvironmentsService = mockDeep(); +const mockTeamRequestService = mockDeep(); +const mockTeamInvitationService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockMailerService = mockDeep(); + +const adminService = new AdminService( + mockUserService, + mockTeamService, + mockTeamCollectionService, + mockTeamRequestService, + mockTeamEnvironmentsService, + mockTeamInvitationService, + mockPubSub as any, + mockPrisma as any, + mockMailerService, +); + +const invitedUsers: InvitedUsers[] = [ + { + adminUid: 'uid1', + adminEmail: 'admin1@example.com', + inviteeEmail: 'i@example.com', + invitedOn: new Date(), + }, + { + adminUid: 'uid2', + adminEmail: 'admin2@example.com', + inviteeEmail: 'u@example.com', + invitedOn: new Date(), + }, +]; +describe('AdminService', () => { + describe('fetchInvitedUsers', () => { + test('should resolve right and return an array of invited users', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers); + + const results = await adminService.fetchInvitedUsers(); + expect(results).toEqual(invitedUsers); + }); + test('should resolve left and return an error if invited users not found', async () => { + mockPrisma.invitedUsers.findMany.mockResolvedValue([]); + + const results = await adminService.fetchInvitedUsers(); + expect(results).toEqual([]); + }); + }); + + describe('inviteUserToSignInViaEmail', () => { + test('should resolve right and create a invited user', async () => { + mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null); + mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]); + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + invitedUsers[0].inviteeEmail, + ); + expect(mockPrisma.invitedUsers.create).toHaveBeenCalledWith({ + data: { + adminUid: invitedUsers[0].adminUid, + adminEmail: invitedUsers[0].adminEmail, + inviteeEmail: invitedUsers[0].inviteeEmail, + }, + }); + return expect(result).toEqualRight(invitedUsers[0]); + }); + test('should resolve right, create a invited user and publish a subscription', async () => { + mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(null); + mockPrisma.invitedUsers.create.mockResolvedValueOnce(invitedUsers[0]); + await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + invitedUsers[0].inviteeEmail, + ); + return expect(mockPubSub.publish).toHaveBeenCalledWith( + `admin/${invitedUsers[0].adminUid}/invited`, + invitedUsers[0], + ); + }); + test('should resolve left and return an error when invalid invitee email is passed', async () => { + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + 'invalidemail', + ); + return expect(result).toEqualLeft(INVALID_EMAIL); + }); + test('should resolve left and return an error when user already invited', async () => { + mockPrisma.invitedUsers.findFirst.mockResolvedValueOnce(invitedUsers[0]); + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].adminEmail, + invitedUsers[0].inviteeEmail, + ); + return expect(result).toEqualLeft(USER_ALREADY_INVITED); + }); + test('should resolve left and return an error when invitee and admin email is same', async () => { + const result = await adminService.inviteUserToSignInViaEmail( + invitedUsers[0].adminUid, + invitedUsers[0].inviteeEmail, + invitedUsers[0].inviteeEmail, + ); + return expect(result).toEqualLeft(DUPLICATE_EMAIL); + }); + }); + + describe('getUsersCount', () => { + test('should return count of all users in the organization', async () => { + mockUserService.getUsersCount.mockResolvedValueOnce(10); + + const result = await adminService.getUsersCount(); + expect(result).toEqual(10); + }); + }); + + describe('getTeamsCount', () => { + test('should return count of all teams in the organization', async () => { + mockTeamService.getTeamsCount.mockResolvedValueOnce(10); + + const result = await adminService.getTeamsCount(); + expect(result).toEqual(10); + }); + }); + + describe('getTeamCollectionsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockTeamCollectionService.getTeamCollectionsCount.mockResolvedValueOnce( + 10, + ); + + const result = await adminService.getTeamCollectionsCount(); + expect(result).toEqual(10); + }); + }); + + describe('getTeamRequestsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockTeamRequestService.getTeamRequestsCount.mockResolvedValueOnce(10); + + const result = await adminService.getTeamRequestsCount(); + expect(result).toEqual(10); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/admin/admin.service.ts b/packages/hoppscotch-backend/src/admin/admin.service.ts new file mode 100644 index 000000000..514eb3402 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/admin.service.ts @@ -0,0 +1,392 @@ +import { Injectable } from '@nestjs/common'; +import { UserService } from '../user/user.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { PrismaService } from '../prisma/prisma.service'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { validateEmail } from '../utils'; +import { + DUPLICATE_EMAIL, + EMAIL_FAILED, + INVALID_EMAIL, + TEAM_INVITE_ALREADY_MEMBER, + USER_ALREADY_INVITED, + USER_IS_ADMIN, + USER_NOT_FOUND, +} from '../errors'; +import { MailerService } from '../mailer/mailer.service'; +import { InvitedUser } from './invited-user.model'; +import { TeamService } from '../team/team.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { TeamRequestService } from '../team-request/team-request.service'; +import { TeamEnvironmentsService } from '../team-environments/team-environments.service'; +import { TeamInvitationService } from '../team-invitation/team-invitation.service'; +import { TeamMemberRole } from '../team/team.model'; + +@Injectable() +export class AdminService { + constructor( + private readonly userService: UserService, + private readonly teamService: TeamService, + private readonly teamCollectionService: TeamCollectionService, + private readonly teamRequestService: TeamRequestService, + private readonly teamEnvironmentsService: TeamEnvironmentsService, + private readonly teamInvitationService: TeamInvitationService, + private readonly pubsub: PubSubService, + private readonly prisma: PrismaService, + private readonly mailerService: MailerService, + ) {} + + /** + * Fetch all the users in the infra. + * @param cursorID Users uid + * @param take number of users to fetch + * @returns an Either of array of user or error + */ + async fetchUsers(cursorID: string, take: number) { + const allUsers = await this.userService.fetchAllUsers(cursorID, take); + return allUsers; + } + + /** + * Invite a user to join the infra. + * @param adminUID Admin's UID + * @param adminEmail Admin's email + * @param inviteeEmail Invitee's email + * @returns an Either of `InvitedUser` object or error + */ + async inviteUserToSignInViaEmail( + adminUID: string, + adminEmail: string, + inviteeEmail: string, + ) { + if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL); + if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL); + + const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({ + where: { + inviteeEmail: inviteeEmail, + }, + }); + if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED); + + try { + await this.mailerService.sendUserInvitationEmail(inviteeEmail, { + template: 'code-your-own', + variables: { + inviteeEmail: inviteeEmail, + magicLink: `${process.env.APP_DOMAIN}`, + }, + }); + } catch (e) { + return E.left(EMAIL_FAILED); + } + + // Add invitee email to the list of invited users by admin + const dbInvitedUser = await this.prisma.invitedUsers.create({ + data: { + adminUid: adminUID, + adminEmail: adminEmail, + inviteeEmail: inviteeEmail, + }, + }); + + const invitedUser = { + adminEmail: dbInvitedUser.adminEmail, + adminUid: dbInvitedUser.adminUid, + inviteeEmail: dbInvitedUser.inviteeEmail, + invitedOn: dbInvitedUser.invitedOn, + }; + + // Publish invited user subscription + await this.pubsub.publish(`admin/${adminUID}/invited`, invitedUser); + + return E.right(invitedUser); + } + + /** + * Fetch the list of invited users by the admin. + * @returns an Either of array of `InvitedUser` object or error + */ + async fetchInvitedUsers() { + const invitedUsers = await this.prisma.invitedUsers.findMany(); + + const users: InvitedUser[] = invitedUsers.map( + (user) => { ...user }, + ); + + return users; + } + + /** + * Fetch all the teams in the infra. + * @param cursorID team id + * @param take number of items to fetch + * @returns an array of teams + */ + async fetchAllTeams(cursorID: string, take: number) { + const allTeams = await this.teamService.fetchAllTeams(cursorID, take); + return allTeams; + } + + /** + * Fetch the count of all the members in a team. + * @param teamID team id + * @returns a count of team members + */ + async membersCountInTeam(teamID: string) { + const teamMembersCount = await this.teamService.getCountOfMembersInTeam( + teamID, + ); + return teamMembersCount; + } + + /** + * Fetch count of all the collections in a team. + * @param teamID team id + * @returns a of count of collections + */ + async collectionCountInTeam(teamID: string) { + const teamCollectionsCount = + await this.teamCollectionService.totalCollectionsInTeam(teamID); + return teamCollectionsCount; + } + + /** + * Fetch the count of all the requests in a team. + * @param teamID team id + * @returns a count of total requests in a team + */ + async requestCountInTeam(teamID: string) { + const teamRequestsCount = + await this.teamRequestService.totalRequestsInATeam(teamID); + + return teamRequestsCount; + } + + /** + * Fetch the count of all the environments in a team. + * @param teamID team id + * @returns a count of environments in a team + */ + async environmentCountInTeam(teamID: string) { + const envCount = await this.teamEnvironmentsService.totalEnvsInTeam(teamID); + return envCount; + } + + /** + * Fetch all the invitations for a given team. + * @param teamID team id + * @returns an array team invitations + */ + async pendingInvitationCountInTeam(teamID: string) { + const invitations = await this.teamInvitationService.getAllTeamInvitations( + teamID, + ); + + return invitations; + } + + /** + * Change the role of a user in a team + * @param userUid users uid + * @param teamID team id + * @returns an Either of updated `TeamMember` object or error + */ + async changeRoleOfUserTeam( + userUid: string, + teamID: string, + newRole: TeamMemberRole, + ) { + const updatedTeamMember = await this.teamService.updateTeamMemberRole( + teamID, + userUid, + newRole, + ); + + if (E.isLeft(updatedTeamMember)) return E.left(updatedTeamMember.left); + + return E.right(updatedTeamMember.right); + } + + /** + * Remove the user from a team + * @param userUid users uid + * @param teamID team id + * @returns an Either of boolean or error + */ + async removeUserFromTeam(userUid: string, teamID: string) { + const removedUser = await this.teamService.leaveTeam(teamID, userUid); + if (E.isLeft(removedUser)) return E.left(removedUser.left); + + return E.right(removedUser.right); + } + + /** + * Add the user to a team + * @param teamID team id + * @param userEmail users email + * @param role team member role for the user + * @returns an Either of boolean or error + */ + async addUserToTeam(teamID: string, userEmail: string, role: TeamMemberRole) { + if (!validateEmail(userEmail)) return E.left(INVALID_EMAIL); + + const user = await this.userService.findUserByEmail(userEmail); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + const isUserAlreadyMember = await this.teamService.getTeamMemberTE( + teamID, + user.value.uid, + )(); + if (E.left(isUserAlreadyMember)) { + const addedUser = await this.teamService.addMemberToTeamWithEmail( + teamID, + userEmail, + role, + ); + if (E.isLeft(addedUser)) return E.left(addedUser.left); + + return E.right(addedUser.right); + } + + return E.left(TEAM_INVITE_ALREADY_MEMBER); + } + + /** + * Create a new team + * @param userUid user uid + * @param name team name + * @returns an Either of `Team` object or error + */ + async createATeam(userUid: string, name: string) { + const validUser = await this.userService.findUserById(userUid); + if (O.isNone(validUser)) return E.left(USER_NOT_FOUND); + + const createdTeam = await this.teamService.createTeam(name, userUid); + if (E.isLeft(createdTeam)) return E.left(createdTeam.left); + + return E.right(createdTeam.right); + } + + /** + * Renames a team + * @param teamID team ID + * @param newName new team name + * @returns an Either of `Team` object or error + */ + async renameATeam(teamID: string, newName: string) { + const renamedTeam = await this.teamService.renameTeam(teamID, newName); + if (E.isLeft(renamedTeam)) return E.left(renamedTeam.left); + + return E.right(renamedTeam.right); + } + + /** + * Deletes a team + * @param teamID team ID + * @returns an Either of boolean or error + */ + async deleteATeam(teamID: string) { + const deleteTeam = await this.teamService.deleteTeam(teamID); + if (E.isLeft(deleteTeam)) return E.left(deleteTeam.left); + + return E.right(deleteTeam.right); + } + + /** + * Fetch all admin accounts + * @returns an array of admin users + */ + async fetchAdmins() { + const admins = this.userService.fetchAdminUsers(); + return admins; + } + + /** + * Fetch a user by UID + * @param userUid User UID + * @returns an Either of `User` obj or error + */ + async fetchUserInfo(userUid: string) { + const user = await this.userService.findUserById(userUid); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + return E.right(user.value); + } + + /** + * Remove a user account by UID + * @param userUid User UID + * @returns an Either of boolean or error + */ + async removeUserAccount(userUid: string) { + const user = await this.userService.findUserById(userUid); + if (O.isNone(user)) return E.left(USER_NOT_FOUND); + + if (user.value.isAdmin) return E.left(USER_IS_ADMIN); + + const delUser = await this.userService.deleteUserByUID(user.value)(); + if (E.isLeft(delUser)) return E.left(delUser.left); + return E.right(delUser.right); + } + + /** + * Make a user an admin + * @param userUid User UID + * @returns an Either of boolean or error + */ + async makeUserAdmin(userUID: string) { + const admin = await this.userService.makeAdmin(userUID); + if (E.isLeft(admin)) return E.left(admin.left); + return E.right(true); + } + + /** + * Remove user as admin + * @param userUid User UID + * @returns an Either of boolean or error + */ + async removeUserAsAdmin(userUID: string) { + const admin = await this.userService.removeUserAsAdmin(userUID); + if (E.isLeft(admin)) return E.left(admin.left); + return E.right(true); + } + + /** + * Fetch list of all the Users in org + * @returns number of users in the org + */ + async getUsersCount() { + const usersCount = this.userService.getUsersCount(); + return usersCount; + } + + /** + * Fetch list of all the Teams in org + * @returns number of users in the org + */ + async getTeamsCount() { + const teamsCount = this.teamService.getTeamsCount(); + return teamsCount; + } + + /** + * Fetch list of all the Team Collections in org + * @returns number of users in the org + */ + async getTeamCollectionsCount() { + const teamCollectionCount = + this.teamCollectionService.getTeamCollectionsCount(); + return teamCollectionCount; + } + + /** + * Fetch list of all the Team Requests in org + * @returns number of users in the org + */ + async getTeamRequestsCount() { + const teamRequestCount = this.teamRequestService.getTeamRequestsCount(); + return teamRequestCount; + } +} diff --git a/packages/hoppscotch-backend/src/admin/decorators/gql-admin.decorator.ts b/packages/hoppscotch-backend/src/admin/decorators/gql-admin.decorator.ts new file mode 100644 index 000000000..7aafb5534 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/decorators/gql-admin.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +export const GqlAdmin = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req.user; + }, +); diff --git a/packages/hoppscotch-backend/src/admin/guards/gql-admin.guard.ts b/packages/hoppscotch-backend/src/admin/guards/gql-admin.guard.ts new file mode 100644 index 000000000..70e495384 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/guards/gql-admin.guard.ts @@ -0,0 +1,14 @@ +import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +@Injectable() +export class GqlAdminGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const ctx = GqlExecutionContext.create(context); + const { req, headers } = ctx.getContext(); + const request = headers ? headers : req; + const user = request.user; + if (user.isAdmin) return true; + else return false; + } +} diff --git a/packages/hoppscotch-backend/src/admin/input-types.args.ts b/packages/hoppscotch-backend/src/admin/input-types.args.ts new file mode 100644 index 000000000..ab7b91d68 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/input-types.args.ts @@ -0,0 +1,42 @@ +import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { TeamMemberRole } from '../team/team.model'; + +@ArgsType() +export class ChangeUserRoleInTeamArgs { + @Field(() => ID, { + name: 'userUID', + description: 'users UID', + }) + userUID: string; + @Field(() => ID, { + name: 'teamID', + description: 'team ID', + }) + teamID: string; + + @Field(() => TeamMemberRole, { + name: 'newRole', + description: 'updated team role', + }) + newRole: TeamMemberRole; +} +@ArgsType() +export class AddUserToTeamArgs { + @Field(() => ID, { + name: 'teamID', + description: 'team ID', + }) + teamID: string; + + @Field(() => TeamMemberRole, { + name: 'role', + description: 'The role of the user to add in the team', + }) + role: TeamMemberRole; + + @Field({ + name: 'userEmail', + description: 'Email of the user to add to team', + }) + userEmail: string; +} diff --git a/packages/hoppscotch-backend/src/admin/invited-user.model.ts b/packages/hoppscotch-backend/src/admin/invited-user.model.ts new file mode 100644 index 000000000..f61564bb3 --- /dev/null +++ b/packages/hoppscotch-backend/src/admin/invited-user.model.ts @@ -0,0 +1,24 @@ +import { ObjectType, ID, Field } from '@nestjs/graphql'; + +@ObjectType() +export class InvitedUser { + @Field(() => ID, { + description: 'Admin UID', + }) + adminUid: string; + + @Field({ + description: 'Admin email', + }) + adminEmail: string; + + @Field({ + description: 'Invitee email', + }) + inviteeEmail: string; + + @Field({ + description: 'Date when the user invitation was sent', + }) + invitedOn: Date; +} diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index d6289a871..58d20fec1 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -14,6 +14,7 @@ import { TeamEnvironmentsModule } from './team-environments/team-environments.mo import { TeamCollectionModule } from './team-collection/team-collection.module'; import { TeamRequestModule } from './team-request/team-request.module'; import { TeamInvitationModule } from './team-invitation/team-invitation.module'; +import { AdminModule } from './admin/admin.module'; import { UserCollectionModule } from './user-collection/user-collection.module'; import { ShortcodeModule } from './shortcode/shortcode.module'; import { COOKIES_NOT_FOUND } from './errors'; @@ -61,6 +62,7 @@ import { COOKIES_NOT_FOUND } from './errors'; }), UserModule, AuthModule, + AdminModule, UserSettingsModule, UserEnvironmentsModule, UserHistoryModule, diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index c456a27f2..716faf8ed 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -5,6 +5,7 @@ import { HttpException, HttpStatus, Post, + Req, Request, Res, UseGuards, @@ -15,6 +16,7 @@ import { VerifyMagicDto } from './dto/verify-magic.dto'; import { Response } from 'express'; import * as E from 'fp-ts/Either'; import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { AuthUser } from 'src/types/AuthUser'; import { RTCookie } from 'src/decorators/rt-cookie.decorator'; @@ -149,4 +151,12 @@ export class AuthController { res.clearCookie('refresh_token'); return res.status(200).send(); } + + @Get('verify/admin') + @UseGuards(JwtAuthGuard) + async verifyAdmin(@GqlUser() user: AuthUser) { + const userInfo = await this.authService.verifyAdmin(user); + if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left); + return userInfo.right; + } } diff --git a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts index a445634f8..97b1121ae 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts @@ -9,6 +9,7 @@ import { MAGIC_LINK_EXPIRED, VERIFICATION_TOKEN_DATA_NOT_FOUND, USER_NOT_FOUND, + USERS_NOT_FOUND, } from 'src/errors'; import { MailerService } from 'src/mailer/mailer.service'; import { PrismaService } from 'src/prisma/prisma.service'; @@ -364,3 +365,46 @@ describe('refreshAuthTokens', () => { }); }); }); + +describe('verifyAdmin', () => { + test('should successfully elevate user to admin when userCount is 1 ', async () => { + // getUsersCount + mockUser.getUsersCount.mockResolvedValueOnce(1); + // makeAdmin + mockUser.makeAdmin.mockResolvedValueOnce( + E.right({ + ...user, + isAdmin: true, + }), + ); + + const result = await authService.verifyAdmin(user); + expect(result).toEqualRight({ isAdmin: true }); + }); + + test('should return true if user is already an admin', async () => { + const result = await authService.verifyAdmin({ ...user, isAdmin: true }); + expect(result).toEqualRight({ isAdmin: true }); + }); + + test('should throw USERS_NOT_FOUND when userUid is invalid', async () => { + // getUsersCount + mockUser.getUsersCount.mockResolvedValueOnce(1); + // makeAdmin + mockUser.makeAdmin.mockResolvedValueOnce(E.left(USER_NOT_FOUND)); + + const result = await authService.verifyAdmin(user); + expect(result).toEqualLeft({ + message: USER_NOT_FOUND, + statusCode: HttpStatus.NOT_FOUND, + }); + }); + + test('should return false when user is not an admin and userCount is greater than 1', async () => { + // getUsersCount + mockUser.getUsersCount.mockResolvedValueOnce(13); + + const result = await authService.verifyAdmin(user); + expect(result).toEqualRight({ isAdmin: false }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts index 44b3e6396..04d2f1722 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -1,7 +1,6 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { MailerService } from 'src/mailer/mailer.service'; import { PrismaService } from 'src/prisma/prisma.service'; -import { User } from 'src/user/user.model'; import { UserService } from 'src/user/user.service'; import { VerifyMagicDto } from './dto/verify-magic.dto'; import { DateTime } from 'luxon'; @@ -26,7 +25,7 @@ import { } from 'src/types/AuthTokens'; import { JwtService } from '@nestjs/jwt'; import { AuthError } from 'src/types/AuthError'; -import { AuthUser } from 'src/types/AuthUser'; +import { AuthUser, IsAdmin } from 'src/types/AuthUser'; import { VerificationToken } from '@prisma/client'; @Injectable() @@ -339,4 +338,28 @@ export class AuthService { return E.right(generatedAuthTokens.right); } + + /** + * Verify is signed in User is an admin or not + * + * @param user User Object + * @returns Either of boolean if user is admin or not + */ + async verifyAdmin(user: AuthUser) { + if (user.isAdmin) return E.right({ isAdmin: true }); + + const usersCount = await this.usersService.getUsersCount(); + if (usersCount === 1) { + const elevatedUser = await this.usersService.makeAdmin(user.uid); + if (E.isLeft(elevatedUser)) + return E.left({ + message: elevatedUser.left, + statusCode: HttpStatus.NOT_FOUND, + }); + + return E.right({ isAdmin: true }); + } + + return E.right({ isAdmin: false }); + } } diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 67a413b4f..92540176c 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -1,6 +1,7 @@ export const INVALID_EMAIL = 'invalid/email' as const; export const EMAIL_FAILED = 'email/failed' as const; +export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const; /** * Token Authorization failed (Check 'Authorization' Header) @@ -26,6 +27,11 @@ export const USER_FB_DOCUMENT_DELETION_FAILED = */ export const USER_NOT_FOUND = 'user/not_found' as const; +/** + * User is already invited by admin + */ +export const USER_ALREADY_INVITED = 'admin/user_already_invited' as const; + /** * User update failure * (UserService) @@ -38,11 +44,28 @@ export const USER_UPDATE_FAILED = 'user/update_failed' as const; */ export const USER_DELETION_FAILED = 'user/deletion_failed' as const; +/** + * Users not found + * (UserService) + */ +export const USERS_NOT_FOUND = 'user/users_not_found' as const; + /** * User deletion failure error due to user being a team owner * (UserService) */ export const USER_IS_OWNER = 'user/is_owner' as const; +/** + * User deletion failure error due to user being an admin + * (UserService) + */ +export const USER_IS_ADMIN = 'user/is_admin' as const; + +/** + * Teams not found + * (TeamsService) + */ +export const TEAMS_NOT_FOUND = 'user/teams_not_found' as const; /** * Tried to find user collection but failed @@ -251,6 +274,13 @@ export const TEAM_INVITE_EMAIL_DO_NOT_MATCH = export const TEAM_INVITE_NOT_VALID_VIEWER = 'team_invite/not_valid_viewer' as const; +/** + * No team invitations found + * (TeamInvitationService) + */ +export const TEAM_INVITATION_NOT_FOUND = + 'team_invite/invitations_not_found' as const; + /** * ShortCode not found in DB * (ShortcodeService) diff --git a/packages/hoppscotch-backend/src/gql-schema.ts b/packages/hoppscotch-backend/src/gql-schema.ts index eb4879a6a..23d820e1b 100644 --- a/packages/hoppscotch-backend/src/gql-schema.ts +++ b/packages/hoppscotch-backend/src/gql-schema.ts @@ -20,6 +20,7 @@ import { UserRequestResolver } from './user-request/resolvers/user-request.resol import { UserSettingsResolver } from './user-settings/user-settings.resolver'; import { UserResolver } from './user/user.resolver'; import { Logger } from '@nestjs/common'; +import { AdminResolver } from './admin/admin.resolver'; /** * All the resolvers present in the application. @@ -27,6 +28,7 @@ import { Logger } from '@nestjs/common'; * NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate */ const RESOLVERS = [ + AdminResolver, ShortcodeResolver, TeamResolver, TeamMemberResolver, diff --git a/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts index 4178bb82a..864b01c79 100644 --- a/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts +++ b/packages/hoppscotch-backend/src/mailer/MailDescriptions.ts @@ -14,3 +14,11 @@ export type UserMagicLinkMailDescription = { magicLink: string; }; }; + +export type AdminUserInvitationMailDescription = { + template: 'code-your-own'; + variables: { + inviteeEmail: string; + magicLink: string; + }; +}; diff --git a/packages/hoppscotch-backend/src/mailer/mailer.service.ts b/packages/hoppscotch-backend/src/mailer/mailer.service.ts index 60f7744e7..8a5b61075 100644 --- a/packages/hoppscotch-backend/src/mailer/mailer.service.ts +++ b/packages/hoppscotch-backend/src/mailer/mailer.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { + AdminUserInvitationMailDescription, MailDescription, UserMagicLinkMailDescription, } from './MailDescriptions'; @@ -18,7 +19,10 @@ export class MailerService { * @returns The subject of the email */ private resolveSubjectForMailDesc( - mailDesc: MailDescription | UserMagicLinkMailDescription, + mailDesc: + | MailDescription + | UserMagicLinkMailDescription + | AdminUserInvitationMailDescription, ): string { switch (mailDesc.template) { case 'team-invitation': @@ -69,4 +73,27 @@ export class MailerService { return throwErr(EMAIL_FAILED); } } + + /** + * + * @param to Receiver's email id + * @param mailDesc Details of email to be sent for user invitation + * @returns Response if email was send successfully or not + */ + async sendUserInvitationEmail( + to: string, + mailDesc: AdminUserInvitationMailDescription, + ) { + try { + const res = await this.nestMailerService.sendMail({ + to, + template: mailDesc.template, + subject: this.resolveSubjectForMailDesc(mailDesc), + context: mailDesc.variables, + }); + return res; + } catch (error) { + return throwErr(EMAIL_FAILED); + } + } } diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index 70330395a..ad085e728 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -17,6 +17,7 @@ import { TeamRequest, } from 'src/team-request/team-request.model'; import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; +import { InvitedUser } from '../admin/invited-user.model'; import { UserCollection } from '@prisma/client'; import { UserCollectionReorderData } from 'src/user-collection/user-collections.model'; import { Shortcode } from 'src/shortcode/shortcode.model'; @@ -24,7 +25,8 @@ import { Shortcode } from 'src/shortcode/shortcode.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. export type TopicDef = { - [topic: `user/${string}/${'updated'}`]: User; + [topic: `admin/${string}/${'invited'}`]: InvitedUser; + [topic: `user/${string}/${'updated' | 'deleted'}`]: User; [topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings; [ topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}` diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts index e8bcb169f..ecd2ad7b5 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts @@ -9,7 +9,6 @@ import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND, } from 'src/errors'; -import { User } from 'src/user/user.model'; import { UserDataHandler } from 'src/user/user.data.handler'; import { Shortcode } from './shortcode.model'; import { Shortcode as DBShortCode } from '@prisma/client'; @@ -17,6 +16,7 @@ import { PubSubService } from 'src/pubsub/pubsub.service'; import { UserService } from 'src/user/user.service'; import { stringToJson } from 'src/utils'; import { PaginationArgs } from 'src/types/input-types.args'; +import { AuthUser } from '../types/AuthUser'; const SHORT_CODE_LENGTH = 12; const SHORT_CODE_CHARS = @@ -34,13 +34,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { this.userService.registerUserDataHandler(this); } - canAllowUserDeletion(user: User): TO.TaskOption { + canAllowUserDeletion(user: AuthUser): TO.TaskOption { return TO.none; } - onUserDelete(user: User): T.Task { - // return this.deleteUserShortcodes(user.uid); - return undefined; + onUserDelete(user: AuthUser): T.Task { + return async () => { + await this.deleteUserShortCodes(user.uid); + }; } /** @@ -195,8 +196,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { } /** - * Delete all of Users ShortCodes - * + * Delete all the Users ShortCodes * @param uid User Uid * @returns number of all deleted user ShortCodes */ diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts index 07d53665a..c9367b0f0 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts @@ -24,6 +24,7 @@ import * as E from 'fp-ts/Either'; const mockPrisma = mockDeep(); const mockPubSub = mockDeep(); +// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const teamCollectionService = new TeamCollectionService( mockPrisma, @@ -1428,4 +1429,36 @@ describe('replaceCollectionsWithJSON', () => { }); }); +describe('totalCollectionsInTeam', () => { + test('should resolve right and return a total team colls count ', async () => { + mockPrisma.teamCollection.count.mockResolvedValueOnce(2); + const result = await teamCollectionService.totalCollectionsInTeam('id1'); + expect(mockPrisma.teamCollection.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team colls found', async () => { + mockPrisma.teamCollection.count.mockResolvedValueOnce(0); + const result = await teamCollectionService.totalCollectionsInTeam('id1'); + expect(mockPrisma.teamCollection.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(0); + }); + + describe('getTeamCollectionsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockPrisma.teamCollection.count.mockResolvedValueOnce(10); + + const result = await teamCollectionService.getTeamCollectionsCount(); + expect(result).toEqual(10); + }); + }); +}); + //ToDo: write test cases for exportCollectionsToJSON diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index 2f5780aa1..7fbff36e3 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -613,13 +613,6 @@ export class TeamCollectionService { }, }); - // // Update orderIndexes in TeamCollection table for user - // await this.updateOrderIndex( - // collection.parentID, - // { gt: collection.orderIndex }, - // { decrement: 1 }, - // ); - // Delete collection from TeamCollection table const deletedTeamCollection = await this.removeTeamCollection( collection.id, @@ -956,6 +949,31 @@ export class TeamCollectionService { } } + /** + * Fetch list of all the Team Collections in DB for a particular team + * @param teamID Team ID + * @returns number of Team Collections in the DB + */ + async totalCollectionsInTeam(teamID: string) { + const collCount = await this.prisma.teamCollection.count({ + where: { + teamID: teamID, + }, + }); + + return collCount; + } + + /** + * Fetch list of all the Team Collections in DB + * + * @returns number of Team Collections in the DB + */ + async getTeamCollectionsCount() { + const teamCollectionsCount = this.prisma.teamCollection.count(); + return teamCollectionsCount; + } + // async importCollectionFromFirestore( // userUid: string, // fbCollectionPath: string, diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts index 8837094f8..742d4ca3e 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts @@ -2,7 +2,7 @@ import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from 'src/prisma/prisma.service'; import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironmentsService } from './team-environments.service'; -import { TEAM_ENVIRONMENT_NOT_FOUND, TEAM_MEMBER_NOT_FOUND } from 'src/errors'; +import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; const mockPrisma = mockDeep(); @@ -400,4 +400,27 @@ describe('TeamEnvironmentsService', () => { ); }); }); + + describe('totalEnvsInTeam', () => { + test('should resolve right and return a total team envs count ', async () => { + mockPrisma.teamEnvironment.count.mockResolvedValueOnce(2); + const result = await teamEnvironmentsService.totalEnvsInTeam('id1'); + expect(mockPrisma.teamEnvironment.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team envs found', async () => { + mockPrisma.teamEnvironment.count.mockResolvedValueOnce(0); + const result = await teamEnvironmentsService.totalEnvsInTeam('id1'); + expect(mockPrisma.teamEnvironment.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(0); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts index 582757d6c..49e3d7bb1 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts @@ -231,4 +231,18 @@ export class TeamEnvironmentsService { ), ); } + + /** + * Fetch the count of environments for a given team. + * @param teamID team id + * @returns a count of team envs + */ + async totalEnvsInTeam(teamID: string) { + const envCount = await this.prisma.teamEnvironment.count({ + where: { + teamID: teamID, + }, + }); + return envCount; + } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts index 6dbf8ae65..2f35b328c 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts @@ -3,6 +3,7 @@ import * as T from 'fp-ts/Task'; import * as O from 'fp-ts/Option'; import * as TO from 'fp-ts/TaskOption'; import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; import { pipe, flow, constVoid } from 'fp-ts/function'; import { PrismaService } from 'src/prisma/prisma.service'; import { Team, TeamMemberRole } from 'src/team/team.model'; @@ -10,6 +11,7 @@ import { Email } from 'src/types/Email'; import { User } from 'src/user/user.model'; import { TeamService } from 'src/team/team.service'; import { + TEAM_INVITATION_NOT_FOUND, TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_EMAIL_DO_NOT_MATCH, TEAM_INVITE_MEMBER_HAS_INVITE, @@ -255,4 +257,19 @@ export class TeamInvitationService { TE.map(({ teamMember }) => teamMember), ); } + + /** + * Fetch the count invitations for a given team. + * @param teamID team id + * @returns a count team invitations for a team + */ + async getAllTeamInvitations(teamID: string) { + const invitations = await this.prisma.teamInvitation.findMany({ + where: { + teamID: teamID, + }, + }); + + return invitations; + } } diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts index d86ed6be9..377f1beb3 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts @@ -10,7 +10,6 @@ import { TEAM_REQ_REORDERING_FAILED, } from 'src/errors'; import * as E from 'fp-ts/Either'; -import * as O from 'fp-ts/Option'; import { mockDeep, mockReset } from 'jest-mock-extended'; import { TeamRequest } from './team-request.model'; import { MoveTeamRequestArgs } from './input-type.args'; @@ -691,3 +690,34 @@ describe('moveRequest', () => { ).resolves.toEqualLeft(TEAM_REQ_REORDERING_FAILED); }); }); +describe('totalRequestsInATeam', () => { + test('should resolve right and return a total team reqs count ', async () => { + mockPrisma.teamRequest.count.mockResolvedValueOnce(2); + const result = await teamRequestService.totalRequestsInATeam('id1'); + expect(mockPrisma.teamRequest.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team reqs found', async () => { + mockPrisma.teamRequest.count.mockResolvedValueOnce(0); + const result = await teamRequestService.totalRequestsInATeam('id1'); + expect(mockPrisma.teamRequest.count).toHaveBeenCalledWith({ + where: { + teamID: 'id1', + }, + }); + expect(result).toEqual(0); + }); + + describe('getTeamRequestsCount', () => { + test('should return count of all Team Collections in the organization', async () => { + mockPrisma.teamRequest.count.mockResolvedValueOnce(10); + + const result = await teamRequestService.getTeamRequestsCount(); + expect(result).toEqual(10); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts index d03582f7b..3e48ea589 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -83,7 +83,7 @@ export class TeamRequestService { teamID: string, searchTerm: string, cursor: string, - take: number = 10, + take = 10, ) { const fetchedRequests = await this.prisma.teamRequest.findMany({ take: take, @@ -183,7 +183,7 @@ export class TeamRequestService { async getRequestsInCollection( collectionID: string, cursor: string, - take: number = 10, + take = 10, ) { const dbTeamRequests = await this.prisma.teamRequest.findMany({ cursor: cursor ? { id: cursor } : undefined, @@ -424,4 +424,28 @@ export class TeamRequestService { return E.left(TEAM_REQ_REORDERING_FAILED); } } + + /** + * Return count of total requests in a team + * @param teamID team ID + */ + async totalRequestsInATeam(teamID: string) { + const requestsCount = await this.prisma.teamRequest.count({ + where: { + teamID: teamID, + }, + }); + + return requestsCount; + } + + /** + * Fetch list of all the Team Requests in DB + * + * @returns number of Team Requests in the DB + */ + async getTeamRequestsCount() { + const teamRequestsCount = this.prisma.teamRequest.count(); + return teamRequestsCount; + } } diff --git a/packages/hoppscotch-backend/src/team/team.service.spec.ts b/packages/hoppscotch-backend/src/team/team.service.spec.ts index 7096dc681..6da64f0ca 100644 --- a/packages/hoppscotch-backend/src/team/team.service.spec.ts +++ b/packages/hoppscotch-backend/src/team/team.service.spec.ts @@ -37,6 +37,17 @@ const team: Team = { id: 'teamID', name: 'teamName', }; + +const teams: Team[] = [ + { + id: 'teamID', + name: 'teamName', + }, + { + id: 'teamID2', + name: 'teamName2', + }, +]; const dbTeamMember: DbTeamMember = { id: 'teamMemberID', role: TeamMemberRole.VIEWER, @@ -51,6 +62,8 @@ const teamMember: TeamMember = { describe('getCountOfUsersWithRoleInTeam', () => { test('resolves to the correct count of owners in a team', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mockPrisma.teamMember.count.mockResolvedValue(2); await expect( @@ -234,6 +247,8 @@ describe('addMemberToTeamWithEmail', () => { describe('deleteTeam', () => { test('resolves for proper deletion', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mockPrisma.team.findUnique.mockResolvedValue(team); mockPrisma.teamMember.deleteMany.mockResolvedValue({ count: 10, @@ -918,3 +933,56 @@ describe('deleteUserFromAllTeams', () => { }); }); }); + +describe('fetchAllTeams', () => { + test('should resolve right and return 20 teams when cursor is null', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce(teams); + + const result = await teamService.fetchAllTeams(null, 20); + expect(result).toEqual(teams); + }); + test('should resolve right and return next 20 teams when cursor is provided', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce(teams); + + const result = await teamService.fetchAllTeams('teamID', 20); + expect(result).toEqual(teams); + }); + test('should resolve left and return an error when users not found', async () => { + mockPrisma.team.findMany.mockResolvedValueOnce([]); + + const result = await teamService.fetchAllTeams(null, 20); + expect(result).toEqual([]); + }); +}); + +describe('getCountOfMembersInTeam', () => { + test('should resolve right and return a total team member count ', async () => { + mockPrisma.teamMember.count.mockResolvedValueOnce(2); + const result = await teamService.getCountOfMembersInTeam(team.id); + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: team.id, + }, + }); + expect(result).toEqual(2); + }); + test('should resolve left and return an error when no team members found', async () => { + mockPrisma.teamMember.count.mockResolvedValueOnce(0); + const result = await teamService.getCountOfMembersInTeam(team.id); + expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({ + where: { + teamID: team.id, + }, + }); + expect(result).toEqual(0); + }); + + describe('getTeamsCount', () => { + test('should return count of all teams in the organization', async () => { + mockPrisma.team.count.mockResolvedValueOnce(10); + + const result = await teamService.getTeamsCount(); + expect(result).toEqual(10); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team/team.service.ts b/packages/hoppscotch-backend/src/team/team.service.ts index 80d239661..9d2c5c953 100644 --- a/packages/hoppscotch-backend/src/team/team.service.ts +++ b/packages/hoppscotch-backend/src/team/team.service.ts @@ -4,7 +4,6 @@ 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, @@ -13,6 +12,7 @@ import { TEAM_INVALID_ID_OR_USER, TEAM_MEMBER_NOT_FOUND, USER_IS_OWNER, + TEAMS_NOT_FOUND, } from '../errors'; import { PubSubService } from '../pubsub/pubsub.service'; import { flow, pipe } from 'fp-ts/function'; @@ -23,6 +23,7 @@ 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'; +import { AuthUser } from '../types/AuthUser'; @Injectable() export class TeamService implements UserDataHandler, OnModuleInit { @@ -36,7 +37,7 @@ export class TeamService implements UserDataHandler, OnModuleInit { this.userService.registerUserDataHandler(this); } - canAllowUserDeletion(user: User): TO.TaskOption { + canAllowUserDeletion(user: AuthUser): TO.TaskOption { return pipe( this.isUserOwnerRoleInTeams(user.uid), TO.fromTask, @@ -44,7 +45,7 @@ export class TeamService implements UserDataHandler, OnModuleInit { ); } - onUserDelete(user: User): T.Task { + onUserDelete(user: AuthUser): T.Task { return this.deleteUserFromAllTeams(user.uid); } @@ -452,6 +453,21 @@ export class TeamService implements UserDataHandler, OnModuleInit { return this.filterMismatchedUsers(teamID, members); } + /** + * Get a count of members in a team + * @param teamID Team ID + * @returns a count of members in a team + */ + async getCountOfMembersInTeam(teamID: string) { + const memberCount = await this.prisma.teamMember.count({ + where: { + teamID: teamID, + }, + }); + + return memberCount; + } + async getMembersOfTeam( teamID: string, cursor: string | null, @@ -489,4 +505,31 @@ export class TeamService implements UserDataHandler, OnModuleInit { return this.filterMismatchedUsers(teamID, members); } + + /** + * Fetch all the teams in the `Team` table based on cursor + * @param cursorID string of teamID or undefined + * @param take number of items to query + * @returns an array of `Team` object + */ + async fetchAllTeams(cursorID: string, take: number) { + const options = { + skip: cursorID ? 1 : 0, + take: take, + cursor: cursorID ? { id: cursorID } : undefined, + }; + + const fetchedTeams = await this.prisma.team.findMany(options); + return fetchedTeams; + } + + /** + * Fetch list of all the Teams in the DB + * + * @returns number of teams in the org + */ + async getTeamsCount() { + const teamsCount = await this.prisma.team.count(); + return teamsCount; + } } diff --git a/packages/hoppscotch-backend/src/types/AuthUser.ts b/packages/hoppscotch-backend/src/types/AuthUser.ts index ee67c4fb8..1332adbb3 100644 --- a/packages/hoppscotch-backend/src/types/AuthUser.ts +++ b/packages/hoppscotch-backend/src/types/AuthUser.ts @@ -6,3 +6,7 @@ export interface SSOProviderProfile { provider: string; id: string; } + +export type IsAdmin = { + isAdmin: boolean; +}; diff --git a/packages/hoppscotch-backend/src/user/user.data.handler.ts b/packages/hoppscotch-backend/src/user/user.data.handler.ts index eee0b75c9..ce621571b 100644 --- a/packages/hoppscotch-backend/src/user/user.data.handler.ts +++ b/packages/hoppscotch-backend/src/user/user.data.handler.ts @@ -1,11 +1,11 @@ -import * as T from "fp-ts/Task" -import * as TO from "fp-ts/TaskOption" -import { User } from "src/user/user.model" +import * as T from 'fp-ts/Task'; +import * as TO from 'fp-ts/TaskOption'; +import { AuthUser } from '../types/AuthUser'; /** * 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 + canAllowUserDeletion: (user: AuthUser) => TO.TaskOption; + onUserDelete: (user: AuthUser) => T.Task; +} diff --git a/packages/hoppscotch-backend/src/user/user.resolver.ts b/packages/hoppscotch-backend/src/user/user.resolver.ts index 803377d41..120c80221 100644 --- a/packages/hoppscotch-backend/src/user/user.resolver.ts +++ b/packages/hoppscotch-backend/src/user/user.resolver.ts @@ -6,6 +6,8 @@ import { GqlUser } from '../decorators/gql-user.decorator'; import { UserService } from './user.service'; import { throwErr } from 'src/utils'; import * as E from 'fp-ts/lib/Either'; +import * as TE from 'fp-ts/TaskEither'; +import { pipe } from 'fp-ts/function'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { AuthUser } from 'src/types/AuthUser'; @@ -52,6 +54,18 @@ export class UserResolver { if (E.isLeft(updatedUser)) throwErr(updatedUser.left); return updatedUser.right; } + @Mutation(() => Boolean, { + description: 'Delete an user account', + }) + @UseGuards(GqlAuthGuard) + deleteUser(@GqlUser() user: AuthUser): Promise { + return pipe( + this.userService.deleteUserByUID(user), + TE.map(() => true), + TE.mapLeft((message) => message.toString()), + TE.getOrElse(throwErr), + )(); + } /* Subscriptions */ @@ -63,4 +77,13 @@ export class UserResolver { userUpdated(@GqlUser() user: User) { return this.pubsub.asyncIterator(`user/${user.uid}/updated`); } + + @Subscription(() => User, { + description: 'Listen for user deletion', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userDeleted(@GqlUser() user: User): AsyncIterator { + return this.pubsub.asyncIterator(`user/${user.uid}/deleted`); + } } diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts index a85830094..2545c4611 100644 --- a/packages/hoppscotch-backend/src/user/user.service.spec.ts +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -1,13 +1,26 @@ -import { JSON_INVALID } from 'src/errors'; +import { JSON_INVALID, USER_NOT_FOUND } from 'src/errors'; import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from 'src/prisma/prisma.service'; import { AuthUser } from 'src/types/AuthUser'; import { User } from './user.model'; import { UserService } from './user.service'; import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as TO from 'fp-ts/TaskOption'; +import * as T from 'fp-ts/Task'; const mockPrisma = mockDeep(); const mockPubSub = mockDeep(); +let service: UserService; + +const handler1 = { + canAllowUserDeletion: jest.fn(), + onUserDelete: jest.fn(), +}; + +const handler2 = { + canAllowUserDeletion: jest.fn(), + onUserDelete: jest.fn(), +}; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -27,6 +40,90 @@ const user: AuthUser = { createdOn: currentTime, }; +const adminUser: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, +}; + +const users: AuthUser[] = [ + { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + }, + { + uid: '5555', + email: 'abc@dundermifflin.com', + displayName: 'abc Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + }, + { + uid: '6666', + email: 'def@dundermifflin.com', + displayName: 'def Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + }, +]; + +const adminUsers: AuthUser[] = [ + { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + }, + { + uid: '5555', + email: 'abc@dundermifflin.com', + displayName: 'abc Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + }, + { + uid: '6666', + email: 'def@dundermifflin.com', + displayName: 'def Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: true, + currentRESTSession: {}, + currentGQLSession: {}, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + }, +]; + const exampleSSOProfileData = { id: '123rfedvd', emails: [{ value: 'dwight@dundermifflin.com' }], @@ -38,6 +135,10 @@ const exampleSSOProfileData = { beforeEach(() => { mockReset(mockPrisma); mockPubSub.publish.mockClear(); + service = new UserService(mockPrisma, mockPubSub as any); + + service.registerUserDataHandler(handler1); + service.registerUserDataHandler(handler2); }); describe('UserService', () => { @@ -312,4 +413,147 @@ describe('UserService', () => { ); }); }); + + describe('fetchAllUsers', () => { + test('should resolve right and return 20 users when cursor is null', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.fetchAllUsers(null, 20); + expect(result).toEqual(users); + }); + test('should resolve right and return next 20 users when cursor is provided', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(users); + + const result = await userService.fetchAllUsers('123344', 20); + expect(result).toEqual(users); + }); + test('should resolve left and return an error when users not found', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce([]); + + const result = await userService.fetchAllUsers(null, 20); + expect(result).toEqual([]); + }); + }); + + describe('fetchAdminUsers', () => { + test('should return a list of admin users', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers); + const result = await userService.fetchAdminUsers(); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + isAdmin: true, + }, + }); + expect(result).toEqual(adminUsers); + }); + test('should return null when no admin users found', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce(null); + const result = await userService.fetchAdminUsers(); + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + isAdmin: true, + }, + }); + expect(result).toEqual(null); + }); + }); + + describe('makeAdmin', () => { + test('should resolve right and return a user object after making a user admin', async () => { + mockPrisma.user.update.mockResolvedValueOnce(adminUser); + const result = await userService.makeAdmin(user.uid); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { + uid: user.uid, + }, + data: { + isAdmin: true, + }, + }); + expect(result).toEqualRight(adminUser); + }); + test('should resolve left and error when invalid user uid is passed', async () => { + mockPrisma.user.update.mockRejectedValueOnce('NotFoundError'); + const result = await userService.makeAdmin(user.uid); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { + uid: user.uid, + }, + data: { + isAdmin: true, + }, + }); + expect(result).toEqualLeft(USER_NOT_FOUND); + }); + }); + + describe('deleteUserByID', () => { + test('should resolve right for valid user uid and perform successful user deletion', () => { + // For a successful deletion, the handlers should allow user deletion + handler1.canAllowUserDeletion.mockImplementation(() => TO.none); + handler2.canAllowUserDeletion.mockImplementation(() => TO.none); + handler1.onUserDelete.mockImplementation(() => T.of(undefined)); + handler2.onUserDelete.mockImplementation(() => T.of(undefined)); + mockPrisma.user.delete.mockResolvedValueOnce(user); + + const result = service.deleteUserByUID(user)(); + return expect(result).resolves.toBeRight(); + }); + test('should resolve right for successful deletion and publish user deleted subscription', async () => { + // For a successful deletion, the handlers should allow user deletion + handler1.canAllowUserDeletion.mockImplementation(() => TO.none); + handler2.canAllowUserDeletion.mockImplementation(() => TO.none); + handler1.onUserDelete.mockImplementation(() => T.of(undefined)); + handler2.onUserDelete.mockImplementation(() => T.of(undefined)); + + mockPrisma.user.delete.mockResolvedValueOnce(user); + const result = service.deleteUserByUID(user)(); + await expect(result).resolves.toBeRight(); + + // fire the subscription for user deletion + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user/${user.uid}/deleted`, + { + uid: user.uid, + displayName: user.displayName, + email: user.email, + photoURL: user.photoURL, + isAdmin: user.isAdmin, + currentRESTSession: user.currentRESTSession, + currentGQLSession: user.currentGQLSession, + createdOn: user.createdOn, + }, + ); + }); + test("should resolve left when one or both the handlers don't allow userDeletion", () => { + // Handlers don't allow user deletion + handler1.canAllowUserDeletion.mockImplementation(() => TO.some); + handler2.canAllowUserDeletion.mockImplementation(() => TO.some); + + const result = service.deleteUserByUID(user)(); + return expect(result).resolves.toBeLeft(); + }); + test('should resolve left when ther is an unsuccessful deletion of userdata from firestore', () => { + // Handlers allow deletion to proceed + handler1.canAllowUserDeletion.mockImplementation(() => TO.none); + handler2.canAllowUserDeletion.mockImplementation(() => TO.none); + handler1.onUserDelete.mockImplementation(() => T.of(undefined)); + handler2.onUserDelete.mockImplementation(() => T.of(undefined)); + + // Deleting users errors out + mockPrisma.user.delete.mockRejectedValueOnce('NotFoundError'); + + const result = service.deleteUserByUID(user)(); + return expect(result).resolves.toBeLeft(); + }); + }); + + describe('getUsersCount', () => { + test('should return count of all users in the organization', async () => { + mockPrisma.user.count.mockResolvedValueOnce(10); + + const result = await userService.getUsersCount(); + expect(result).toEqual(10); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index a758e79f3..edfe8e239 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -2,12 +2,17 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; +import * as TO from 'fp-ts/TaskOption'; +import * as TE from 'fp-ts/TaskEither'; +import * as T from 'fp-ts/Task'; +import * as A from 'fp-ts/Array'; +import { pipe, constVoid } from 'fp-ts/function'; import { AuthUser } from 'src/types/AuthUser'; -import { USER_NOT_FOUND } from 'src/errors'; +import { USER_NOT_FOUND, USERS_NOT_FOUND } from 'src/errors'; 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 { stringToJson, taskEitherValidateArraySeq } from 'src/utils'; import { UserDataHandler } from './user.data.handler'; @Injectable() @@ -261,4 +266,168 @@ export class UserService { return E.right(jsonSession.right); } + + /** + * Fetch all the users in the `User` table based on cursor + * @param cursorID string of userUID or null + * @param take number of users to query + * @returns an array of `User` object + */ + async fetchAllUsers(cursorID: string, take: number) { + const fetchedUsers = await this.prisma.user.findMany({ + skip: cursorID ? 1 : 0, + take: take, + cursor: cursorID ? { uid: cursorID } : undefined, + }); + return fetchedUsers; + } + + /** + * Fetch the number of users in db + * @returns a count (Int) of user records in DB + */ + async getUsersCount() { + const usersCount = await this.prisma.user.count(); + return usersCount; + } + + /** + * Change a user to an admin by toggling isAdmin param to true + * @param userUID user UID + * @returns a Either of `User` object or error + */ + async makeAdmin(userUID: string) { + try { + const elevatedUser = await this.prisma.user.update({ + where: { + uid: userUID, + }, + data: { + isAdmin: true, + }, + }); + return E.right(elevatedUser); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Fetch all the admin users + * @returns an array of admin users + */ + async fetchAdminUsers() { + const admins = this.prisma.user.findMany({ + where: { + isAdmin: true, + }, + }); + + return admins; + } + + /** + * Deletes a user account by UID + * @param uid User UID + * @returns an Either of string or boolean + */ + async deleteUserAccount(uid: string) { + try { + await this.prisma.user.delete({ + where: { + uid: uid, + }, + }); + return E.right(true); + } catch (e) { + return E.left(USER_NOT_FOUND); + } + } + + /** + * Get user deletion error messages when the data handlers are initialised in respective modules + * @param user User Object + * @returns an TaskOption of string array + */ + getUserDeletionErrors(user: AuthUser): TO.TaskOption { + return pipe( + this.userDataHandlers, + A.map((handler) => + pipe( + handler.canAllowUserDeletion(user), + TO.matchE( + () => TE.right(undefined), + (error) => TE.left(error), + ), + ), + ), + taskEitherValidateArraySeq, + TE.matchE( + (e) => TO.some(e), + () => TO.none, + ), + ); + } + + /** + * Deletes a user by UID + * @param user User Object + * @returns an TaskEither of string or boolean + */ + deleteUserByUID(user: AuthUser) { + return pipe( + this.getUserDeletionErrors(user), + TO.matchEW( + () => + pipe( + this.userDataHandlers, + A.map((handler) => handler.onUserDelete(user)), + T.sequenceArray, + T.map(constVoid), + TE.fromTask, + ) as TE.TaskEither, + (errors): TE.TaskEither => TE.left(errors), + ), + + TE.chainW(() => () => this.deleteUserAccount(user.uid)), + + TE.chainFirst(() => + TE.fromTask(() => + this.pubsub.publish(`user/${user.uid}/deleted`, { + uid: user.uid, + displayName: user.displayName, + email: user.email, + photoURL: user.photoURL, + isAdmin: user.isAdmin, + createdOn: user.createdOn, + currentGQLSession: user.currentGQLSession, + currentRESTSession: user.currentRESTSession, + }), + ), + ), + + TE.mapLeft((errors) => errors.toString()), + ); + } + + /** + * Change the user from an admin by toggling isAdmin param to false + * @param userUID user UID + * @returns a Either of `User` object or error + */ + async removeUserAsAdmin(userUID: string) { + try { + const user = await this.prisma.user.update({ + where: { + uid: userUID, + }, + data: { + isAdmin: false, + }, + }); + return E.right(user); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } }