Compare commits
23 Commits
feat/serve
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70665dae03 | ||
|
|
efc98588d9 | ||
|
|
619bdf85f3 | ||
|
|
2509545dea | ||
|
|
df2d5995fd | ||
|
|
3c2b48a635 | ||
|
|
a0d40c8776 | ||
|
|
3c7a2401ae | ||
|
|
9543369ff3 | ||
|
|
fd5abd59fb | ||
|
|
8f6ca169ce | ||
|
|
2eab86476e | ||
|
|
b53cbb093c | ||
|
|
2bde3f8b02 | ||
|
|
da606f5a96 | ||
|
|
2a667a74f0 | ||
|
|
a4c889e38d | ||
|
|
9ceef43c74 | ||
|
|
abaddd94a5 | ||
|
|
88bca2057a | ||
|
|
3ff6cc53bb | ||
|
|
1df2520bf0 | ||
|
|
5368c52aab |
@@ -27,9 +27,7 @@ import {
|
|||||||
} from './input-types.args';
|
} from './input-types.args';
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
import { User } from 'src/user/user.model';
|
import { UserDeletionResult } from 'src/user/user.model';
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
|
||||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
@UseGuards(GqlThrottlerGuard)
|
||||||
@Resolver(() => Admin)
|
@Resolver(() => Admin)
|
||||||
@@ -49,203 +47,6 @@ export class AdminResolver {
|
|||||||
return admin;
|
return admin;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => [User], {
|
|
||||||
description: 'Returns a list of all admin users in infra',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
|
||||||
async admins() {
|
|
||||||
const admins = await this.adminService.fetchAdmins();
|
|
||||||
return admins;
|
|
||||||
}
|
|
||||||
@ResolveField(() => User, {
|
|
||||||
description: 'Returns a user info by UID',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
@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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
@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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
async allTeams(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args() args: PaginationArgs,
|
|
||||||
): Promise<Team[]> {
|
|
||||||
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
|
|
||||||
return teams;
|
|
||||||
}
|
|
||||||
@ResolveField(() => Team, {
|
|
||||||
description: 'Returns a team info by ID when requested by Admin',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
async teamInfo(
|
|
||||||
@Parent() admin: Admin,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
type: () => ID,
|
|
||||||
description: 'Team ID for which info to fetch',
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
): Promise<Team> {
|
|
||||||
const team = await this.adminService.getTeamInfo(teamID);
|
|
||||||
if (E.isLeft(team)) throwErr(team.left);
|
|
||||||
return team.right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return count of all the members in a team',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
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',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
async usersCount() {
|
|
||||||
return this.adminService.getUsersCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Teams in organization',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
async teamsCount() {
|
|
||||||
return this.adminService.getTeamsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Team Collections in organization',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
async teamCollectionsCount() {
|
|
||||||
return this.adminService.getTeamCollectionsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ResolveField(() => Number, {
|
|
||||||
description: 'Return total number of Team Requests in organization',
|
|
||||||
deprecationReason: 'Use `infra` query instead',
|
|
||||||
})
|
|
||||||
async teamRequestsCount() {
|
|
||||||
return this.adminService.getTeamRequestsCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mutations */
|
/* Mutations */
|
||||||
|
|
||||||
@Mutation(() => InvitedUser, {
|
@Mutation(() => InvitedUser, {
|
||||||
@@ -269,8 +70,26 @@ export class AdminResolver {
|
|||||||
return invitedUser.right;
|
return invitedUser.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Revoke a user invites by invitee emails',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async revokeUserInvitationsByAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'inviteeEmails',
|
||||||
|
description: 'Invitee Emails',
|
||||||
|
type: () => [String],
|
||||||
|
})
|
||||||
|
inviteeEmails: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
|
||||||
|
if (E.isLeft(invite)) throwErr(invite.left);
|
||||||
|
return invite.right;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
@Mutation(() => Boolean, {
|
||||||
description: 'Delete an user account from infra',
|
description: 'Delete an user account from infra',
|
||||||
|
deprecationReason: 'Use removeUsersByAdmin instead',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async removeUserByAdmin(
|
async removeUserByAdmin(
|
||||||
@@ -281,12 +100,33 @@ export class AdminResolver {
|
|||||||
})
|
})
|
||||||
userUID: string,
|
userUID: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const invitedUser = await this.adminService.removeUserAccount(userUID);
|
const removedUser = await this.adminService.removeUserAccount(userUID);
|
||||||
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
|
if (E.isLeft(removedUser)) throwErr(removedUser.left);
|
||||||
return invitedUser.right;
|
return removedUser.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => [UserDeletionResult], {
|
||||||
|
description: 'Delete user accounts from infra',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async removeUsersByAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'userUIDs',
|
||||||
|
description: 'users UID',
|
||||||
|
type: () => [ID],
|
||||||
|
})
|
||||||
|
userUIDs: string[],
|
||||||
|
): Promise<UserDeletionResult[]> {
|
||||||
|
const deletionResults = await this.adminService.removeUserAccounts(
|
||||||
|
userUIDs,
|
||||||
|
);
|
||||||
|
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
|
||||||
|
return deletionResults.right;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
@Mutation(() => Boolean, {
|
||||||
description: 'Make user an admin',
|
description: 'Make user an admin',
|
||||||
|
deprecationReason: 'Use makeUsersAdmin instead',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async makeUserAdmin(
|
async makeUserAdmin(
|
||||||
@@ -302,8 +142,51 @@ export class AdminResolver {
|
|||||||
return admin.right;
|
return admin.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Make users an admin',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async makeUsersAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'userUIDs',
|
||||||
|
description: 'users UID',
|
||||||
|
type: () => [ID],
|
||||||
|
})
|
||||||
|
userUIDs: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
|
||||||
|
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||||
|
return isUpdated.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Update user display name',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async updateUserDisplayNameByAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'userUID',
|
||||||
|
description: 'users UID',
|
||||||
|
type: () => ID,
|
||||||
|
})
|
||||||
|
userUID: string,
|
||||||
|
@Args({
|
||||||
|
name: 'displayName',
|
||||||
|
description: 'users display name',
|
||||||
|
})
|
||||||
|
displayName: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const isUpdated = await this.adminService.updateUserDisplayName(
|
||||||
|
userUID,
|
||||||
|
displayName,
|
||||||
|
);
|
||||||
|
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||||
|
return isUpdated.right;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
@Mutation(() => Boolean, {
|
||||||
description: 'Remove user as admin',
|
description: 'Remove user as admin',
|
||||||
|
deprecationReason: 'Use demoteUsersByAdmin instead',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async removeUserAsAdmin(
|
async removeUserAsAdmin(
|
||||||
@@ -319,6 +202,23 @@ export class AdminResolver {
|
|||||||
return admin.right;
|
return admin.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Remove users as admin',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async demoteUsersByAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'userUIDs',
|
||||||
|
description: 'users UID',
|
||||||
|
type: () => [ID],
|
||||||
|
})
|
||||||
|
userUIDs: string[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
|
||||||
|
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||||
|
return isUpdated.right;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => Team, {
|
@Mutation(() => Team, {
|
||||||
description:
|
description:
|
||||||
'Create a new team by providing the user uid to nominate as Team owner',
|
'Create a new team by providing the user uid to nominate as Team owner',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
import { PubSubService } from '../pubsub/pubsub.service';
|
||||||
import { mockDeep } from 'jest-mock-extended';
|
import { mockDeep } from 'jest-mock-extended';
|
||||||
import { InvitedUsers } from '@prisma/client';
|
import { InvitedUsers, User as DbUser } from '@prisma/client';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { TeamService } from '../team/team.service';
|
import { TeamService } from '../team/team.service';
|
||||||
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
|
||||||
@@ -13,10 +13,15 @@ import { PrismaService } from 'src/prisma/prisma.service';
|
|||||||
import {
|
import {
|
||||||
DUPLICATE_EMAIL,
|
DUPLICATE_EMAIL,
|
||||||
INVALID_EMAIL,
|
INVALID_EMAIL,
|
||||||
|
ONLY_ONE_ADMIN_ACCOUNT,
|
||||||
USER_ALREADY_INVITED,
|
USER_ALREADY_INVITED,
|
||||||
|
USER_INVITATION_DELETION_FAILED,
|
||||||
|
USER_NOT_FOUND,
|
||||||
} from '../errors';
|
} from '../errors';
|
||||||
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
const mockPubSub = mockDeep<PubSubService>();
|
const mockPubSub = mockDeep<PubSubService>();
|
||||||
@@ -58,20 +63,87 @@ const invitedUsers: InvitedUsers[] = [
|
|||||||
invitedOn: new Date(),
|
invitedOn: new Date(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const dbAdminUsers: DbUser[] = [
|
||||||
|
{
|
||||||
|
uid: 'uid 1',
|
||||||
|
displayName: 'displayName',
|
||||||
|
email: 'email@email.com',
|
||||||
|
photoURL: 'photoURL',
|
||||||
|
isAdmin: true,
|
||||||
|
refreshToken: 'refreshToken',
|
||||||
|
currentRESTSession: '',
|
||||||
|
currentGQLSession: '',
|
||||||
|
createdOn: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uid: 'uid 2',
|
||||||
|
displayName: 'displayName',
|
||||||
|
email: 'email@email.com',
|
||||||
|
photoURL: 'photoURL',
|
||||||
|
isAdmin: true,
|
||||||
|
refreshToken: 'refreshToken',
|
||||||
|
currentRESTSession: '',
|
||||||
|
currentGQLSession: '',
|
||||||
|
createdOn: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const dbNonAminUser: DbUser = {
|
||||||
|
uid: 'uid 3',
|
||||||
|
displayName: 'displayName',
|
||||||
|
email: 'email@email.com',
|
||||||
|
photoURL: 'photoURL',
|
||||||
|
isAdmin: false,
|
||||||
|
refreshToken: 'refreshToken',
|
||||||
|
currentRESTSession: '',
|
||||||
|
currentGQLSession: '',
|
||||||
|
createdOn: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
describe('AdminService', () => {
|
describe('AdminService', () => {
|
||||||
describe('fetchInvitedUsers', () => {
|
describe('fetchInvitedUsers', () => {
|
||||||
test('should resolve right and return an array of invited users', async () => {
|
test('should resolve right and apply pagination correctly', async () => {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
||||||
|
// @ts-ignore
|
||||||
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||||
|
|
||||||
const results = await adminService.fetchInvitedUsers();
|
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
|
||||||
|
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||||
|
|
||||||
|
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
|
||||||
|
...paginationArgs,
|
||||||
|
orderBy: {
|
||||||
|
invitedOn: 'desc',
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
NOT: {
|
||||||
|
inviteeEmail: {
|
||||||
|
in: [dbAdminUsers[0].email],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('should resolve right and return an array of invited users', async () => {
|
||||||
|
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
|
||||||
|
// @ts-ignore
|
||||||
|
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
|
||||||
|
|
||||||
|
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||||
expect(results).toEqual(invitedUsers);
|
expect(results).toEqual(invitedUsers);
|
||||||
});
|
});
|
||||||
test('should resolve left and return an empty array if invited users not found', async () => {
|
test('should resolve left and return an empty array if invited users not found', async () => {
|
||||||
|
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
|
||||||
|
|
||||||
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
const results = await adminService.fetchInvitedUsers();
|
const results = await adminService.fetchInvitedUsers(paginationArgs);
|
||||||
expect(results).toEqual([]);
|
expect(results).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -134,6 +206,58 @@ describe('AdminService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('revokeUserInvitations', () => {
|
||||||
|
test('should resolve left and return error if email not invited', async () => {
|
||||||
|
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
|
||||||
|
'RecordNotFound',
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await adminService.revokeUserInvitations([
|
||||||
|
'test@gmail.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should resolve right and return deleted invitee email', async () => {
|
||||||
|
const adminUid = 'adminUid';
|
||||||
|
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
|
||||||
|
|
||||||
|
const result = await adminService.revokeUserInvitations([
|
||||||
|
invitedUsers[0].inviteeEmail,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqualRight(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeUsersAsAdmin', () => {
|
||||||
|
test('should resolve right and make admins to users', async () => {
|
||||||
|
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
|
||||||
|
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
|
||||||
|
|
||||||
|
return expect(
|
||||||
|
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
|
||||||
|
).toEqualRight(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should resolve left and return error if only one admin in the infra', async () => {
|
||||||
|
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
|
||||||
|
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
|
||||||
|
|
||||||
|
return expect(
|
||||||
|
await adminService.demoteUsersByAdmin(
|
||||||
|
dbAdminUsers.map((user) => user.uid),
|
||||||
|
),
|
||||||
|
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getUsersCount', () => {
|
describe('getUsersCount', () => {
|
||||||
test('should return count of all users in the organization', async () => {
|
test('should return count of all users in the organization', async () => {
|
||||||
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
mockUserService.getUsersCount.mockResolvedValueOnce(10);
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import * as E from 'fp-ts/Either';
|
|||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { validateEmail } from '../utils';
|
import { validateEmail } from '../utils';
|
||||||
import {
|
import {
|
||||||
|
ADMIN_CAN_NOT_BE_DELETED,
|
||||||
DUPLICATE_EMAIL,
|
DUPLICATE_EMAIL,
|
||||||
EMAIL_FAILED,
|
EMAIL_FAILED,
|
||||||
INVALID_EMAIL,
|
INVALID_EMAIL,
|
||||||
ONLY_ONE_ADMIN_ACCOUNT,
|
ONLY_ONE_ADMIN_ACCOUNT,
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
TEAM_INVITE_ALREADY_MEMBER,
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
|
USERS_NOT_FOUND,
|
||||||
USER_ALREADY_INVITED,
|
USER_ALREADY_INVITED,
|
||||||
|
USER_INVITATION_DELETION_FAILED,
|
||||||
USER_IS_ADMIN,
|
USER_IS_ADMIN,
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
} from '../errors';
|
} from '../errors';
|
||||||
@@ -26,6 +29,8 @@ import { TeamInvitationService } from '../team-invitation/team-invitation.servic
|
|||||||
import { TeamMemberRole } from '../team/team.model';
|
import { TeamMemberRole } from '../team/team.model';
|
||||||
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
import { ShortcodeService } from 'src/shortcode/shortcode.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||||
|
import { UserDeletionResult } from 'src/user/user.model';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
@@ -48,12 +53,30 @@ export class AdminService {
|
|||||||
* @param cursorID Users uid
|
* @param cursorID Users uid
|
||||||
* @param take number of users to fetch
|
* @param take number of users to fetch
|
||||||
* @returns an Either of array of user or error
|
* @returns an Either of array of user or error
|
||||||
|
* @deprecated use fetchUsersV2 instead
|
||||||
*/
|
*/
|
||||||
async fetchUsers(cursorID: string, take: number) {
|
async fetchUsers(cursorID: string, take: number) {
|
||||||
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
|
||||||
return allUsers;
|
return allUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all the users in the infra.
|
||||||
|
* @param searchString search on users displayName or email
|
||||||
|
* @param paginationOption pagination options
|
||||||
|
* @returns an Either of array of user or error
|
||||||
|
*/
|
||||||
|
async fetchUsersV2(
|
||||||
|
searchString: string,
|
||||||
|
paginationOption: OffsetPaginationArgs,
|
||||||
|
) {
|
||||||
|
const allUsers = await this.userService.fetchAllUsersV2(
|
||||||
|
searchString,
|
||||||
|
paginationOption,
|
||||||
|
);
|
||||||
|
return allUsers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invite a user to join the infra.
|
* Invite a user to join the infra.
|
||||||
* @param adminUID Admin's UID
|
* @param adminUID Admin's UID
|
||||||
@@ -110,14 +133,68 @@ export class AdminService {
|
|||||||
return E.right(invitedUser);
|
return E.right(invitedUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the display name of a user
|
||||||
|
* @param userUid Who's display name is being updated
|
||||||
|
* @param displayName New display name of the user
|
||||||
|
* @returns an Either of boolean or error
|
||||||
|
*/
|
||||||
|
async updateUserDisplayName(userUid: string, displayName: string) {
|
||||||
|
const updatedUser = await this.userService.updateUserDisplayName(
|
||||||
|
userUid,
|
||||||
|
displayName,
|
||||||
|
);
|
||||||
|
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
|
||||||
|
|
||||||
|
return E.right(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke infra level user invitations
|
||||||
|
* @param inviteeEmails Invitee's emails
|
||||||
|
* @param adminUid Admin Uid
|
||||||
|
* @returns an Either of boolean or error string
|
||||||
|
*/
|
||||||
|
async revokeUserInvitations(inviteeEmails: string[]) {
|
||||||
|
try {
|
||||||
|
await this.prisma.invitedUsers.deleteMany({
|
||||||
|
where: {
|
||||||
|
inviteeEmail: { in: inviteeEmails },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return E.right(true);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(USER_INVITATION_DELETION_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the list of invited users by the admin.
|
* Fetch the list of invited users by the admin.
|
||||||
* @returns an Either of array of `InvitedUser` object or error
|
* @returns an Either of array of `InvitedUser` object or error
|
||||||
*/
|
*/
|
||||||
async fetchInvitedUsers() {
|
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
|
||||||
const invitedUsers = await this.prisma.invitedUsers.findMany();
|
const userEmailObjs = await this.prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const users: InvitedUser[] = invitedUsers.map(
|
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
|
||||||
|
take: paginationOption.take,
|
||||||
|
skip: paginationOption.skip,
|
||||||
|
orderBy: {
|
||||||
|
invitedOn: 'desc',
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
NOT: {
|
||||||
|
inviteeEmail: {
|
||||||
|
in: userEmailObjs.map((user) => user.email),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const users: InvitedUser[] = pendingInvitedUsers.map(
|
||||||
(user) => <InvitedUser>{ ...user },
|
(user) => <InvitedUser>{ ...user },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -337,6 +414,7 @@ export class AdminService {
|
|||||||
* Remove a user account by UID
|
* Remove a user account by UID
|
||||||
* @param userUid User UID
|
* @param userUid User UID
|
||||||
* @returns an Either of boolean or error
|
* @returns an Either of boolean or error
|
||||||
|
* @deprecated use removeUserAccounts instead
|
||||||
*/
|
*/
|
||||||
async removeUserAccount(userUid: string) {
|
async removeUserAccount(userUid: string) {
|
||||||
const user = await this.userService.findUserById(userUid);
|
const user = await this.userService.findUserById(userUid);
|
||||||
@@ -349,10 +427,73 @@ export class AdminService {
|
|||||||
return E.right(delUser.right);
|
return E.right(delUser.right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove user (not Admin) accounts by UIDs
|
||||||
|
* @param userUIDs User UIDs
|
||||||
|
* @returns an Either of boolean or error
|
||||||
|
*/
|
||||||
|
async removeUserAccounts(userUIDs: string[]) {
|
||||||
|
const userDeleteResult: UserDeletionResult[] = [];
|
||||||
|
|
||||||
|
// step 1: fetch all users
|
||||||
|
const allUsersList = await this.userService.findUsersByIds(userUIDs);
|
||||||
|
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
|
||||||
|
|
||||||
|
// step 2: admin user can not be deleted without removing admin status/role
|
||||||
|
allUsersList.forEach((user) => {
|
||||||
|
if (user.isAdmin) {
|
||||||
|
userDeleteResult.push({
|
||||||
|
userUID: user.uid,
|
||||||
|
isDeleted: false,
|
||||||
|
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
|
||||||
|
let deletedUserEmails: string[] = [];
|
||||||
|
|
||||||
|
// step 3: delete non-admin users
|
||||||
|
const deletionPromises = nonAdminUsers.map((user) => {
|
||||||
|
return this.userService
|
||||||
|
.deleteUserByUID(user)()
|
||||||
|
.then((res) => {
|
||||||
|
if (E.isLeft(res)) {
|
||||||
|
return {
|
||||||
|
userUID: user.uid,
|
||||||
|
isDeleted: false,
|
||||||
|
errorMessage: res.left,
|
||||||
|
} as UserDeletionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedUserEmails.push(user.email);
|
||||||
|
return {
|
||||||
|
userUID: user.uid,
|
||||||
|
isDeleted: true,
|
||||||
|
errorMessage: null,
|
||||||
|
} as UserDeletionResult;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const promiseResult = await Promise.allSettled(deletionPromises);
|
||||||
|
|
||||||
|
// step 4: revoke all the invites sent to the deleted users
|
||||||
|
await this.revokeUserInvitations(deletedUserEmails);
|
||||||
|
|
||||||
|
// step 5: return the result
|
||||||
|
promiseResult.forEach((result) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
userDeleteResult.push(result.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return E.right(userDeleteResult);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a user an admin
|
* Make a user an admin
|
||||||
* @param userUid User UID
|
* @param userUid User UID
|
||||||
* @returns an Either of boolean or error
|
* @returns an Either of boolean or error
|
||||||
|
* @deprecated use makeUsersAdmin instead
|
||||||
*/
|
*/
|
||||||
async makeUserAdmin(userUID: string) {
|
async makeUserAdmin(userUID: string) {
|
||||||
const admin = await this.userService.makeAdmin(userUID);
|
const admin = await this.userService.makeAdmin(userUID);
|
||||||
@@ -360,10 +501,22 @@ export class AdminService {
|
|||||||
return E.right(true);
|
return E.right(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make users to admin
|
||||||
|
* @param userUid User UIDs
|
||||||
|
* @returns an Either of boolean or error
|
||||||
|
*/
|
||||||
|
async makeUsersAdmin(userUIDs: string[]) {
|
||||||
|
const isUpdated = await this.userService.makeAdmins(userUIDs);
|
||||||
|
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||||
|
return E.right(true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove user as admin
|
* Remove user as admin
|
||||||
* @param userUid User UID
|
* @param userUid User UID
|
||||||
* @returns an Either of boolean or error
|
* @returns an Either of boolean or error
|
||||||
|
* @deprecated use demoteUsersByAdmin instead
|
||||||
*/
|
*/
|
||||||
async removeUserAsAdmin(userUID: string) {
|
async removeUserAsAdmin(userUID: string) {
|
||||||
const adminUsers = await this.userService.fetchAdminUsers();
|
const adminUsers = await this.userService.fetchAdminUsers();
|
||||||
@@ -374,6 +527,26 @@ export class AdminService {
|
|||||||
return E.right(true);
|
return E.right(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove users as admin
|
||||||
|
* @param userUIDs User UIDs
|
||||||
|
* @returns an Either of boolean or error
|
||||||
|
*/
|
||||||
|
async demoteUsersByAdmin(userUIDs: string[]) {
|
||||||
|
const adminUsers = await this.userService.fetchAdminUsers();
|
||||||
|
|
||||||
|
const remainingAdmins = adminUsers.filter(
|
||||||
|
(adminUser) => !userUIDs.includes(adminUser.uid),
|
||||||
|
);
|
||||||
|
if (remainingAdmins.length < 1) {
|
||||||
|
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
|
||||||
|
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
|
||||||
|
return E.right(isUpdated.right);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch list of all the Users in org
|
* Fetch list of all the Users in org
|
||||||
* @returns number of users in the org
|
* @returns number of users in the org
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import { AuthUser } from 'src/types/AuthUser';
|
|||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { Admin } from './admin.model';
|
import { Admin } from './admin.model';
|
||||||
import { PaginationArgs } from 'src/types/input-types.args';
|
import {
|
||||||
|
OffsetPaginationArgs,
|
||||||
|
PaginationArgs,
|
||||||
|
} from 'src/types/input-types.args';
|
||||||
import { InvitedUser } from './invited-user.model';
|
import { InvitedUser } from './invited-user.model';
|
||||||
import { Team } from 'src/team/team.model';
|
import { Team } from 'src/team/team.model';
|
||||||
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
|
||||||
@@ -76,6 +79,7 @@ export class InfraResolver {
|
|||||||
|
|
||||||
@ResolveField(() => [User], {
|
@ResolveField(() => [User], {
|
||||||
description: 'Returns a list of all the users in infra',
|
description: 'Returns a list of all the users in infra',
|
||||||
|
deprecationReason: 'Use allUsersV2 instead',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
|
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
|
||||||
@@ -83,11 +87,33 @@ export class InfraResolver {
|
|||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => [User], {
|
||||||
|
description: 'Returns a list of all the users in infra',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async allUsersV2(
|
||||||
|
@Args({
|
||||||
|
name: 'searchString',
|
||||||
|
nullable: true,
|
||||||
|
description: 'Search on users displayName or email',
|
||||||
|
})
|
||||||
|
searchString: string,
|
||||||
|
@Args() paginationOption: OffsetPaginationArgs,
|
||||||
|
): Promise<AuthUser[]> {
|
||||||
|
const users = await this.adminService.fetchUsersV2(
|
||||||
|
searchString,
|
||||||
|
paginationOption,
|
||||||
|
);
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
@ResolveField(() => [InvitedUser], {
|
@ResolveField(() => [InvitedUser], {
|
||||||
description: 'Returns a list of all the invited users',
|
description: 'Returns a list of all the invited users',
|
||||||
})
|
})
|
||||||
async invitedUsers(): Promise<InvitedUser[]> {
|
async invitedUsers(
|
||||||
const users = await this.adminService.fetchInvitedUsers();
|
@Args() args: OffsetPaginationArgs,
|
||||||
|
): Promise<InvitedUser[]> {
|
||||||
|
const users = await this.adminService.fetchInvitedUsers(args);
|
||||||
return users;
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +332,9 @@ export class InfraResolver {
|
|||||||
})
|
})
|
||||||
providerInfo: EnableAndDisableSSOArgs[],
|
providerInfo: EnableAndDisableSSOArgs[],
|
||||||
) {
|
) {
|
||||||
const isUpdated = await this.infraConfigService.enableAndDisableSSO(providerInfo);
|
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
|
||||||
|
providerInfo,
|
||||||
|
);
|
||||||
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
|
|||||||
export const ONLY_ONE_ADMIN_ACCOUNT =
|
export const ONLY_ONE_ADMIN_ACCOUNT =
|
||||||
'admin/only_one_admin_account_found' as const;
|
'admin/only_one_admin_account_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin user can not be deleted
|
||||||
|
* To delete the admin user, first make the Admin user a normal user
|
||||||
|
* (AdminService)
|
||||||
|
*/
|
||||||
|
export const ADMIN_CAN_NOT_BE_DELETED =
|
||||||
|
'admin/admin_can_not_be_deleted' as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token Authorization failed (Check 'Authorization' Header)
|
* Token Authorization failed (Check 'Authorization' Header)
|
||||||
* (GqlAuthGuard)
|
* (GqlAuthGuard)
|
||||||
@@ -99,6 +107,13 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
|
|||||||
*/
|
*/
|
||||||
export const USER_IS_ADMIN = 'user/is_admin' as const;
|
export const USER_IS_ADMIN = 'user/is_admin' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User invite deletion failure error due to invitation not found
|
||||||
|
* (AdminService)
|
||||||
|
*/
|
||||||
|
export const USER_INVITATION_DELETION_FAILED =
|
||||||
|
'user/invitation_deletion_failed' as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Teams not found
|
* Teams not found
|
||||||
* (TeamsService)
|
* (TeamsService)
|
||||||
|
|||||||
@@ -17,3 +17,21 @@ export class PaginationArgs {
|
|||||||
})
|
})
|
||||||
take: number;
|
take: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
@InputType()
|
||||||
|
export class OffsetPaginationArgs {
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: 0,
|
||||||
|
description: 'Number of items to skip',
|
||||||
|
})
|
||||||
|
skip: number;
|
||||||
|
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: 10,
|
||||||
|
description: 'Number of items to fetch',
|
||||||
|
})
|
||||||
|
take: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,3 +56,22 @@ export enum SessionType {
|
|||||||
registerEnumType(SessionType, {
|
registerEnumType(SessionType, {
|
||||||
name: 'SessionType',
|
name: 'SessionType',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class UserDeletionResult {
|
||||||
|
@Field(() => ID, {
|
||||||
|
description: 'UID of the user',
|
||||||
|
})
|
||||||
|
userUID: string;
|
||||||
|
|
||||||
|
@Field(() => Boolean, {
|
||||||
|
description: 'Flag to determine if user deletion was successful or not',
|
||||||
|
})
|
||||||
|
isDeleted: Boolean;
|
||||||
|
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Error message if user deletion was not successful',
|
||||||
|
})
|
||||||
|
errorMessage: String;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { JSON_INVALID, USER_NOT_FOUND } from 'src/errors';
|
import { JSON_INVALID, USERS_NOT_FOUND, 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';
|
||||||
@@ -176,6 +176,26 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findUsersByIds', () => {
|
||||||
|
test('should successfully return users given valid user UIDs', async () => {
|
||||||
|
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||||
|
|
||||||
|
const result = await userService.findUsersByIds([
|
||||||
|
'123344',
|
||||||
|
'5555',
|
||||||
|
'6666',
|
||||||
|
]);
|
||||||
|
expect(result).toEqual(users);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty array of users given a invalid user UIDs', async () => {
|
||||||
|
mockPrisma.user.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await userService.findUsersByIds(['sdcvbdbr']);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createUserViaMagicLink', () => {
|
describe('createUserViaMagicLink', () => {
|
||||||
test('should successfully create user and account for magic-link given valid inputs', async () => {
|
test('should successfully create user and account for magic-link given valid inputs', async () => {
|
||||||
mockPrisma.user.create.mockResolvedValueOnce(user);
|
mockPrisma.user.create.mockResolvedValueOnce(user);
|
||||||
@@ -414,6 +434,54 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateUserDisplayName', () => {
|
||||||
|
test('should resolve right and update user display name', async () => {
|
||||||
|
const newDisplayName = 'New Name';
|
||||||
|
mockPrisma.user.update.mockResolvedValueOnce({
|
||||||
|
...user,
|
||||||
|
displayName: newDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await userService.updateUserDisplayName(
|
||||||
|
user.uid,
|
||||||
|
newDisplayName,
|
||||||
|
);
|
||||||
|
expect(result).toEqualRight({
|
||||||
|
...user,
|
||||||
|
displayName: newDisplayName,
|
||||||
|
currentGQLSession: JSON.stringify(user.currentGQLSession),
|
||||||
|
currentRESTSession: JSON.stringify(user.currentRESTSession),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test('should resolve right and publish user updated subscription', async () => {
|
||||||
|
const newDisplayName = 'New Name';
|
||||||
|
mockPrisma.user.update.mockResolvedValueOnce({
|
||||||
|
...user,
|
||||||
|
displayName: newDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await userService.updateUserDisplayName(user.uid, user.displayName);
|
||||||
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
|
`user/${user.uid}/updated`,
|
||||||
|
{
|
||||||
|
...user,
|
||||||
|
displayName: newDisplayName,
|
||||||
|
currentGQLSession: JSON.stringify(user.currentGQLSession),
|
||||||
|
currentRESTSession: JSON.stringify(user.currentRESTSession),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('should resolve left and error when invalid user uid is passed', async () => {
|
||||||
|
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
|
||||||
|
|
||||||
|
const result = await userService.updateUserDisplayName(
|
||||||
|
'invalidUserUid',
|
||||||
|
user.displayName,
|
||||||
|
);
|
||||||
|
expect(result).toEqualLeft(USER_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fetchAllUsers', () => {
|
describe('fetchAllUsers', () => {
|
||||||
test('should resolve right and return 20 users when cursor is null', async () => {
|
test('should resolve right and return 20 users when cursor is null', async () => {
|
||||||
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||||
@@ -435,6 +503,36 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchAllUsersV2', () => {
|
||||||
|
test('should resolve right and return first 20 users when searchString is null', async () => {
|
||||||
|
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||||
|
|
||||||
|
const result = await userService.fetchAllUsersV2(null, {
|
||||||
|
take: 20,
|
||||||
|
skip: 0,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(users);
|
||||||
|
});
|
||||||
|
test('should resolve right and return next 20 users when searchString is provided', async () => {
|
||||||
|
mockPrisma.user.findMany.mockResolvedValueOnce(users);
|
||||||
|
|
||||||
|
const result = await userService.fetchAllUsersV2('.com', {
|
||||||
|
take: 20,
|
||||||
|
skip: 0,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(users);
|
||||||
|
});
|
||||||
|
test('should resolve left and return an empty array when users not found', async () => {
|
||||||
|
mockPrisma.user.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await userService.fetchAllUsersV2('Unknown entry', {
|
||||||
|
take: 20,
|
||||||
|
skip: 0,
|
||||||
|
});
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fetchAdminUsers', () => {
|
describe('fetchAdminUsers', () => {
|
||||||
test('should return a list of admin users', async () => {
|
test('should return a list of admin users', async () => {
|
||||||
mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers);
|
mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers);
|
||||||
@@ -556,4 +654,17 @@ describe('UserService', () => {
|
|||||||
expect(result).toEqual(10);
|
expect(result).toEqual(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('removeUsersAsAdmin', () => {
|
||||||
|
test('should resolve right and return true for valid user UIDs', async () => {
|
||||||
|
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 1 });
|
||||||
|
const result = await userService.removeUsersAsAdmin(['123344']);
|
||||||
|
expect(result).toEqualRight(true);
|
||||||
|
});
|
||||||
|
test('should resolve right and return false for invalid user UIDs', async () => {
|
||||||
|
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 0 });
|
||||||
|
const result = await userService.removeUsersAsAdmin(['123344']);
|
||||||
|
expect(result).toEqualLeft(USERS_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import * as T from 'fp-ts/Task';
|
|||||||
import * as A from 'fp-ts/Array';
|
import * as A from 'fp-ts/Array';
|
||||||
import { pipe, constVoid } from 'fp-ts/function';
|
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 { USERS_NOT_FOUND, USER_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, taskEitherValidateArraySeq } from 'src/utils';
|
import { stringToJson, taskEitherValidateArraySeq } from 'src/utils';
|
||||||
import { UserDataHandler } from './user.data.handler';
|
import { UserDataHandler } from './user.data.handler';
|
||||||
import { User as DbUser } from '@prisma/client';
|
import { User as DbUser } from '@prisma/client';
|
||||||
|
import { OffsetPaginationArgs } from 'src/types/input-types.args';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -88,6 +89,20 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find users with given IDs
|
||||||
|
* @param userUIDs User IDs
|
||||||
|
* @returns Array of found Users
|
||||||
|
*/
|
||||||
|
async findUsersByIds(userUIDs: string[]): Promise<AuthUser[]> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
uid: { in: userUIDs },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update User with new generated hashed refresh token
|
* Update User with new generated hashed refresh token
|
||||||
*
|
*
|
||||||
@@ -269,6 +284,30 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a user's data
|
||||||
|
* @param userUID User UID
|
||||||
|
* @param displayName User's displayName
|
||||||
|
* @returns a Either of User or error
|
||||||
|
*/
|
||||||
|
async updateUserDisplayName(userUID: string, displayName: string) {
|
||||||
|
try {
|
||||||
|
const dbUpdatedUser = await this.prisma.user.update({
|
||||||
|
where: { uid: userUID },
|
||||||
|
data: { displayName },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedUser = this.convertDbUserToUser(dbUpdatedUser);
|
||||||
|
|
||||||
|
// Publish subscription for user updates
|
||||||
|
await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser);
|
||||||
|
|
||||||
|
return E.right(updatedUser);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and parse currentRESTSession and currentGQLSession
|
* Validate and parse currentRESTSession and currentGQLSession
|
||||||
* @param sessionData string of the session
|
* @param sessionData string of the session
|
||||||
@@ -286,6 +325,7 @@ export class UserService {
|
|||||||
* @param cursorID string of userUID or null
|
* @param cursorID string of userUID or null
|
||||||
* @param take number of users to query
|
* @param take number of users to query
|
||||||
* @returns an array of `User` object
|
* @returns an array of `User` object
|
||||||
|
* @deprecated use fetchAllUsersV2 instead
|
||||||
*/
|
*/
|
||||||
async fetchAllUsers(cursorID: string, take: number) {
|
async fetchAllUsers(cursorID: string, take: number) {
|
||||||
const fetchedUsers = await this.prisma.user.findMany({
|
const fetchedUsers = await this.prisma.user.findMany({
|
||||||
@@ -296,6 +336,43 @@ export class UserService {
|
|||||||
return fetchedUsers;
|
return fetchedUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all the users in the `User` table based on cursor
|
||||||
|
* @param searchString search on user's displayName or email
|
||||||
|
* @param paginationOption pagination options
|
||||||
|
* @returns an array of `User` object
|
||||||
|
*/
|
||||||
|
async fetchAllUsersV2(
|
||||||
|
searchString: string,
|
||||||
|
paginationOption: OffsetPaginationArgs,
|
||||||
|
) {
|
||||||
|
const fetchedUsers = await this.prisma.user.findMany({
|
||||||
|
skip: paginationOption.skip,
|
||||||
|
take: paginationOption.take,
|
||||||
|
where: searchString
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
displayName: {
|
||||||
|
contains: searchString,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: {
|
||||||
|
contains: searchString,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: [{ isAdmin: 'desc' }, { displayName: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the number of users in db
|
* Fetch the number of users in db
|
||||||
* @returns a count (Int) of user records in DB
|
* @returns a count (Int) of user records in DB
|
||||||
@@ -326,6 +403,23 @@ export class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change users to admins by toggling isAdmin param to true
|
||||||
|
* @param userUID user UIDs
|
||||||
|
* @returns a Either of true or error
|
||||||
|
*/
|
||||||
|
async makeAdmins(userUIDs: string[]) {
|
||||||
|
try {
|
||||||
|
await this.prisma.user.updateMany({
|
||||||
|
where: { uid: { in: userUIDs } },
|
||||||
|
data: { isAdmin: true },
|
||||||
|
});
|
||||||
|
return E.right(true);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(USER_UPDATE_FAILED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all the admin users
|
* Fetch all the admin users
|
||||||
* @returns an array of admin users
|
* @returns an array of admin users
|
||||||
@@ -444,4 +538,22 @@ export class UserService {
|
|||||||
return E.left(USER_NOT_FOUND);
|
return E.left(USER_NOT_FOUND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change users from an admin by toggling isAdmin param to false
|
||||||
|
* @param userUIDs user UIDs
|
||||||
|
* @returns a Either of true or error
|
||||||
|
*/
|
||||||
|
async removeUsersAsAdmin(userUIDs: string[]) {
|
||||||
|
const data = await this.prisma.user.updateMany({
|
||||||
|
where: { uid: { in: userUIDs } },
|
||||||
|
data: { isAdmin: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.count === 0) {
|
||||||
|
return E.left(USERS_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return E.right(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13885
pnpm-lock.yaml
generated
13885
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user