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?
|
currentRESTSession Json?
|
||||||
currentGQLSession Json?
|
currentGQLSession Json?
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
|
invitedUsers InvitedUsers[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -160,6 +161,14 @@ model UserEnvironment {
|
|||||||
isGlobal Boolean
|
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 {
|
model UserRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userCollection UserCollection @relation(fields: [collectionID], references: [id])
|
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 { TeamCollectionModule } from './team-collection/team-collection.module';
|
||||||
import { TeamRequestModule } from './team-request/team-request.module';
|
import { TeamRequestModule } from './team-request/team-request.module';
|
||||||
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
import { TeamInvitationModule } from './team-invitation/team-invitation.module';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { UserCollectionModule } from './user-collection/user-collection.module';
|
import { UserCollectionModule } from './user-collection/user-collection.module';
|
||||||
import { ShortcodeModule } from './shortcode/shortcode.module';
|
import { ShortcodeModule } from './shortcode/shortcode.module';
|
||||||
import { COOKIES_NOT_FOUND } from './errors';
|
import { COOKIES_NOT_FOUND } from './errors';
|
||||||
@@ -61,6 +62,7 @@ import { COOKIES_NOT_FOUND } from './errors';
|
|||||||
}),
|
}),
|
||||||
UserModule,
|
UserModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
AdminModule,
|
||||||
UserSettingsModule,
|
UserSettingsModule,
|
||||||
UserEnvironmentsModule,
|
UserEnvironmentsModule,
|
||||||
UserHistoryModule,
|
UserHistoryModule,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Post,
|
Post,
|
||||||
|
Req,
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -15,6 +16,7 @@ import { VerifyMagicDto } from './dto/verify-magic.dto';
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard';
|
import { RTJwtAuthGuard } from './guards/rt-jwt-auth.guard';
|
||||||
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||||
@@ -149,4 +151,12 @@ export class AuthController {
|
|||||||
res.clearCookie('refresh_token');
|
res.clearCookie('refresh_token');
|
||||||
return res.status(200).send();
|
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,
|
MAGIC_LINK_EXPIRED,
|
||||||
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
VERIFICATION_TOKEN_DATA_NOT_FOUND,
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
|
USERS_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { MailerService } from 'src/mailer/mailer.service';
|
import { MailerService } from 'src/mailer/mailer.service';
|
||||||
import { PrismaService } from 'src/prisma/prisma.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 { MailerService } from 'src/mailer/mailer.service';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
import { VerifyMagicDto } from './dto/verify-magic.dto';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@@ -26,7 +25,7 @@ import {
|
|||||||
} from 'src/types/AuthTokens';
|
} from 'src/types/AuthTokens';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { AuthError } from 'src/types/AuthError';
|
import { AuthError } from 'src/types/AuthError';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
||||||
import { VerificationToken } from '@prisma/client';
|
import { VerificationToken } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -339,4 +338,28 @@ export class AuthService {
|
|||||||
|
|
||||||
return E.right(generatedAuthTokens.right);
|
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 INVALID_EMAIL = 'invalid/email' as const;
|
||||||
|
|
||||||
export const EMAIL_FAILED = 'email/failed' 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)
|
* 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;
|
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
|
* User update failure
|
||||||
* (UserService)
|
* (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;
|
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
|
* User deletion failure error due to user being a team owner
|
||||||
* (UserService)
|
* (UserService)
|
||||||
*/
|
*/
|
||||||
export const USER_IS_OWNER = 'user/is_owner' as const;
|
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
|
* 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 =
|
export const TEAM_INVITE_NOT_VALID_VIEWER =
|
||||||
'team_invite/not_valid_viewer' as const;
|
'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
|
* ShortCode not found in DB
|
||||||
* (ShortcodeService)
|
* (ShortcodeService)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { UserRequestResolver } from './user-request/resolvers/user-request.resol
|
|||||||
import { UserSettingsResolver } from './user-settings/user-settings.resolver';
|
import { UserSettingsResolver } from './user-settings/user-settings.resolver';
|
||||||
import { UserResolver } from './user/user.resolver';
|
import { UserResolver } from './user/user.resolver';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { AdminResolver } from './admin/admin.resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All the resolvers present in the application.
|
* 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
|
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
|
||||||
*/
|
*/
|
||||||
const RESOLVERS = [
|
const RESOLVERS = [
|
||||||
|
AdminResolver,
|
||||||
ShortcodeResolver,
|
ShortcodeResolver,
|
||||||
TeamResolver,
|
TeamResolver,
|
||||||
TeamMemberResolver,
|
TeamMemberResolver,
|
||||||
|
|||||||
@@ -14,3 +14,11 @@ export type UserMagicLinkMailDescription = {
|
|||||||
magicLink: string;
|
magicLink: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminUserInvitationMailDescription = {
|
||||||
|
template: 'code-your-own';
|
||||||
|
variables: {
|
||||||
|
inviteeEmail: string;
|
||||||
|
magicLink: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
AdminUserInvitationMailDescription,
|
||||||
MailDescription,
|
MailDescription,
|
||||||
UserMagicLinkMailDescription,
|
UserMagicLinkMailDescription,
|
||||||
} from './MailDescriptions';
|
} from './MailDescriptions';
|
||||||
@@ -18,7 +19,10 @@ export class MailerService {
|
|||||||
* @returns The subject of the email
|
* @returns The subject of the email
|
||||||
*/
|
*/
|
||||||
private resolveSubjectForMailDesc(
|
private resolveSubjectForMailDesc(
|
||||||
mailDesc: MailDescription | UserMagicLinkMailDescription,
|
mailDesc:
|
||||||
|
| MailDescription
|
||||||
|
| UserMagicLinkMailDescription
|
||||||
|
| AdminUserInvitationMailDescription,
|
||||||
): string {
|
): string {
|
||||||
switch (mailDesc.template) {
|
switch (mailDesc.template) {
|
||||||
case 'team-invitation':
|
case 'team-invitation':
|
||||||
@@ -69,4 +73,27 @@ export class MailerService {
|
|||||||
return throwErr(EMAIL_FAILED);
|
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,
|
TeamRequest,
|
||||||
} from 'src/team-request/team-request.model';
|
} from 'src/team-request/team-request.model';
|
||||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
||||||
|
import { InvitedUser } from '../admin/invited-user.model';
|
||||||
import { UserCollection } from '@prisma/client';
|
import { UserCollection } from '@prisma/client';
|
||||||
import { UserCollectionReorderData } from 'src/user-collection/user-collections.model';
|
import { UserCollectionReorderData } from 'src/user-collection/user-collections.model';
|
||||||
import { Shortcode } from 'src/shortcode/shortcode.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.
|
// 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.
|
// For every module that publishes a subscription add its type def and the possible subscription type.
|
||||||
export type TopicDef = {
|
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_settings/${string}/${'created' | 'updated'}`]: UserSettings;
|
||||||
[
|
[
|
||||||
topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}`
|
topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}`
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
SHORTCODE_INVALID_JSON,
|
SHORTCODE_INVALID_JSON,
|
||||||
SHORTCODE_NOT_FOUND,
|
SHORTCODE_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { UserDataHandler } from 'src/user/user.data.handler';
|
import { UserDataHandler } from 'src/user/user.data.handler';
|
||||||
import { Shortcode } from './shortcode.model';
|
import { Shortcode } from './shortcode.model';
|
||||||
import { Shortcode as DBShortCode } from '@prisma/client';
|
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 { UserService } from 'src/user/user.service';
|
||||||
import { stringToJson } from 'src/utils';
|
import { stringToJson } from 'src/utils';
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
import { PaginationArgs } from 'src/types/input-types.args';
|
||||||
|
import { AuthUser } from '../types/AuthUser';
|
||||||
|
|
||||||
const SHORT_CODE_LENGTH = 12;
|
const SHORT_CODE_LENGTH = 12;
|
||||||
const SHORT_CODE_CHARS =
|
const SHORT_CODE_CHARS =
|
||||||
@@ -34,13 +34,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
|
|||||||
this.userService.registerUserDataHandler(this);
|
this.userService.registerUserDataHandler(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
canAllowUserDeletion(user: User): TO.TaskOption<string> {
|
canAllowUserDeletion(user: AuthUser): TO.TaskOption<string> {
|
||||||
return TO.none;
|
return TO.none;
|
||||||
}
|
}
|
||||||
|
|
||||||
onUserDelete(user: User): T.Task<void> {
|
onUserDelete(user: AuthUser): T.Task<void> {
|
||||||
// return this.deleteUserShortcodes(user.uid);
|
return async () => {
|
||||||
return undefined;
|
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
|
* @param uid User Uid
|
||||||
* @returns number of all deleted user ShortCodes
|
* @returns number of all deleted user ShortCodes
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import * as E from 'fp-ts/Either';
|
|||||||
const mockPrisma = mockDeep<PrismaService>();
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
const mockPubSub = mockDeep<PubSubService>();
|
const mockPubSub = mockDeep<PubSubService>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const teamCollectionService = new TeamCollectionService(
|
const teamCollectionService = new TeamCollectionService(
|
||||||
mockPrisma,
|
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
|
//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
|
// Delete collection from TeamCollection table
|
||||||
const deletedTeamCollection = await this.removeTeamCollection(
|
const deletedTeamCollection = await this.removeTeamCollection(
|
||||||
collection.id,
|
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(
|
// async importCollectionFromFirestore(
|
||||||
// userUid: string,
|
// userUid: string,
|
||||||
// fbCollectionPath: string,
|
// fbCollectionPath: string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
|
|||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
import { TeamEnvironment } from './team-environments.model';
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
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>();
|
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 O from 'fp-ts/Option';
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
import * as TO from 'fp-ts/TaskOption';
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
import * as TE from 'fp-ts/TaskEither';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
import { pipe, flow, constVoid } from 'fp-ts/function';
|
import { pipe, flow, constVoid } from 'fp-ts/function';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { Team, TeamMemberRole } from 'src/team/team.model';
|
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 { User } from 'src/user/user.model';
|
||||||
import { TeamService } from 'src/team/team.service';
|
import { TeamService } from 'src/team/team.service';
|
||||||
import {
|
import {
|
||||||
|
TEAM_INVITATION_NOT_FOUND,
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
TEAM_INVITE_ALREADY_MEMBER,
|
||||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||||
TEAM_INVITE_MEMBER_HAS_INVITE,
|
TEAM_INVITE_MEMBER_HAS_INVITE,
|
||||||
@@ -255,4 +257,19 @@ export class TeamInvitationService {
|
|||||||
TE.map(({ teamMember }) => teamMember),
|
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,
|
TEAM_REQ_REORDERING_FAILED,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||||
import { TeamRequest } from './team-request.model';
|
import { TeamRequest } from './team-request.model';
|
||||||
import { MoveTeamRequestArgs } from './input-type.args';
|
import { MoveTeamRequestArgs } from './input-type.args';
|
||||||
@@ -691,3 +690,34 @@ describe('moveRequest', () => {
|
|||||||
).resolves.toEqualLeft(TEAM_REQ_REORDERING_FAILED);
|
).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,
|
teamID: string,
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
cursor: string,
|
cursor: string,
|
||||||
take: number = 10,
|
take = 10,
|
||||||
) {
|
) {
|
||||||
const fetchedRequests = await this.prisma.teamRequest.findMany({
|
const fetchedRequests = await this.prisma.teamRequest.findMany({
|
||||||
take: take,
|
take: take,
|
||||||
@@ -183,7 +183,7 @@ export class TeamRequestService {
|
|||||||
async getRequestsInCollection(
|
async getRequestsInCollection(
|
||||||
collectionID: string,
|
collectionID: string,
|
||||||
cursor: string,
|
cursor: string,
|
||||||
take: number = 10,
|
take = 10,
|
||||||
) {
|
) {
|
||||||
const dbTeamRequests = await this.prisma.teamRequest.findMany({
|
const dbTeamRequests = await this.prisma.teamRequest.findMany({
|
||||||
cursor: cursor ? { id: cursor } : undefined,
|
cursor: cursor ? { id: cursor } : undefined,
|
||||||
@@ -424,4 +424,28 @@ export class TeamRequestService {
|
|||||||
return E.left(TEAM_REQ_REORDERING_FAILED);
|
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',
|
id: 'teamID',
|
||||||
name: 'teamName',
|
name: 'teamName',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const teams: Team[] = [
|
||||||
|
{
|
||||||
|
id: 'teamID',
|
||||||
|
name: 'teamName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'teamID2',
|
||||||
|
name: 'teamName2',
|
||||||
|
},
|
||||||
|
];
|
||||||
const dbTeamMember: DbTeamMember = {
|
const dbTeamMember: DbTeamMember = {
|
||||||
id: 'teamMemberID',
|
id: 'teamMemberID',
|
||||||
role: TeamMemberRole.VIEWER,
|
role: TeamMemberRole.VIEWER,
|
||||||
@@ -51,6 +62,8 @@ const teamMember: TeamMember = {
|
|||||||
|
|
||||||
describe('getCountOfUsersWithRoleInTeam', () => {
|
describe('getCountOfUsersWithRoleInTeam', () => {
|
||||||
test('resolves to the correct count of owners in a team', async () => {
|
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);
|
mockPrisma.teamMember.count.mockResolvedValue(2);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -234,6 +247,8 @@ describe('addMemberToTeamWithEmail', () => {
|
|||||||
|
|
||||||
describe('deleteTeam', () => {
|
describe('deleteTeam', () => {
|
||||||
test('resolves for proper deletion', async () => {
|
test('resolves for proper deletion', async () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
mockPrisma.team.findUnique.mockResolvedValue(team);
|
mockPrisma.team.findUnique.mockResolvedValue(team);
|
||||||
mockPrisma.teamMember.deleteMany.mockResolvedValue({
|
mockPrisma.teamMember.deleteMany.mockResolvedValue({
|
||||||
count: 10,
|
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 { TeamMember as DbTeamMember } from '@prisma/client';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { UserDataHandler } from 'src/user/user.data.handler';
|
import { UserDataHandler } from 'src/user/user.data.handler';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import {
|
import {
|
||||||
TEAM_NAME_INVALID,
|
TEAM_NAME_INVALID,
|
||||||
TEAM_ONLY_ONE_OWNER,
|
TEAM_ONLY_ONE_OWNER,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
TEAM_INVALID_ID_OR_USER,
|
TEAM_INVALID_ID_OR_USER,
|
||||||
TEAM_MEMBER_NOT_FOUND,
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
USER_IS_OWNER,
|
USER_IS_OWNER,
|
||||||
|
TEAMS_NOT_FOUND,
|
||||||
} from '../errors';
|
} from '../errors';
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
import { PubSubService } from '../pubsub/pubsub.service';
|
||||||
import { flow, pipe } from 'fp-ts/function';
|
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 T from 'fp-ts/Task';
|
||||||
import * as A from 'fp-ts/Array';
|
import * as A from 'fp-ts/Array';
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
|
import { AuthUser } from '../types/AuthUser';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamService implements UserDataHandler, OnModuleInit {
|
export class TeamService implements UserDataHandler, OnModuleInit {
|
||||||
@@ -36,7 +37,7 @@ export class TeamService implements UserDataHandler, OnModuleInit {
|
|||||||
this.userService.registerUserDataHandler(this);
|
this.userService.registerUserDataHandler(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
canAllowUserDeletion(user: User): TO.TaskOption<string> {
|
canAllowUserDeletion(user: AuthUser): TO.TaskOption<string> {
|
||||||
return pipe(
|
return pipe(
|
||||||
this.isUserOwnerRoleInTeams(user.uid),
|
this.isUserOwnerRoleInTeams(user.uid),
|
||||||
TO.fromTask,
|
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);
|
return this.deleteUserFromAllTeams(user.uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +453,21 @@ export class TeamService implements UserDataHandler, OnModuleInit {
|
|||||||
return this.filterMismatchedUsers(teamID, members);
|
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(
|
async getMembersOfTeam(
|
||||||
teamID: string,
|
teamID: string,
|
||||||
cursor: string | null,
|
cursor: string | null,
|
||||||
@@ -489,4 +505,31 @@ export class TeamService implements UserDataHandler, OnModuleInit {
|
|||||||
|
|
||||||
return this.filterMismatchedUsers(teamID, members);
|
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;
|
provider: string;
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IsAdmin = {
|
||||||
|
isAdmin: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as T from "fp-ts/Task"
|
import * as T from 'fp-ts/Task';
|
||||||
import * as TO from "fp-ts/TaskOption"
|
import * as TO from 'fp-ts/TaskOption';
|
||||||
import { User } from "src/user/user.model"
|
import { AuthUser } from '../types/AuthUser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines how external services should handle User Data and User data related operations and actions.
|
* Defines how external services should handle User Data and User data related operations and actions.
|
||||||
*/
|
*/
|
||||||
export interface UserDataHandler {
|
export interface UserDataHandler {
|
||||||
canAllowUserDeletion: (user: User) => TO.TaskOption<string>
|
canAllowUserDeletion: (user: AuthUser) => TO.TaskOption<string>;
|
||||||
onUserDelete: (user: User) => T.Task<void>
|
onUserDelete: (user: AuthUser) => T.Task<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { GqlUser } from '../decorators/gql-user.decorator';
|
|||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import * as E from 'fp-ts/lib/Either';
|
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 { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
|
||||||
@@ -52,6 +54,18 @@ export class UserResolver {
|
|||||||
if (E.isLeft(updatedUser)) throwErr(updatedUser.left);
|
if (E.isLeft(updatedUser)) throwErr(updatedUser.left);
|
||||||
return updatedUser.right;
|
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 */
|
/* Subscriptions */
|
||||||
|
|
||||||
@@ -63,4 +77,13 @@ export class UserResolver {
|
|||||||
userUpdated(@GqlUser() user: User) {
|
userUpdated(@GqlUser() user: User) {
|
||||||
return this.pubsub.asyncIterator(`user/${user.uid}/updated`);
|
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 { mockDeep, mockReset } from 'jest-mock-extended';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
import { User } from './user.model';
|
import { User } from './user.model';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.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 mockPrisma = mockDeep<PrismaService>();
|
||||||
const mockPubSub = mockDeep<PubSubService>();
|
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
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -27,6 +40,90 @@ const user: AuthUser = {
|
|||||||
createdOn: currentTime,
|
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 = {
|
const exampleSSOProfileData = {
|
||||||
id: '123rfedvd',
|
id: '123rfedvd',
|
||||||
emails: [{ value: 'dwight@dundermifflin.com' }],
|
emails: [{ value: 'dwight@dundermifflin.com' }],
|
||||||
@@ -38,6 +135,10 @@ const exampleSSOProfileData = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockReset(mockPrisma);
|
mockReset(mockPrisma);
|
||||||
mockPubSub.publish.mockClear();
|
mockPubSub.publish.mockClear();
|
||||||
|
service = new UserService(mockPrisma, mockPubSub as any);
|
||||||
|
|
||||||
|
service.registerUserDataHandler(handler1);
|
||||||
|
service.registerUserDataHandler(handler2);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('UserService', () => {
|
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 { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import * as E from 'fp-ts/Either';
|
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 { 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 { SessionType, User } from './user.model';
|
||||||
import { USER_UPDATE_FAILED } from 'src/errors';
|
import { USER_UPDATE_FAILED } from 'src/errors';
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { stringToJson } from 'src/utils';
|
import { stringToJson, taskEitherValidateArraySeq } from 'src/utils';
|
||||||
import { UserDataHandler } from './user.data.handler';
|
import { UserDataHandler } from './user.data.handler';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -261,4 +266,168 @@ export class UserService {
|
|||||||
|
|
||||||
return E.right(jsonSession.right);
|
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