feat: team module added

This commit is contained in:
Mir Arif Hasan
2023-02-07 21:15:54 +06:00
parent 420359066e
commit 9bee62ada9
12 changed files with 1937 additions and 2 deletions

View File

@@ -8,6 +8,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
import { UserHistoryModule } from './user-history/user-history.module';
import { subscriptionContextCookieParser } from './auth/helper';
import { TeamModule } from './team/team.module';
@Module({
imports: [
@@ -45,6 +46,7 @@ import { subscriptionContextCookieParser } from './auth/helper';
UserSettingsModule,
UserEnvironmentsModule,
UserHistoryModule,
TeamModule,
],
providers: [GQLComplexityPlugin],
})

View File

@@ -2,6 +2,7 @@ import { User } from 'src/user/user.model';
import { UserSettings } from 'src/user-settings/user-settings.model';
import { UserEnvironment } from '../user-environment/user-environments.model';
import { UserHistory } from '../user-history/user-history.model';
import { TeamMember } from 'src/team/team.model';
// A custom message type that defines the topic and the corresponding payload.
// For every module that publishes a subscription add its type def and the possible subscription type.
@@ -15,5 +16,8 @@ export type TopicDef = {
[
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`
]: UserHistory;
[topic: `team/${string}/member_removed`]: string;
[topic: `team/${string}/member_added`]: TeamMember;
[topic: `team/${string}/member_updated`]: TeamMember;
[topic: `user_history/${string}/deleted_many`]: number;
};

View File

@@ -0,0 +1,5 @@
import { TeamMemberRole } from '@prisma/client';
import { SetMetadata } from '@nestjs/common';
export const RequiresTeamRole = (...roles: TeamMemberRole[]) =>
SetMetadata('requiresTeamRole', roles);

View File

@@ -0,0 +1,47 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TeamService } from '../team.service';
import { TeamMemberRole } from '../team.model';
import { GqlExecutionContext } from '@nestjs/graphql';
import { User } from '../../user/user.model';
import {
TEAM_NOT_REQUIRED_ROLE,
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_NO_REQUIRE_TEAM_ROLE,
BUG_TEAM_NO_TEAM_ID,
} from 'src/errors';
@Injectable()
export class GqlTeamMemberGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly teamService: TeamService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>(
'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_NO_REQUIRE_TEAM_ROLE);
const gqlExecCtx = GqlExecutionContext.create(context);
const { user } = gqlExecCtx.getContext().req;
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
const { teamID } = gqlExecCtx.getArgs<{ teamID: string }>();
if (!teamID) throw new Error(BUG_TEAM_NO_TEAM_ID);
const status = await this.teamService.getTeamMember(teamID, user.uid);
if (!status) throw new Error('team/member_not_found');
if (requireRoles.includes(status.role)) return true;
throw new Error(TEAM_NOT_REQUIRED_ROLE);
}
}

View File

@@ -0,0 +1,24 @@
import { Resolver, ResolveField, Parent } from '@nestjs/graphql';
import { TeamMember } from './team.model';
import { UserService } from 'src/user/user.service';
import { User } from '../user/user.model';
import { throwErr } from 'src/utils';
import { USER_NOT_FOUND } from 'src/errors';
import * as O from 'fp-ts/Option';
@Resolver(() => TeamMember)
export class TeamMemberResolver {
constructor(private readonly userService: UserService) {}
@ResolveField(() => User)
async user(@Parent() teamMember: TeamMember): Promise<User> {
const member = await this.userService.findUserById(teamMember.userUid);
if (O.isNone(member)) throwErr(USER_NOT_FOUND);
return {
...member.value,
currentRESTSession: JSON.stringify(member.value.currentRESTSession),
currentGQLSession: JSON.stringify(member.value.currentGQLSession),
};
}
}

View File

@@ -0,0 +1,39 @@
import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql';
@ObjectType()
export class Team {
@Field(() => ID, {
description: 'ID of the team',
})
id: string;
@Field(() => String, {
description: 'Displayed name of the team',
})
name: string;
}
@ObjectType()
export class TeamMember {
@Field(() => ID, {
description: 'Membership ID of the Team Member'
})
membershipID: string;
userUid: string;
@Field(() => TeamMemberRole, {
description: 'Role of the given team member in the given team',
})
role: TeamMemberRole;
}
export enum TeamMemberRole {
OWNER = 'OWNER',
VIEWER = 'VIEWER',
EDITOR = 'EDITOR',
}
registerEnumType(TeamMemberRole, {
name: 'TeamMemberRole',
});

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TeamService } from './team.service';
import { TeamResolver } from './team.resolver';
import { UserModule } from '../user/user.module';
import { TeamMemberResolver } from './team-member.resolver';
import { GqlTeamMemberGuard } from './guards/gql-team-member.guard';
import { PrismaModule } from '../prisma/prisma.module';
import { PubSubModule } from '../pubsub/pubsub.module';
@Module({
imports: [UserModule ,PubSubModule , PrismaModule ],
providers: [
TeamService,
TeamResolver,
TeamMemberResolver,
GqlTeamMemberGuard,
],
exports: [TeamService, GqlTeamMemberGuard],
})
export class TeamModule {}

View File

@@ -0,0 +1,364 @@
import { Team, TeamMember, TeamMemberRole } from './team.model';
import {
Resolver,
ResolveField,
Args,
Parent,
Query,
Mutation,
Int,
Subscription,
ID,
} from '@nestjs/graphql';
import { TeamService } from './team.service';
import { User } from '../user/user.model';
import { GqlAuthGuard } from '../guards/gql-auth.guard';
import { GqlUser } from '../decorators/gql-user.decorator';
import { UseGuards } from '@nestjs/common';
import { RequiresTeamRole } from './decorators/requires-team-role.decorator';
import { GqlTeamMemberGuard } from './guards/gql-team-member.guard';
import { PubSubService } from '../pubsub/pubsub.service';
import * as E from 'fp-ts/Either';
import { throwErr } from 'src/utils';
import { AuthUser } from 'src/types/AuthUser';
@Resolver(() => Team)
export class TeamResolver {
constructor(
private readonly teamService: TeamService,
private readonly pubsub: PubSubService,
) {}
// Field Resolvers
// TODO: Deprecate this
@ResolveField(() => [TeamMember], {
description: 'Returns the list of members of a team',
complexity: 10,
})
members(
@Parent() team: Team,
@Args({
name: 'cursor',
type: () => ID,
description:
'The ID of the last returned team member entry (used for pagination)',
nullable: true,
})
cursor?: string,
): Promise<TeamMember[]> {
return this.teamService.getMembersOfTeam(team.id, cursor ?? null);
}
@ResolveField(() => [TeamMember], {
description: 'Returns the list of members of a team',
complexity: 10,
})
teamMembers(@Parent() team: Team): Promise<TeamMember[]> {
return this.teamService.getTeamMembers(team.id);
}
@ResolveField(() => TeamMemberRole, {
description: 'The role of the current user in the team',
})
@UseGuards(GqlAuthGuard)
myRole(
@Parent() team: Team,
@GqlUser() user: AuthUser,
): Promise<TeamMemberRole | null> {
return this.teamService.getRoleOfUserInTeam(team.id, user.uid);
}
@ResolveField(() => Int, {
description: 'The number of users with the OWNER role in the team',
})
ownersCount(@Parent() team: Team): Promise<number> {
return this.teamService.getCountOfUsersWithRoleInTeam(
team.id,
TeamMemberRole.OWNER,
);
}
@ResolveField(() => Int, {
description: 'The number of users with the EDITOR role in the team',
})
editorsCount(@Parent() team: Team): Promise<number> {
return this.teamService.getCountOfUsersWithRoleInTeam(
team.id,
TeamMemberRole.EDITOR,
);
}
@ResolveField(() => Int, {
description: 'The number of users with the VIEWER role in the team',
})
viewersCount(@Parent() team: Team): Promise<number> {
return this.teamService.getCountOfUsersWithRoleInTeam(
team.id,
TeamMemberRole.VIEWER,
);
}
// Query
@Query(() => [Team], {
description: 'List of teams that the executing user belongs to.',
})
@UseGuards(GqlAuthGuard)
myTeams(
@GqlUser() user: AuthUser,
@Args({
name: 'cursor',
type: () => ID,
description:
'The ID of the last returned team entry (used for pagination)',
nullable: true,
})
cursor?: string,
): Promise<Team[]> {
return this.teamService.getTeamsOfUser(user.uid, cursor ?? null);
}
@Query(() => Team, {
description: 'Returns the detail of the team with the given ID',
nullable: true,
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(
TeamMemberRole.VIEWER,
TeamMemberRole.EDITOR,
TeamMemberRole.OWNER,
)
team(
@Args({
name: 'teamID',
type: () => ID,
description: 'ID of the team to check',
})
teamID: string,
): Promise<Team | null> {
return this.teamService.getTeamWithID(teamID);
}
// Mutation
@Mutation(() => Team, {
description: 'Creates a team owned by the executing user',
})
@UseGuards(GqlAuthGuard)
async createTeam(
@GqlUser() user: AuthUser,
@Args({ name: 'name', description: 'Displayed name of the team' })
name: string,
): Promise<Team> {
const team = await this.teamService.createTeam(name, user.uid);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@Mutation(() => Boolean, {
description: 'Leaves a team the executing user is a part of',
})
@UseGuards(GqlAuthGuard)
async leaveTeam(
@GqlUser() user: AuthUser,
@Args({
name: 'teamID',
description: 'ID of the Team to leave',
type: () => ID,
})
teamID: string,
): Promise<boolean> {
const isUserLeft = await this.teamService.leaveTeam(teamID, user.uid);
if (E.isLeft(isUserLeft)) throwErr(isUserLeft.left);
return isUserLeft.right;
}
@Mutation(() => Boolean, {
description: 'Removes the team member from the team',
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
async removeTeamMember(
@GqlUser() _user: AuthUser,
@Args({
name: 'teamID',
description: 'ID of the Team to remove user from',
type: () => ID,
})
teamID: string,
@Args({
name: 'userUid',
description: 'ID of the user to remove from the given team',
type: () => ID,
})
userUid: string,
): Promise<boolean> {
const isRemoved = await this.teamService.leaveTeam(teamID, userUid);
if (E.isLeft(isRemoved)) throwErr(isRemoved.left);
return isRemoved.right;
}
@Mutation(() => Team, {
description: 'Renames a team',
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
async renameTeam(
@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 team = await this.teamService.renameTeam(teamID, newName);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@Mutation(() => Boolean, {
description: 'Deletes the team',
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER)
async deleteTeam(
@Args({ name: 'teamID', description: 'ID of the team', type: () => ID })
teamID: string,
): Promise<boolean> {
const isDeleted = await this.teamService.deleteTeam(teamID);
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
}
@Mutation(() => TeamMember, {
description: 'Adds a team member to the team via email',
deprecationReason:
'This is only present for backwards compatibility and will be removed soon use team invitations instead',
})
@RequiresTeamRole(TeamMemberRole.OWNER)
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
async addTeamMemberByEmail(
@Args({
name: 'teamID',
description: 'ID of the team to add to',
type: () => ID,
})
teamID: string,
@Args({
name: 'userEmail',
description: 'Email of the user to add to team',
})
userEmail: string,
@Args({
name: 'userRole',
description: 'The role of the user to add in the team',
type: () => TeamMemberRole,
})
role: TeamMemberRole,
): Promise<TeamMember> {
const teamMember = await this.teamService.addMemberToTeamWithEmail(
teamID,
userEmail,
role,
);
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
}
@Mutation(() => TeamMember, {
description: 'Update role of a team member the executing user owns',
})
@RequiresTeamRole(TeamMemberRole.OWNER)
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
async updateTeamMemberRole(
@Args({
name: 'teamID',
description: 'ID of the affected team',
type: () => ID,
})
teamID: string,
@Args({
name: 'userUid',
description: 'UID of the affected user',
type: () => ID,
})
userUid: string,
@Args({
name: 'newRole',
description: 'Updated role value',
type: () => TeamMemberRole,
})
newRole: TeamMemberRole,
): Promise<TeamMember> {
const teamMember = await this.teamService.updateTeamMemberRole(
teamID,
userUid,
newRole,
);
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
}
// Subscriptions
@Subscription(() => TeamMember, {
description:
'Listen to when a new team member being added to the team. The emitted value is the new team member added.',
resolve: (value) => value,
})
@RequiresTeamRole(
TeamMemberRole.OWNER,
TeamMemberRole.EDITOR,
TeamMemberRole.VIEWER,
)
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
teamMemberAdded(
@Args({
name: 'teamID',
description: 'ID of the team to listen to',
type: () => ID,
})
teamID: string,
): AsyncIterator<TeamMember> {
return this.pubsub.asyncIterator(`team/${teamID}/member_added`);
}
@Subscription(() => TeamMember, {
description:
'Listen to when a team member status has been updated. The emitted value is the new team member status',
resolve: (value) => value,
})
@RequiresTeamRole(
TeamMemberRole.OWNER,
TeamMemberRole.EDITOR,
TeamMemberRole.VIEWER,
)
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
teamMemberUpdated(
@Args({
name: 'teamID',
description: 'ID of the team to listen to',
type: () => ID,
})
teamID: string,
): AsyncIterator<TeamMember> {
return this.pubsub.asyncIterator(`team/${teamID}/member_updated`);
}
@Subscription(() => ID, {
description:
'Listen to when a team member has been removed. The emitted value is the uid of the user removed',
resolve: (value) => value,
})
@RequiresTeamRole(
TeamMemberRole.OWNER,
TeamMemberRole.EDITOR,
TeamMemberRole.VIEWER,
)
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
teamMemberRemoved(
@Args({
name: 'teamID',
description: 'ID of the team to listen to',
type: () => ID,
})
teamID: string,
): AsyncIterator<string> {
return this.pubsub.asyncIterator(`team/${teamID}/member_removed`);
}
}

View File

@@ -0,0 +1,920 @@
import { TeamService } from './team.service';
import { PrismaService } from '../prisma/prisma.service';
import { Team, TeamMember, TeamMemberRole } from './team.model';
import { TeamMember as DbTeamMember } from '@prisma/client';
import {
USER_NOT_FOUND,
TEAM_INVALID_ID,
TEAM_NAME_INVALID,
TEAM_ONLY_ONE_OWNER,
TEAM_INVALID_ID_OR_USER,
} from '../errors';
import { mockDeep, mockReset } from 'jest-mock-extended';
const mockPrisma = mockDeep<PrismaService>();
const mockUserService = {
getUserWithEmail: jest.fn(),
getUserForUID: jest.fn(),
authenticateWithIDToken: jest.fn(),
};
const mockPubSub = {
publish: jest.fn().mockResolvedValue(null),
};
const teamService = new TeamService(
mockPrisma as any,
mockUserService as any,
mockPubSub as any,
);
beforeEach(async () => {
mockReset(mockPrisma);
});
const team: Team = {
id: 'teamID',
name: 'teamName',
};
const dbTeamMember: DbTeamMember = {
id: 'teamMemberID',
role: TeamMemberRole.VIEWER,
userUid: 'userUid',
teamID: team.id,
};
const teamMember: TeamMember = {
membershipID: dbTeamMember.id,
role: TeamMemberRole[dbTeamMember.role],
userUid: dbTeamMember.userUid,
};
describe('getCountOfUsersWithRoleInTeam', () => {
test('resolves to the correct count of owners in a team', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
await expect(
teamService.getCountOfUsersWithRoleInTeam(
dbTeamMember.teamID,
TeamMemberRole.OWNER,
),
).resolves.toEqual(2);
expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({
where: {
teamID: dbTeamMember.teamID,
role: TeamMemberRole.OWNER,
},
});
});
test('resolves to the correct count of viewers in a team', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
await expect(
teamService.getCountOfUsersWithRoleInTeam(
dbTeamMember.teamID,
TeamMemberRole.VIEWER,
),
).resolves.toEqual(2);
expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({
where: {
teamID: dbTeamMember.teamID,
role: TeamMemberRole.VIEWER,
},
});
});
test('resolves to the correct count of editors in a team', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
await expect(
teamService.getCountOfUsersWithRoleInTeam(
dbTeamMember.teamID,
TeamMemberRole.EDITOR,
),
).resolves.toEqual(2);
expect(mockPrisma.teamMember.count).toHaveBeenCalledWith({
where: {
teamID: dbTeamMember.teamID,
role: TeamMemberRole.EDITOR,
},
});
});
});
describe('addMemberToTeam', () => {
test('resolves when proper team id is given', () => {
mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember);
expect(
teamService.addMemberToTeam(
dbTeamMember.teamID,
dbTeamMember.userUid,
TeamMemberRole[dbTeamMember.role],
),
).resolves.toEqual(expect.objectContaining(teamMember));
});
test('makes the update in the database', async () => {
mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember);
await teamService.addMemberToTeam(
dbTeamMember.teamID,
dbTeamMember.userUid,
TeamMemberRole[dbTeamMember.role],
);
expect(mockPrisma.teamMember.create).toHaveBeenCalledWith({
data: {
userUid: dbTeamMember.userUid,
team: {
connect: {
id: dbTeamMember.teamID,
},
},
role: TeamMemberRole[dbTeamMember.role],
},
});
});
test('fires "team/<team_id>/member_added" pubsub message with correct payload', async () => {
mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember);
const member = await teamService.addMemberToTeam(
dbTeamMember.teamID,
dbTeamMember.userUid,
TeamMemberRole[dbTeamMember.role],
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team/${dbTeamMember.teamID}/member_added`,
member,
);
});
});
describe('addMemberToTeamWithEmail', () => {
afterEach(() => {
mockUserService.getUserWithEmail.mockClear();
mockUserService.authenticateWithIDToken.mockClear();
mockUserService.authenticateWithIDToken.mockClear();
});
test('resolves when user with email exists', () => {
mockUserService.getUserWithEmail.mockResolvedValueOnce({
uid: dbTeamMember.userUid,
});
mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember);
const result = teamService.addMemberToTeamWithEmail(
dbTeamMember.teamID,
'test@hoppscotch.io',
TeamMemberRole[dbTeamMember.role],
);
return expect(result).resolves.toBeDefined();
});
test("rejects with user with email doesn't exist with USER_NOT_FOUND", () => {
mockUserService.getUserWithEmail.mockResolvedValue(null);
const result = teamService.addMemberToTeamWithEmail(
dbTeamMember.teamID,
'test@hoppscotch.io',
TeamMemberRole[dbTeamMember.role],
);
return expect(result).resolves.toEqualLeft(USER_NOT_FOUND);
});
test('makes update in the database', async () => {
mockUserService.getUserWithEmail.mockResolvedValueOnce({
uid: dbTeamMember.userUid,
});
mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember);
await teamService.addMemberToTeamWithEmail(
dbTeamMember.teamID,
'test@hoppscotch.io',
TeamMemberRole[dbTeamMember.role],
);
expect(mockPrisma.teamMember.create).toHaveBeenCalledWith({
data: {
userUid: dbTeamMember.userUid,
team: {
connect: {
id: dbTeamMember.teamID,
},
},
role: TeamMemberRole[dbTeamMember.role],
},
});
});
test('fires "team/<team_id>/member_added" pubsub message with correct payload', async () => {
mockUserService.getUserWithEmail.mockResolvedValueOnce({
uid: dbTeamMember.userUid,
});
mockPrisma.teamMember.create.mockResolvedValue(dbTeamMember);
await teamService.addMemberToTeamWithEmail(
dbTeamMember.teamID,
'test@hoppscotch.io',
TeamMemberRole[dbTeamMember.role],
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team/${dbTeamMember.teamID}/member_added`,
teamMember,
);
});
});
describe('deleteTeam', () => {
test('resolves for proper deletion', async () => {
mockPrisma.team.findUnique.mockResolvedValue(team);
mockPrisma.teamMember.deleteMany.mockResolvedValue({
count: 10,
});
mockPrisma.team.delete.mockResolvedValue(team);
const result = await teamService.deleteTeam(team.id);
return expect(result).toEqualRight(true);
});
test('performs deletion on database', async () => {
mockPrisma.team.findUnique.mockResolvedValue(team);
mockPrisma.teamMember.deleteMany.mockResolvedValue({
count: 10,
});
mockPrisma.team.delete.mockResolvedValue(team);
await teamService.deleteTeam(team.id);
expect(mockPrisma.team.delete).toHaveBeenCalledWith({
where: {
id: team.id,
},
});
});
test('rejects for invalid team id', async () => {
mockPrisma.team.findUnique.mockResolvedValue(null);
// If invalid team ID, team member deletes nothing (count 0)
mockPrisma.teamMember.deleteMany.mockResolvedValue({
count: 0,
});
// TODO: Confirm RecordNotFound works like this
mockPrisma.team.delete.mockRejectedValue('RecordNotFound');
// Team will not find and reject
const result = await teamService.deleteTeam(team.id);
return expect(result).toEqualLeft(TEAM_INVALID_ID);
});
});
describe('renameTeam', () => {
test('resolves for proper rename', () => {
const newTeamName = 'Rename';
mockPrisma.team.update.mockResolvedValue({
...team,
name: newTeamName,
});
return expect(
teamService.renameTeam(team.id, newTeamName),
).resolves.toBeDefined();
});
test('resolves with team structure', () => {
const newTeamName = 'Rename';
mockPrisma.team.update.mockResolvedValue({
...team,
name: newTeamName,
});
return expect(
teamService.renameTeam(team.id, newTeamName),
).resolves.toEqualRight(
expect.objectContaining({
...team,
name: newTeamName,
}),
);
});
test('performs rename on database', async () => {
const newTeamName = 'Rename';
mockPrisma.team.update.mockResolvedValue({
...team,
name: newTeamName,
});
await teamService.renameTeam(team.id, newTeamName);
expect(mockPrisma.team.update).toHaveBeenCalledWith({
where: {
id: team.id,
},
data: {
name: newTeamName,
},
});
});
test('rejects for invalid team id with TEAM_INVALID_ID', () => {
const newTeamName = 'Rename';
// If invalid team id, update fails with RecordNotFound
mockPrisma.team.update.mockRejectedValue('RecordNotFound');
return expect(
teamService.renameTeam(team.id, newTeamName),
).resolves.toEqualLeft(TEAM_INVALID_ID);
});
test('rejects for new team name length < 6 with TEAM_NAME_INVALID', () => {
const newTeamName = 'smol';
// Prisma doesn't care about the team name length, so it will resolve
mockPrisma.team.update.mockResolvedValue({
...team,
name: newTeamName,
});
return expect(
teamService.renameTeam(team.id, newTeamName),
).resolves.toEqualLeft(TEAM_NAME_INVALID);
});
});
describe('updateTeamMemberRole', () => {
/**
* Test Scenario:
* 3 users (testuid1 thru 3) having each of the roles
* (OWNER, VIEWER, EDITOR)
* in Team with id 3170
*/
test('updates the role', async () => {
const newRole = TeamMemberRole.EDITOR;
mockPrisma.teamMember.count.mockResolvedValue(1);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole[dbTeamMember.role],
});
mockPrisma.teamMember.update.mockResolvedValue({
...dbTeamMember,
role: newRole,
});
await teamService.updateTeamMemberRole(
dbTeamMember.teamID,
dbTeamMember.userUid,
newRole,
);
expect(mockPrisma.teamMember.update).toHaveBeenCalledWith({
where: {
teamID_userUid: {
teamID: dbTeamMember.teamID,
userUid: dbTeamMember.userUid,
},
},
data: {
role: newRole,
},
});
});
test('returns the updated details', () => {
const newRole = TeamMemberRole.EDITOR;
mockPrisma.teamMember.count.mockResolvedValue(1);
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
mockPrisma.teamMember.update.mockResolvedValue({
...dbTeamMember,
role: newRole,
});
return expect(
teamService.updateTeamMemberRole(
dbTeamMember.teamID,
dbTeamMember.userUid,
newRole,
),
).resolves.toEqualRight({ ...teamMember, role: newRole });
});
test('rejects if you change the status of the sole owner to non-owner status with TEAM_ONLY_ONE_OWNER', () => {
mockPrisma.teamMember.count.mockResolvedValue(1);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
// Prisma doesn't care if it goes through
mockPrisma.teamMember.update.mockResolvedValue(dbTeamMember);
return expect(
teamService.updateTeamMemberRole(
dbTeamMember.teamID,
dbTeamMember.userUid,
TeamMemberRole[dbTeamMember.role],
),
).resolves.toEqualLeft(TEAM_ONLY_ONE_OWNER);
});
test('resolves if you change the status of the sole owner to owner status (no change)', () => {
mockPrisma.teamMember.count.mockResolvedValue(1);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
mockPrisma.teamMember.update.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
return expect(
teamService.updateTeamMemberRole(
dbTeamMember.teamID,
dbTeamMember.userUid,
TeamMemberRole[TeamMemberRole.OWNER],
),
).resolves.toBeDefined();
});
test('resolves if you change the status of an owner but there are other owners', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
mockPrisma.teamMember.update.mockResolvedValue(dbTeamMember);
// Set another user as the owner
await teamService.updateTeamMemberRole(
dbTeamMember.teamID,
'testuid2',
TeamMemberRole.OWNER,
);
await expect(
teamService.updateTeamMemberRole(
dbTeamMember.teamID,
dbTeamMember.userUid,
TeamMemberRole[dbTeamMember.role],
),
).resolves.toBeDefined();
});
test('fires "team/<team_id>/member_updated" pubsub message with correct payload', async () => {
const newRole = TeamMemberRole.EDITOR;
mockPrisma.teamMember.count.mockResolvedValue(2);
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
mockPrisma.teamMember.update.mockResolvedValue({
...dbTeamMember,
role: newRole,
});
await teamService.updateTeamMemberRole(
dbTeamMember.teamID,
dbTeamMember.userUid,
newRole,
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team/${dbTeamMember.teamID}/member_updated`,
{
...teamMember,
role: newRole,
},
);
});
});
describe('leaveTeam', () => {
/*
Same scenario as above:
3 users (testuid1 thru 3) with respectively
OWNER, VIEWER and EDITOR roles in team with id 3170
*/
test('removes the user if valid credentials given', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
mockPrisma.teamMember.delete.mockResolvedValue(dbTeamMember);
await teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid);
expect(mockPrisma.teamMember.delete).toHaveBeenCalledWith({
where: {
teamID_userUid: {
teamID: dbTeamMember.teamID,
userUid: dbTeamMember.userUid,
},
},
});
});
test('rejects if invalid teamId with TEAM_INVALID_ID_OR_USER', () => {
// Invalid team id will return 0 count
mockPrisma.teamMember.count.mockResolvedValue(0);
// getTeamMember returns null if no match
mockPrisma.teamMember.findUnique.mockResolvedValue(null);
// Deletion rejects with RecordNotFound when no match
mockPrisma.teamMember.delete.mockRejectedValue('RecordNotFound');
return expect(
teamService.leaveTeam('31700', dbTeamMember.userUid),
).resolves.toEqualLeft(TEAM_INVALID_ID_OR_USER);
});
test('rejects if invalid userUid with TEAM_INVALID_ID_OR_USER', () => {
// Invalid team id will return proper count
mockPrisma.teamMember.count.mockResolvedValue(1);
// getTeamMember returns null if no match
mockPrisma.teamMember.findUnique.mockResolvedValue(null);
// Deletion rejects with RecordNotFound when no match
mockPrisma.teamMember.delete.mockRejectedValue('RecordNotFound');
return expect(
teamService.leaveTeam(dbTeamMember.teamID, 'testuid3'),
).resolves.toEqualLeft(TEAM_INVALID_ID_OR_USER);
});
test('rejects if the removed user is the sole owner of the team with TEAM_ONLY_ONE_OWNER', () => {
mockPrisma.teamMember.count.mockResolvedValue(1);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
// Prisma does not care
mockPrisma.teamMember.delete.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
return expect(
teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid),
).resolves.toEqualLeft(TEAM_ONLY_ONE_OWNER);
});
test('resolves if the removed user is an owner (but not the sole) of the team', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
mockPrisma.teamMember.delete.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
await expect(
teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid),
).resolves.toEqualRight(true);
});
test('fires "team/<team_id>/member_removed" pubsub message with correct payload', async () => {
mockPrisma.teamMember.count.mockResolvedValue(2);
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
mockPrisma.teamMember.delete.mockResolvedValue(dbTeamMember);
await teamService.leaveTeam(dbTeamMember.teamID, dbTeamMember.userUid);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`team/${dbTeamMember.teamID}/member_removed`,
dbTeamMember.userUid,
);
});
});
describe('createTeam', () => {
test('adds the new team to the db', async () => {
mockPrisma.team.create.mockResolvedValue(team);
await teamService.createTeam(team.name, dbTeamMember.userUid);
expect(mockPrisma.team.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: team.name,
}),
}),
);
});
test('adds the creator to team and set them as OWNER', async () => {
mockPrisma.team.create.mockResolvedValue(team);
await teamService.createTeam(team.name, dbTeamMember.userUid);
expect(mockPrisma.team.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
members: {
create: {
userUid: dbTeamMember.userUid,
role: TeamMemberRole.OWNER,
},
},
}),
}),
);
});
test('resolves with the team info', () => {
mockPrisma.team.create.mockResolvedValue(team);
return expect(
teamService.createTeam(team.name, dbTeamMember.userUid),
).resolves.toEqualRight(expect.objectContaining(team));
});
test('rejects for team name length < 6 with TEAM_NAME_INVALID', () => {
const newName = 'smol';
// Prisma doesn't care
mockPrisma.team.create.mockResolvedValue({
...team,
name: newName,
});
return expect(
teamService.createTeam(newName, dbTeamMember.userUid),
).resolves.toEqualLeft(TEAM_NAME_INVALID);
});
});
describe('getTeamWithID', () => {
test('resolves for a proper team id with the proper details', () => {
mockPrisma.team.findUnique.mockResolvedValue(team);
return expect(teamService.getTeamWithID(team.id)).resolves.toEqual(
expect.objectContaining(team),
);
});
test('resolves for a invalid team id as null', () => {
// Prisma would reject with RecordNotFound
mockPrisma.team.findUnique.mockRejectedValue('RecordNotFound');
return expect(teamService.getTeamWithID('3171')).resolves.toBeNull();
});
});
describe('getTeamMember', () => {
test('resolves for a proper team id and user uid and returns the info', () => {
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
return expect(
teamService.getTeamMember(dbTeamMember.teamID, dbTeamMember.userUid),
).resolves.toEqual(expect.objectContaining(teamMember));
});
test('resolves for a invalid team id and proper uid and returns null', () => {
// If not found, prisma rejects with RecordNotFound
mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound');
return expect(
teamService.getTeamMember(dbTeamMember.teamID, 'testuid'),
).resolves.toBeNull();
});
});
describe('getRoleOfUserInTeam', () => {
test('resolves with the correct role value', () => {
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
return expect(
teamService.getRoleOfUserInTeam(
dbTeamMember.teamID,
dbTeamMember.userUid,
),
).resolves.toEqual(dbTeamMember.role);
});
test('resolves with null if user is not found in team', () => {
mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound');
return expect(
teamService.getRoleOfUserInTeam(dbTeamMember.teamID, 'nottestuid'),
).resolves.toBeNull();
});
test('resolves with null if team does not exist', () => {
mockPrisma.teamMember.findUnique.mockRejectedValue('RecordNotFound');
return expect(
teamService.getRoleOfUserInTeam('invalidteam', dbTeamMember.userUid),
).resolves.toBeNull();
});
});
describe('getMembersOfTeam', () => {
test('resolves for the team id and null cursor with the first page', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
await teamService.getMembersOfTeam(team.id, null);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
take: 10,
where: {
teamID: team.id,
},
});
});
test('resolves for the team id and proper cursor with pagination', async () => {
const cursor = 'secondpage';
mockPrisma.teamMember.findMany.mockResolvedValue([]);
await teamService.getMembersOfTeam(team.id, cursor);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
take: 10,
skip: 1,
cursor: {
id: cursor,
},
where: {
teamID: team.id,
},
});
});
test('resolves with an empty array for invalid team id and null cursor', () => {
// findMany returns an empty array if no matches are found
mockPrisma.teamMember.findMany.mockResolvedValue([]);
return expect(
teamService.getMembersOfTeam('invalidteamid', null),
).resolves.toHaveLength(0);
});
test('resolves with an empty array for an invalid team id and invalid cursor', () => {
// findMany returns an empty array if no matches are found
mockPrisma.teamMember.findMany.mockResolvedValue([]);
return expect(
teamService.getMembersOfTeam('invalidteamid', 'invalidcursor'),
).resolves.toHaveLength(0);
});
});
describe('getTeamsOfUser', () => {
test('resolves with the first 10 elements when no cursor is given', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
await teamService.getTeamsOfUser(dbTeamMember.userUid, null);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
take: 10,
where: {
userUid: dbTeamMember.userUid,
},
include: {
team: true,
},
});
});
test('resolves as expected for paginated requests with cursor', async () => {
const cursor = 'secondpage';
mockPrisma.teamMember.findMany.mockResolvedValue([]);
await teamService.getTeamsOfUser(dbTeamMember.userUid, cursor);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
take: 10,
skip: 1,
cursor: {
teamID_userUid: {
teamID: cursor,
userUid: dbTeamMember.userUid,
},
},
where: {
userUid: dbTeamMember.userUid,
},
include: {
team: true,
},
});
});
test('resolves with an empty array for an invalid cursor', () => {
// Invalid cursors return an empty array
mockPrisma.teamMember.findMany.mockResolvedValue([]);
return expect(
teamService.getTeamsOfUser(dbTeamMember.userUid, 'invalidcursor'),
).resolves.toHaveLength(0);
});
test('resolves with an empty array for invalid user id and null cursor', () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
return expect(
teamService.getTeamsOfUser('invalidid', null),
).resolves.toHaveLength(0);
});
test('resolves with an empty array for invalid user id and invalid cursor', () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
return expect(
teamService.getTeamsOfUser('invalidId', 'invalidCursor'),
).resolves.toHaveLength(0);
});
});
describe('deleteUserFromAllTeams', () => {
test('should return undefined when a valid uid is passed and user is deleted from all teams', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([dbTeamMember]);
mockPrisma.teamMember.count.mockResolvedValue(2);
mockPrisma.teamMember.findUnique.mockResolvedValue(dbTeamMember);
const result = await teamService.deleteUserFromAllTeams(
dbTeamMember.userUid,
)();
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
where: {
userUid: dbTeamMember.userUid,
},
});
expect(result).toBeUndefined();
});
test('should return undefined when user has no data or the uid is invalid', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([]);
const result = await teamService.deleteUserFromAllTeams(
dbTeamMember.userUid,
)();
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
where: {
userUid: dbTeamMember.userUid,
},
});
expect(result).toBeUndefined();
});
test('should reject when user is an OWNER in a team with only 1 member', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([dbTeamMember]);
mockPrisma.teamMember.count.mockResolvedValue(1);
mockPrisma.teamMember.findUnique.mockResolvedValue({
...dbTeamMember,
role: TeamMemberRole.OWNER,
});
const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid)();
await expect(result).rejects.toThrowError(TEAM_ONLY_ONE_OWNER);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
where: {
userUid: dbTeamMember.userUid,
},
});
});
test('should reject when a valid uid is passed but fetching teamMember details errors out', async () => {
mockPrisma.teamMember.findMany.mockResolvedValue([
{
...dbTeamMember,
role: TeamMemberRole.OWNER,
},
]);
mockPrisma.teamMember.count.mockResolvedValue(2);
// findUnique while getTeamMember() is called errors out
mockPrisma.teamMember.findUnique.mockRejectedValueOnce('NotFoundError');
const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid);
await expect(result).rejects.toThrowError(TEAM_INVALID_ID_OR_USER);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
where: {
userUid: dbTeamMember.userUid,
},
});
});
});

View File

@@ -0,0 +1,492 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { TeamMember, TeamMemberRole, Team } from './team.model';
import { PrismaService } from '../prisma/prisma.service';
import { TeamMember as DbTeamMember } from '@prisma/client';
import { UserService } from '../user/user.service';
import { UserDataHandler } from 'src/user/user.data.handler';
import { User } from 'src/user/user.model';
import {
TEAM_NAME_INVALID,
TEAM_ONLY_ONE_OWNER,
USER_NOT_FOUND,
TEAM_INVALID_ID,
TEAM_INVALID_ID_OR_USER,
TEAM_MEMBER_NOT_FOUND,
USER_IS_OWNER,
} from '../errors';
import { PubSubService } from '../pubsub/pubsub.service';
import { flow, pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as TO from 'fp-ts/TaskOption';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import * as T from 'fp-ts/Task';
import * as A from 'fp-ts/Array';
import { throwErr } from 'src/utils';
@Injectable()
export class TeamService implements UserDataHandler, OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly userService: UserService,
private readonly pubsub: PubSubService,
) {}
onModuleInit() {
this.userService.registerUserDataHandler(this);
}
canAllowUserDeletion(user: User): TO.TaskOption<string> {
return pipe(
this.isUserOwnerRoleInTeams(user.uid),
TO.fromTask,
TO.chain((isOwner) => (isOwner ? TO.some(USER_IS_OWNER) : TO.none)),
);
}
onUserDelete(user: User): T.Task<void> {
return this.deleteUserFromAllTeams(user.uid);
}
async getCountOfUsersWithRoleInTeam(
teamID: string,
role: TeamMemberRole,
): Promise<number> {
return await this.prisma.teamMember.count({
where: {
teamID,
role,
},
});
}
async addMemberToTeamWithEmail(
teamID: string,
email: string,
role: TeamMemberRole,
): Promise<E.Left<string> | E.Right<TeamMember>> {
const user = await this.userService.findUserByEmail(email);
if(O.isNone(user)) return E.left(USER_NOT_FOUND);
const teamMember = await this.addMemberToTeam(teamID, user.value.uid, role);
return E.right(teamMember);
}
async addMemberToTeam(
teamID: string,
uid: string,
role: TeamMemberRole,
): Promise<TeamMember> {
const teamMember = await this.prisma.teamMember.create({
data: {
userUid: uid,
team: {
connect: {
id: teamID,
},
},
role: role,
},
});
const member: TeamMember = {
membershipID: teamMember.id,
userUid: teamMember.userUid,
role: TeamMemberRole[teamMember.role],
};
this.pubsub.publish(`team/${teamID}/member_added`, member);
return member;
}
async deleteTeam(teamID: string): Promise<E.Left<string> | E.Right<boolean>> {
const team = await this.prisma.team.findUnique({
where: {
id: teamID,
},
});
if (!team) return E.left(TEAM_INVALID_ID);
await this.prisma.teamMember.deleteMany({
where: {
teamID: teamID,
},
});
await this.prisma.team.delete({
where: {
id: teamID,
},
});
return E.right(true);
}
validateTeamName(title: string): E.Left<string> | E.Right<boolean> {
if (!title || title.length < 6) return E.left(TEAM_NAME_INVALID);
return E.right(true);
}
async renameTeam(
teamID: string,
newName: string,
): Promise<E.Left<string> | E.Right<Team>> {
const isValidTitle = this.validateTeamName(newName);
if (E.isLeft(isValidTitle)) return isValidTitle;
try {
const updatedTeam = await this.prisma.team.update({
where: {
id: teamID,
},
data: {
name: newName,
},
});
return E.right(updatedTeam);
} catch (e) {
// Prisma update errors out if it can't find the record
return E.left(TEAM_INVALID_ID);
}
}
async updateTeamMemberRole(
teamID: string,
userUid: string,
newRole: TeamMemberRole,
): Promise<E.Left<string> | E.Right<TeamMember>> {
const ownerCount = await this.prisma.teamMember.count({
where: {
teamID,
role: TeamMemberRole.OWNER,
},
});
const member = await this.prisma.teamMember.findUnique({
where: {
teamID_userUid: {
teamID,
userUid,
},
},
});
if (!member) return E.left(TEAM_MEMBER_NOT_FOUND);
if (
member.role === TeamMemberRole.OWNER &&
newRole != TeamMemberRole.OWNER &&
ownerCount === 1
) {
return E.left(TEAM_ONLY_ONE_OWNER);
}
const result = await this.prisma.teamMember.update({
where: {
teamID_userUid: {
teamID,
userUid,
},
},
data: {
role: newRole,
},
});
const updatedMember: TeamMember = {
membershipID: result.id,
userUid: result.userUid,
role: TeamMemberRole[result.role],
};
this.pubsub.publish(`team/${teamID}/member_updated`, updatedMember);
return E.right(updatedMember);
}
async leaveTeam(
teamID: string,
userUid: string,
): Promise<E.Left<string> | E.Right<boolean>> {
const ownerCount = await this.prisma.teamMember.count({
where: {
teamID,
role: TeamMemberRole.OWNER,
},
});
const member = await this.getTeamMember(teamID, userUid);
if (!member) return E.left(TEAM_INVALID_ID_OR_USER);
if (ownerCount === 1 && member.role === TeamMemberRole.OWNER) {
return E.left(TEAM_ONLY_ONE_OWNER);
}
try {
await this.prisma.teamMember.delete({
where: {
teamID_userUid: {
userUid,
teamID,
},
},
});
} catch (e) {
// Record not found
return E.left(TEAM_INVALID_ID_OR_USER);
}
this.pubsub.publish(`team/${teamID}/member_removed`, userUid);
return E.right(true);
}
async createTeam(
name: string,
creatorUid: string,
): Promise<E.Left<string> | E.Right<Team>> {
const isValidName = this.validateTeamName(name);
if (E.isLeft(isValidName)) return isValidName;
const team = await this.prisma.team.create({
data: {
name: name,
members: {
create: {
userUid: creatorUid,
role: TeamMemberRole.OWNER,
},
},
},
});
return E.right(team);
}
async getTeamsOfUser(uid: string, cursor: string | null): Promise<Team[]> {
if (!cursor) {
const entries = await this.prisma.teamMember.findMany({
take: 10,
where: {
userUid: uid,
},
include: {
team: true,
},
});
return entries.map((entry) => entry.team);
} else {
const entries = await this.prisma.teamMember.findMany({
take: 10,
skip: 1,
cursor: {
teamID_userUid: {
teamID: cursor,
userUid: uid,
},
},
where: {
userUid: uid,
},
include: {
team: true,
},
});
return entries.map((entry) => entry.team);
}
}
async getTeamWithID(teamID: string): Promise<Team | null> {
try {
const team = await this.prisma.team.findUnique({
where: {
id: teamID,
},
});
return team;
} catch (_e) {
return null;
}
}
getTeamWithIDTE(teamID: string): TE.TaskEither<'team/invalid_id', Team> {
return pipe(
() => this.getTeamWithID(teamID),
TE.fromTask,
TE.chain(
TE.fromPredicate(
(x): x is Team => !!x,
() => TEAM_INVALID_ID,
),
),
);
}
/**
* Filters out team members that we weren't able to match
* (also deletes the membership)
* @param members Members to filter against
*/
async filterMismatchedUsers(
teamID: string,
members: TeamMember[],
): Promise<TeamMember[]> {
const memberUsers = await Promise.all(
members.map(async (member) => {
const user = await this.userService.findUserById(member.userUid);
// // TODO:Investigate if a race condition exists that deletes user from teams.
// // Delete the membership if the user doesnt exist
// if (!user) this.leaveTeam(teamID, member.userUid);
if (O.isSome(user)) return member;
else return null;
}),
);
return memberUsers.filter((x) => x !== null) as TeamMember[];
}
async getTeamMember(
teamID: string,
userUid: string,
): Promise<TeamMember | null> {
try {
const teamMember = await this.prisma.teamMember.findUnique({
where: {
teamID_userUid: {
teamID,
userUid,
},
},
});
if (!teamMember) return null;
return <TeamMember>{
membershipID: teamMember.id,
userUid: userUid,
role: TeamMemberRole[teamMember.role],
};
} catch (e) {
return null;
}
}
getTeamMemberTE(teamID: string, userUid: string) {
return pipe(
() => this.getTeamMember(teamID, userUid),
TE.fromTask,
TE.chain(
TE.fromPredicate(
(x): x is TeamMember => !!x,
() => TEAM_MEMBER_NOT_FOUND,
),
),
);
}
async getRoleOfUserInTeam(
teamID: string,
userUid: string,
): Promise<TeamMemberRole | null> {
const teamMember = await this.getTeamMember(teamID, userUid);
return teamMember ? teamMember.role : null;
}
isUserOwnerRoleInTeams(uid: string): T.Task<boolean> {
return pipe(
() =>
this.prisma.teamMember.count({
where: {
userUid: uid,
role: TeamMemberRole.OWNER,
},
take: 1,
}),
T.map((count) => count > 0),
);
}
deleteUserFromAllTeams(uid: string) {
return pipe(
() =>
this.prisma.teamMember.findMany({
where: {
userUid: uid,
},
}),
T.chainFirst(
flow(
A.map((member) => async () => {
const res = await this.leaveTeam(member.teamID, uid);
if (E.isLeft(res)) throwErr(res.left);
return E.right(res);
}),
T.sequenceArray,
),
),
T.map(() => undefined),
);
}
async getTeamMembers(teamID: string): Promise<TeamMember[]> {
const dbTeamMembers = await this.prisma.teamMember.findMany({
where: {
teamID,
},
});
const members = dbTeamMembers.map(
(entry) =>
<TeamMember>{
membershipID: entry.id,
userUid: entry.userUid,
role: TeamMemberRole[entry.role],
},
);
return this.filterMismatchedUsers(teamID, members);
}
async getMembersOfTeam(
teamID: string,
cursor: string | null,
): Promise<TeamMember[]> {
let teamMembers: DbTeamMember[];
if (!cursor) {
teamMembers = await this.prisma.teamMember.findMany({
take: 10,
where: {
teamID,
},
});
} else {
teamMembers = await this.prisma.teamMember.findMany({
take: 10,
skip: 1,
cursor: {
id: cursor,
},
where: {
teamID,
},
});
}
const members = teamMembers.map(
(entry) =>
<TeamMember>{
membershipID: entry.id,
userUid: entry.userUid,
role: TeamMemberRole[entry.role],
},
);
return this.filterMismatchedUsers(teamID, members);
}
}

View File

@@ -0,0 +1,11 @@
import * as T from "fp-ts/Task"
import * as TO from "fp-ts/TaskOption"
import { User } from "src/user/user.model"
/**
* Defines how external services should handle User Data and User data related operations and actions.
*/
export interface UserDataHandler {
canAllowUserDeletion: (user: User) => TO.TaskOption<string>
onUserDelete: (user: User) => T.Task<void>
}

View File

@@ -8,6 +8,7 @@ import { SessionType, User } from './user.model';
import { USER_UPDATE_FAILED } from 'src/errors';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { stringToJson } from 'src/utils';
import { UserDataHandler } from './user.data.handler';
@Injectable()
export class UserService {
@@ -16,13 +17,19 @@ export class UserService {
private readonly pubsub: PubSubService,
) {}
private userDataHandlers: UserDataHandler[] = [];
registerUserDataHandler(handler: UserDataHandler) {
this.userDataHandlers.push(handler);
}
/**
* Find User with given email id
*
* @param email User's email
* @returns Option of found User
*/
async findUserByEmail(email: string) {
async findUserByEmail(email: string): Promise<O.None | O.Some<AuthUser>> {
try {
const user = await this.prisma.user.findUniqueOrThrow({
where: {
@@ -41,7 +48,7 @@ export class UserService {
* @param userUid User ID
* @returns Option of found User
*/
async findUserById(userUid: string) {
async findUserById(userUid: string): Promise<O.None | O.Some<AuthUser>> {
try {
const user = await this.prisma.user.findUniqueOrThrow({
where: {