feat: Introducing Admin Module to Backend (HBE-83) (#21)
* feat: introducing admin module, resolvers and service files as a module * feat: adding admin module in the app module * feat: introducing admin guard and decorator for allowing admin operations * feat: invited user model * chore: added user invitation mail description to mailer service * chore: added admin and user related error * feat: added invited users as a new model in prisma * chore: added admin related topics to pubsub * chore: added service method to fetch all users from user table * chore: added user deletion base implementation * Revert "chore: added user deletion base implementation" This reverts commit d1615ad83db2bae946e2d366a903d2f95051dabb. * feat: adding team related operations to admin * chore: adding admin related service methods to teams module service * chore: adding admin related service methods to team coll invitations requests envs * chore: added more module error messages * chore: added admin check service method * chore: added find individual user by UID in admin * HBE-106 feat: introduced code to handle first time admin login setup (#23) * test: wrote test cases for verifyAdmin route service method * chore: added comments to verifyAdmin service method * chore: deleted the prisma migration file * chore: added find admin users * feat: added user deletion into admin module * chore: admin user related errors * chore: fixed registry pattern in the shortcodes and teams to handle user deletion * chore: add subscription topic for user deletion * chore: updated user type in data handler * feat: implement and fix user deletion * feat: added make user admin mutation * chore: added unit tests for admin specific service methods in admin module * chore: added invitation not found error * chore: added admin specific operation test cases in specific modules * chore: added tests related to user deletion and admin related operation in user module * chore: updated to error constant when invitations not found * chore: fix rebase overwritten methods * feat: implement remove user as admin * chore: add new line * feat: introducing basic metrics into the self-hosted admin module (HBE-104) (#43) * feat: introducing admin module, resolvers and service files as a module * feat: adding admin module in the app module * feat: introducing admin guard and decorator for allowing admin operations * feat: invited user model * chore: added user invitation mail description to mailer service * chore: added admin and user related error * feat: added invited users as a new model in prisma * chore: added admin related topics to pubsub * chore: added service method to fetch all users from user table * chore: added user deletion base implementation * Revert "chore: added user deletion base implementation" This reverts commit d1615ad83db2bae946e2d366a903d2f95051dabb. * feat: adding team related operations to admin * chore: adding admin related service methods to teams module service * chore: adding admin related service methods to team coll invitations requests envs * chore: added more module error messages * chore: added admin check service method * chore: added find individual user by UID in admin * HBE-106 feat: introduced code to handle first time admin login setup (#23) * test: wrote test cases for verifyAdmin route service method * chore: added comments to verifyAdmin service method * chore: deleted the prisma migration file * chore: added find admin users * feat: added user deletion into admin module * chore: admin user related errors * chore: fixed registry pattern in the shortcodes and teams to handle user deletion * chore: add subscription topic for user deletion * chore: updated user type in data handler * feat: implement and fix user deletion * feat: added make user admin mutation * chore: added unit tests for admin specific service methods in admin module * chore: added invitation not found error * chore: added admin specific operation test cases in specific modules * chore: added tests related to user deletion and admin related operation in user module * chore: updated to error constant when invitations not found * chore: fix rebase overwritten methods * feat: implement remove user as admin * chore: add new line * chore: created new GQL return type for admin module * chore: created resolver and service method for method to fetch org metrics * chore: removed all entities relevant to seperate query for fetching admin metrics * chore: created all resolvers for metrics * feat: completed adding field resolves to query org metrics * test: wrote tests for all metrics related methods in admin module * test: added test cases for get count functions in multiple modules * chore: removed prisma migration folder * Delete backend-schema.gql * chore: resolved merge conflicts in team test file --------- Co-authored-by: ankitsridhar16 <ankit.sridhar16@gmail.com> * refactor: update mailer service to stop using postmark (#38) * refactor: update mailer service to stop using postmark * chore: remove postmark as a dep and move out postmark code * chore: remove postmark variables from .env.example * chore: add formal errors for mailer initialization errors * chore: add and update jsdoc comments in mailer service methods * chore: added user invitation mail description to mailer service * chore: updated with review changes requested for admin module * feat: adding admin resolver to gql schema * feat: adding input args for admin resolvers * chore: invited user renamed * chore: updated mailer service to be compatible with new mailer * chore: updated team service with review changes * chore: updated team collection service with review changes * chore: updated team environments service with review changes * chore: updated team requests service with review changes * chore: updated user service with review changes * refactor: invited user model * chore: review changes implemented * chore: implemented the review changes for admin, user and teams module * chore: removed error handling and implemented review changes * refactor: naming change for IsAdmin --------- Co-authored-by: Balu Babu <balub997@gmail.com> Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
@@ -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])
|
||||
|
||||
4
packages/hoppscotch-backend/src/admin/admin.model.ts
Normal file
4
packages/hoppscotch-backend/src/admin/admin.model.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { ObjectType, ID, Field, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class Admin {}
|
||||
29
packages/hoppscotch-backend/src/admin/admin.module.ts
Normal file
29
packages/hoppscotch-backend/src/admin/admin.module.ts
Normal file
@@ -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 {}
|
||||
402
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
402
packages/hoppscotch-backend/src/admin/admin.resolver.ts
Normal file
@@ -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<AuthUser> {
|
||||
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<AuthUser[]> {
|
||||
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<InvitedUser[]> {
|
||||
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<Team[]> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<InvitedUser> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<Team> {
|
||||
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<TeamMember> {
|
||||
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<boolean> {
|
||||
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<TeamMember> {
|
||||
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<Team> {
|
||||
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<boolean> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
168
packages/hoppscotch-backend/src/admin/admin.service.spec.ts
Normal file
168
packages/hoppscotch-backend/src/admin/admin.service.spec.ts
Normal file
@@ -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<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
const mockUserService = mockDeep<UserService>();
|
||||
const mockTeamService = mockDeep<TeamService>();
|
||||
const mockTeamEnvironmentsService = mockDeep<TeamEnvironmentsService>();
|
||||
const mockTeamRequestService = mockDeep<TeamRequestService>();
|
||||
const mockTeamInvitationService = mockDeep<TeamInvitationService>();
|
||||
const mockTeamCollectionService = mockDeep<TeamCollectionService>();
|
||||
const mockMailerService = mockDeep<MailerService>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
392
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
392
packages/hoppscotch-backend/src/admin/admin.service.ts
Normal file
@@ -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 = <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) => <InvitedUser>{ ...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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
42
packages/hoppscotch-backend/src/admin/input-types.args.ts
Normal file
42
packages/hoppscotch-backend/src/admin/input-types.args.ts
Normal file
@@ -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;
|
||||
}
|
||||
24
packages/hoppscotch-backend/src/admin/invited-user.model.ts
Normal file
24
packages/hoppscotch-backend/src/admin/invited-user.model.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>{ 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(<AuthError>{
|
||||
message: elevatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: true });
|
||||
}
|
||||
|
||||
return E.right(<IsAdmin>{ isAdmin: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,3 +14,11 @@ export type UserMagicLinkMailDescription = {
|
||||
magicLink: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminUserInvitationMailDescription = {
|
||||
template: 'code-your-own';
|
||||
variables: {
|
||||
inviteeEmail: string;
|
||||
magicLink: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
|
||||
@@ -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<string> {
|
||||
canAllowUserDeletion(user: AuthUser): TO.TaskOption<string> {
|
||||
return TO.none;
|
||||
}
|
||||
|
||||
onUserDelete(user: User): T.Task<void> {
|
||||
// return this.deleteUserShortcodes(user.uid);
|
||||
return undefined;
|
||||
onUserDelete(user: AuthUser): T.Task<void> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,7 @@ import * as E from 'fp-ts/Either';
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PrismaService>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
canAllowUserDeletion(user: AuthUser): TO.TaskOption<string> {
|
||||
return pipe(
|
||||
this.isUserOwnerRoleInTeams(user.uid),
|
||||
TO.fromTask,
|
||||
@@ -44,7 +45,7 @@ export class TeamService implements UserDataHandler, OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
onUserDelete(user: User): T.Task<void> {
|
||||
onUserDelete(user: AuthUser): T.Task<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,7 @@ export interface SSOProviderProfile {
|
||||
provider: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type IsAdmin = {
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
@@ -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<string>
|
||||
onUserDelete: (user: User) => T.Task<void>
|
||||
}
|
||||
canAllowUserDeletion: (user: AuthUser) => TO.TaskOption<string>;
|
||||
onUserDelete: (user: AuthUser) => T.Task<void>;
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<User> {
|
||||
return this.pubsub.asyncIterator(`user/${user.uid}/deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PrismaService>();
|
||||
const mockPubSub = mockDeep<PubSubService>();
|
||||
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`,
|
||||
<User>{
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string[]> {
|
||||
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<never, void>,
|
||||
(errors): TE.TaskEither<string[], void> => TE.left(errors),
|
||||
),
|
||||
|
||||
TE.chainW(() => () => this.deleteUserAccount(user.uid)),
|
||||
|
||||
TE.chainFirst(() =>
|
||||
TE.fromTask(() =>
|
||||
this.pubsub.publish(`user/${user.uid}/deleted`, <User>{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user