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:
Ankit Sridhar
2023-03-21 16:42:30 +05:30
committed by GitHub
parent 8b1d8e6a90
commit f78354a377
34 changed files with 1986 additions and 35 deletions

View File

@@ -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])

View File

@@ -0,0 +1,4 @@
import { ObjectType, ID, Field, ResolveField } from '@nestjs/graphql';
@ObjectType()
export class Admin {}

View 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 {}

View 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`);
}
}

View 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);
});
});
});

View 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;
}
}

View File

@@ -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;
},
);

View File

@@ -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;
}
}

View 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;
}

View 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;
}

View File

@@ -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,

View File

@@ -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;
}
} }

View File

@@ -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 });
});
});

View File

@@ -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 });
}
} }

View File

@@ -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)

View File

@@ -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,

View File

@@ -14,3 +14,11 @@ export type UserMagicLinkMailDescription = {
magicLink: string; magicLink: string;
}; };
}; };
export type AdminUserInvitationMailDescription = {
template: 'code-your-own';
variables: {
inviteeEmail: string;
magicLink: string;
};
};

View File

@@ -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);
}
}
} }

View File

@@ -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'}`

View File

@@ -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
*/ */

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
});
});
}); });

View File

@@ -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;
}
} }

View File

@@ -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;
}
} }

View File

@@ -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);
});
});
});

View File

@@ -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;
}
} }

View File

@@ -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);
});
});
});

View File

@@ -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;
}
} }

View File

@@ -6,3 +6,7 @@ export interface SSOProviderProfile {
provider: string; provider: string;
id: string; id: string;
} }
export type IsAdmin = {
isAdmin: boolean;
};

View File

@@ -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>;
} }

View File

@@ -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`);
}
} }

View File

@@ -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);
});
});
}); });

View File

@@ -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);
}
}
} }