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 { 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'; import { AuthUser } from '../types/AuthUser'; @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: AuthUser): TO.TaskOption { return pipe( this.isUserOwnerRoleInTeams(user.uid), TO.fromTask, TO.chain((isOwner) => (isOwner ? TO.some(USER_IS_OWNER) : TO.none)), ); } onUserDelete(user: AuthUser): T.Task { return this.deleteUserFromAllTeams(user.uid); } async getCountOfUsersWithRoleInTeam( teamID: string, role: TeamMemberRole, ): Promise { return await this.prisma.teamMember.count({ where: { teamID, role, }, }); } async addMemberToTeamWithEmail( teamID: string, email: string, role: TeamMemberRole, ): Promise | E.Right> { 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 { 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.Right> { 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 | E.Right { if (!title || title.length < 6) return E.left(TEAM_NAME_INVALID); return E.right(true); } async renameTeam( teamID: string, newName: string, ): Promise | E.Right> { 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.Right> { 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.Right> { 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.Right> { 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 { 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 { 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 { 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 { try { const teamMember = await this.prisma.teamMember.findUnique({ where: { teamID_userUid: { teamID, userUid, }, }, }); if (!teamMember) return null; return { 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 { const teamMember = await this.getTeamMember(teamID, userUid); return teamMember ? teamMember.role : null; } isUserOwnerRoleInTeams(uid: string): T.Task { 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 { const dbTeamMembers = await this.prisma.teamMember.findMany({ where: { teamID, }, }); const members = dbTeamMembers.map( (entry) => { membershipID: entry.id, userUid: entry.userUid, role: TeamMemberRole[entry.role], }, ); return this.filterMismatchedUsers(teamID, members); } /** * Get a count of members in a team * @param teamID Team ID * @returns a count of members in a team */ async getCountOfMembersInTeam(teamID: string) { const memberCount = await this.prisma.teamMember.count({ where: { teamID: teamID, }, }); return memberCount; } async getMembersOfTeam( teamID: string, cursor: string | null, ): Promise { 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) => { membershipID: entry.id, userUid: entry.userUid, role: TeamMemberRole[entry.role], }, ); return this.filterMismatchedUsers(teamID, members); } /** * Fetch all the teams in the `Team` table based on cursor * @param cursorID string of teamID or undefined * @param take number of items to query * @returns an array of `Team` object */ async fetchAllTeams(cursorID: string, take: number) { const options = { skip: cursorID ? 1 : 0, take: take, cursor: cursorID ? { id: cursorID } : undefined, }; const fetchedTeams = await this.prisma.team.findMany(options); return fetchedTeams; } /** * Fetch list of all the Teams in the DB * * @returns number of teams in the org */ async getTeamsCount() { const teamsCount = await this.prisma.team.count(); return teamsCount; } }