From 525ba777391fda646f9fe8914a67e4d7c725f01a Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Thu, 13 Jul 2023 12:28:03 +0600 Subject: [PATCH] refactor: team invitation module in pseudo fp-ts (#3175) --- .../src/admin/admin.service.ts | 4 +- .../src/auth/auth.service.ts | 2 +- .../src/mailer/mailer.service.ts | 26 +- .../src/team-invitation/input-type.args.ts | 20 + .../team-invitation.resolver.ts | 116 ++---- .../team-invitation.service.ts | 390 ++++++++---------- .../team-invite-team-owner.guard.ts | 64 ++- .../team-invite-viewer.guard.ts | 73 ++-- .../src/team-invitation/team-invitee.guard.ts | 54 +-- .../team-teaminvite-ext.resolver.ts | 2 +- 10 files changed, 299 insertions(+), 452 deletions(-) create mode 100644 packages/hoppscotch-backend/src/team-invitation/input-type.args.ts diff --git a/packages/hoppscotch-backend/src/admin/admin.service.ts b/packages/hoppscotch-backend/src/admin/admin.service.ts index 7a0c7305e..ab1f1dc9c 100644 --- a/packages/hoppscotch-backend/src/admin/admin.service.ts +++ b/packages/hoppscotch-backend/src/admin/admin.service.ts @@ -181,7 +181,7 @@ export class AdminService { * @returns an array team invitations */ async pendingInvitationCountInTeam(teamID: string) { - const invitations = await this.teamInvitationService.getAllTeamInvitations( + const invitations = await this.teamInvitationService.getTeamInvitations( teamID, ); @@ -257,7 +257,7 @@ export class AdminService { if (E.isRight(userInvitation)) { await this.teamInvitationService.revokeInvitation( userInvitation.right.id, - )(); + ); } return E.right(addedUser.right); diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts index 6c5beae5e..63e59ac2a 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -228,7 +228,7 @@ export class AuthService { url = process.env.VITE_BASE_URL; } - await this.mailerService.sendAuthEmail(email, { + await this.mailerService.sendEmail(email, { template: 'code-your-own', variables: { inviteeEmail: email, diff --git a/packages/hoppscotch-backend/src/mailer/mailer.service.ts b/packages/hoppscotch-backend/src/mailer/mailer.service.ts index 8a5b61075..8a03d6e57 100644 --- a/packages/hoppscotch-backend/src/mailer/mailer.service.ts +++ b/packages/hoppscotch-backend/src/mailer/mailer.service.ts @@ -5,7 +5,6 @@ import { UserMagicLinkMailDescription, } from './MailDescriptions'; import { throwErr } from 'src/utils'; -import * as TE from 'fp-ts/TaskEither'; import { EMAIL_FAILED } from 'src/errors'; import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; @@ -35,33 +34,14 @@ export class MailerService { /** * Sends an email to the given email address given a mail description - * @param to The email address to be sent to (NOTE: this is not validated) + * @param to Receiver's email id * @param mailDesc Definition of what email to be sent + * @returns Response if email was send successfully or not */ - sendMail( + async sendEmail( to: string, mailDesc: MailDescription | UserMagicLinkMailDescription, ) { - return TE.tryCatch( - async () => { - await this.nestMailerService.sendMail({ - to, - template: mailDesc.template, - subject: this.resolveSubjectForMailDesc(mailDesc), - context: mailDesc.variables, - }); - }, - () => EMAIL_FAILED, - ); - } - - /** - * - * @param to Receiver's email id - * @param mailDesc Details of email to be sent for Magic-Link auth - * @returns Response if email was send successfully or not - */ - async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) { try { await this.nestMailerService.sendMail({ to, diff --git a/packages/hoppscotch-backend/src/team-invitation/input-type.args.ts b/packages/hoppscotch-backend/src/team-invitation/input-type.args.ts new file mode 100644 index 000000000..9cb3f2987 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/input-type.args.ts @@ -0,0 +1,20 @@ +import { ArgsType, Field, ID } from '@nestjs/graphql'; +import { TeamMemberRole } from 'src/team/team.model'; + +@ArgsType() +export class CreateTeamInvitationArgs { + @Field(() => ID, { + name: 'teamID', + description: 'ID of the Team ID to invite from', + }) + teamID: string; + + @Field({ name: 'inviteeEmail', description: 'Email of the user to invite' }) + inviteeEmail: string; + + @Field(() => TeamMemberRole, { + name: 'inviteeRole', + description: 'Role to be given to the user', + }) + inviteeRole: TeamMemberRole; +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts index e11df0313..32a903fa6 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts @@ -12,15 +12,10 @@ import { TeamInvitation } from './team-invitation.model'; import { TeamInvitationService } from './team-invitation.service'; import { pipe } from 'fp-ts/function'; import * as TE from 'fp-ts/TaskEither'; +import * as E from 'fp-ts/Either'; import * as O from 'fp-ts/Option'; import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model'; -import { EmailCodec } from 'src/types/Email'; -import { - INVALID_EMAIL, - TEAM_INVITE_EMAIL_DO_NOT_MATCH, - TEAM_INVITE_NO_INVITE_FOUND, - USER_NOT_FOUND, -} from 'src/errors'; +import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { User } from 'src/user/user.model'; import { UseGuards } from '@nestjs/common'; @@ -36,6 +31,8 @@ import { UserService } from 'src/user/user.service'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { SkipThrottle } from '@nestjs/throttler'; +import { AuthUser } from 'src/types/AuthUser'; +import { CreateTeamInvitationArgs } from './input-type.args'; @UseGuards(GqlThrottlerGuard) @Resolver(() => TeamInvitation) @@ -79,8 +76,8 @@ export class TeamInvitationResolver { 'Gets the Team Invitation with the given ID, or null if not exists', }) @UseGuards(GqlAuthGuard, TeamInviteViewerGuard) - teamInvitation( - @GqlUser() user: User, + async teamInvitation( + @GqlUser() user: AuthUser, @Args({ name: 'inviteID', description: 'ID of the Team Invitation to lookup', @@ -88,17 +85,11 @@ export class TeamInvitationResolver { }) inviteID: string, ): Promise { - return pipe( - this.teamInvitationService.getInvitation(inviteID), - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), - TE.chainW( - TE.fromPredicate( - (a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(), - () => TEAM_INVITE_EMAIL_DO_NOT_MATCH, - ), - ), - TE.getOrElse(throwErr), - )(); + const teamInvitation = await this.teamInvitationService.getInvitation( + inviteID, + ); + if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); + return teamInvitation.value; } @Mutation(() => TeamInvitation, { @@ -106,56 +97,19 @@ export class TeamInvitationResolver { }) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @RequiresTeamRole(TeamMemberRole.OWNER) - createTeamInvitation( - @GqlUser() - user: User, - - @Args({ - name: 'teamID', - description: 'ID of the Team ID to invite from', - type: () => ID, - }) - teamID: string, - @Args({ - name: 'inviteeEmail', - description: 'Email of the user to invite', - }) - inviteeEmail: string, - @Args({ - name: 'inviteeRole', - type: () => TeamMemberRole, - description: 'Role to be given to the user', - }) - inviteeRole: TeamMemberRole, + async createTeamInvitation( + @GqlUser() user: AuthUser, + @Args() args: CreateTeamInvitationArgs, ): Promise { - return pipe( - TE.Do, + const teamInvitation = await this.teamInvitationService.createInvitation( + user, + args.teamID, + args.inviteeEmail, + args.inviteeRole, + ); - // Validate email - TE.bindW('email', () => - pipe( - EmailCodec.decode(inviteeEmail), - TE.fromEither, - TE.mapLeft(() => INVALID_EMAIL), - ), - ), - - // Validate and get Team - TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)), - - // Create team - TE.chainW(({ email, team }) => - this.teamInvitationService.createInvitation( - user, - team, - email, - inviteeRole, - ), - ), - - // If failed, throw err (so the message is passed) else return value - TE.getOrElse(throwErr), - )(); + if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left); + return teamInvitation.right; } @Mutation(() => Boolean, { @@ -163,7 +117,7 @@ export class TeamInvitationResolver { }) @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) @RequiresTeamRole(TeamMemberRole.OWNER) - revokeTeamInvitation( + async revokeTeamInvitation( @Args({ name: 'inviteID', type: () => ID, @@ -171,19 +125,19 @@ export class TeamInvitationResolver { }) inviteID: string, ): Promise { - return pipe( - this.teamInvitationService.revokeInvitation(inviteID), - TE.map(() => true as const), - TE.getOrElse(throwErr), - )(); + const isRevoked = await this.teamInvitationService.revokeInvitation( + inviteID, + ); + if (E.isLeft(isRevoked)) throwErr(isRevoked.left); + return true; } @Mutation(() => TeamMember, { description: 'Accept an Invitation', }) @UseGuards(GqlAuthGuard, TeamInviteeGuard) - acceptTeamInvitation( - @GqlUser() user: User, + async acceptTeamInvitation( + @GqlUser() user: AuthUser, @Args({ name: 'inviteID', type: () => ID, @@ -191,10 +145,12 @@ export class TeamInvitationResolver { }) inviteID: string, ): Promise { - return pipe( - this.teamInvitationService.acceptInvitation(inviteID, user), - TE.getOrElse(throwErr), - )(); + const teamMember = await this.teamInvitationService.acceptInvitation( + inviteID, + user, + ); + if (E.isLeft(teamMember)) throwErr(teamMember.left); + return teamMember.right; } // Subscriptions diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts index 0253d371e..392d82e97 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts @@ -1,27 +1,25 @@ import { Injectable } from '@nestjs/common'; -import * as T from 'fp-ts/Task'; import * as O from 'fp-ts/Option'; -import * as TO from 'fp-ts/TaskOption'; -import * as TE from 'fp-ts/TaskEither'; import * as E from 'fp-ts/Either'; -import { pipe, flow, constVoid } from 'fp-ts/function'; import { PrismaService } from 'src/prisma/prisma.service'; -import { Team, TeamMemberRole } from 'src/team/team.model'; -import { Email } from 'src/types/Email'; -import { User } from 'src/user/user.model'; +import { TeamInvitation as DBTeamInvitation } from '@prisma/client'; +import { TeamMember, TeamMemberRole } from 'src/team/team.model'; import { TeamService } from 'src/team/team.service'; import { INVALID_EMAIL, + TEAM_INVALID_ID, TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_EMAIL_DO_NOT_MATCH, TEAM_INVITE_MEMBER_HAS_INVITE, TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, } from 'src/errors'; import { TeamInvitation } from './team-invitation.model'; import { MailerService } from 'src/mailer/mailer.service'; import { UserService } from 'src/user/user.service'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { validateEmail } from '../utils'; +import { AuthUser } from 'src/types/AuthUser'; @Injectable() export class TeamInvitationService { @@ -32,38 +30,37 @@ export class TeamInvitationService { private readonly mailerService: MailerService, private readonly pubsub: PubSubService, - ) { - this.getInvitation = this.getInvitation.bind(this); + ) {} + + /** + * Cast a DBTeamInvitation to a TeamInvitation + * @param dbTeamInvitation database TeamInvitation + * @returns TeamInvitation model + */ + cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation { + return { + ...dbTeamInvitation, + inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole], + }; } - getInvitation(inviteID: string): TO.TaskOption { - return pipe( - () => - this.prisma.teamInvitation.findUnique({ - where: { - id: inviteID, - }, - }), - TO.fromTask, - TO.chain(flow(O.fromNullable, TO.fromOption)), - TO.map((x) => x as TeamInvitation), - ); - } + /** + * Get the team invite + * @param inviteID invite id + * @returns an Option of team invitation or none + */ + async getInvitation(inviteID: string) { + try { + const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({ + where: { + id: inviteID, + }, + }); - getInvitationWithEmail(email: Email, team: Team) { - return pipe( - () => - this.prisma.teamInvitation.findUnique({ - where: { - teamID_inviteeEmail: { - inviteeEmail: email, - teamID: team.id, - }, - }, - }), - TO.fromTask, - TO.chain(flow(O.fromNullable, TO.fromOption)), - ); + return O.some(this.cast(dbInvitation)); + } catch (e) { + return O.none; + } } /** @@ -92,213 +89,162 @@ export class TeamInvitationService { } } - createInvitation( - creator: User, - team: Team, - inviteeEmail: Email, + /** + * Create a team invitation + * @param creator creator of the invitation + * @param teamID team id + * @param inviteeEmail invitee email + * @param inviteeRole invitee role + * @returns an Either of team invitation or error message + */ + async createInvitation( + creator: AuthUser, + teamID: string, + inviteeEmail: string, inviteeRole: TeamMemberRole, ) { - return pipe( - // Perform all validation checks - TE.sequenceArray([ - // creator should be a TeamMember - pipe( - this.teamService.getTeamMemberTE(team.id, creator.uid), - TE.map(constVoid), - ), + // validate email + const isEmailValid = validateEmail(inviteeEmail); + if (!isEmailValid) return E.left(INVALID_EMAIL); - // Invitee should not be a team member - pipe( - async () => await this.userService.findUserByEmail(inviteeEmail), - TO.foldW( - () => TE.right(undefined), // If no user, short circuit to completion - (user) => - pipe( - // If user is found, check if team member - this.teamService.getTeamMemberTE(team.id, user.uid), - TE.foldW( - () => TE.right(undefined), // Not team-member, this is good - () => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good - ), - ), - ), - TE.map(constVoid), - ), + // team ID should valid + const team = await this.teamService.getTeamWithID(teamID); + if (!team) return E.left(TEAM_INVALID_ID); - // Should not have an existing invite - pipe( - this.getInvitationWithEmail(inviteeEmail, team), - TE.fromTaskOption(() => null), - TE.swap, - TE.map(constVoid), - TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE), - ), - ]), - - // Create the invitation - TE.chainTaskK( - () => () => - this.prisma.teamInvitation.create({ - data: { - teamID: team.id, - inviteeEmail, - inviteeRole, - creatorUid: creator.uid, - }, - }), - ), - - // Send email, this is a side effect - TE.chainFirstTaskK((invitation) => - pipe( - this.mailerService.sendMail(inviteeEmail, { - template: 'team-invitation', - variables: { - invitee: creator.displayName ?? 'A Hoppscotch User', - action_url: `${process.env.VITE_BASE_URL}/join-team?id=${invitation.id}`, - invite_team_name: team.name, - }, - }), - - TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes - ), - ), - - // Send PubSub topic - TE.chainFirstTaskK((invitation) => - TE.fromTask(async () => { - const inv: TeamInvitation = { - id: invitation.id, - teamID: invitation.teamID, - creatorUid: invitation.creatorUid, - inviteeEmail: invitation.inviteeEmail, - inviteeRole: TeamMemberRole[invitation.inviteeRole], - }; - - this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv); - }), - ), - - // Map to model type - TE.map((x) => x as TeamInvitation), + // invitation creator should be a TeamMember + const isTeamMember = await this.teamService.getTeamMember( + team.id, + creator.uid, ); - } + if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND); - revokeInvitation(inviteID: string) { - return pipe( - // Make sure invite exists - this.getInvitation(inviteID), - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), + // Checking to see if the invitee is already part of the team or not + const inviteeUser = await this.userService.findUserByEmail(inviteeEmail); + if (O.isSome(inviteeUser)) { + // invitee should not already a member + const isTeamMember = await this.teamService.getTeamMember( + team.id, + inviteeUser.value.uid, + ); + if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER); + } - // Delete team invitation - TE.chainTaskK( - () => () => - this.prisma.teamInvitation.delete({ - where: { - id: inviteID, - }, - }), - ), - - // Emit Pubsub Event - TE.chainFirst((invitation) => - TE.fromTask(() => - this.pubsub.publish( - `team/${invitation.teamID}/invite_removed`, - invitation.id, - ), - ), - ), - - // We are not returning anything - TE.map(constVoid), + // check invitee already invited earlier or not + const teamInvitation = await this.getTeamInviteByEmailAndTeamID( + inviteeEmail, + team.id, ); - } + if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE); - getAllInvitationsInTeam(team: Team) { - return pipe( - () => - this.prisma.teamInvitation.findMany({ - where: { - teamID: team.id, - }, - }), - T.map((x) => x as TeamInvitation[]), - ); - } + // create the invitation + const dbInvitation = await this.prisma.teamInvitation.create({ + data: { + teamID: team.id, + inviteeEmail, + inviteeRole, + creatorUid: creator.uid, + }, + }); - acceptInvitation(inviteID: string, acceptedBy: User) { - return pipe( - TE.Do, + await this.mailerService.sendEmail(inviteeEmail, { + template: 'team-invitation', + variables: { + invitee: creator.displayName ?? 'A Hoppscotch User', + action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`, + invite_team_name: team.name, + }, + }); - // First get the invitation - TE.bindW('invitation', () => - pipe( - this.getInvitation(inviteID), - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), - ), - ), + const invitation = this.cast(dbInvitation); + this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation); - // Validation checks - TE.chainFirstW(({ invitation }) => - TE.sequenceArray([ - // Make sure the invited user is not part of the team - pipe( - this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid), - TE.swap, - TE.bimap( - () => TEAM_INVITE_ALREADY_MEMBER, - constVoid, // The return type is ignored - ), - ), - - // Make sure the invited user and accepting user has the same email - pipe( - undefined, - TE.fromPredicate( - () => - acceptedBy.email.toLowerCase() === - invitation.inviteeEmail.toLowerCase(), - () => TEAM_INVITE_EMAIL_DO_NOT_MATCH, - ), - ), - ]), - ), - - // Add the team member - // TODO: Somehow bring subscriptions to this ? - TE.bindW('teamMember', ({ invitation }) => - pipe( - TE.tryCatch( - () => - this.teamService.addMemberToTeam( - invitation.teamID, - acceptedBy.uid, - invitation.inviteeRole, - ), - () => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too - ), - ), - ), - - TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)), - - TE.map(({ teamMember }) => teamMember), - ); + return E.right(invitation); } /** - * Fetch the count invitations for a given team. - * @param teamID team id - * @returns a count team invitations for a team + * Revoke a team invitation + * @param inviteID invite id + * @returns an Either of true or error message */ - async getAllTeamInvitations(teamID: string) { - const invitations = await this.prisma.teamInvitation.findMany({ + async revokeInvitation(inviteID: string) { + // check if the invite exists + const invitation = await this.getInvitation(inviteID); + if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND); + + // delete the invite + await this.prisma.teamInvitation.delete({ + where: { + id: inviteID, + }, + }); + + this.pubsub.publish( + `team/${invitation.value.teamID}/invite_removed`, + invitation.value.id, + ); + + return E.right(true); + } + + /** + * Accept a team invitation + * @param inviteID invite id + * @param acceptedBy user who accepted the invitation + * @returns an Either of team member or error message + */ + async acceptInvitation(inviteID: string, acceptedBy: AuthUser) { + // check if the invite exists + const invitation = await this.getInvitation(inviteID); + if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND); + + // make sure the user is not already a member of the team + const teamMemberInvitee = await this.teamService.getTeamMember( + invitation.value.teamID, + acceptedBy.uid, + ); + if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER); + + // make sure the user is the same as the invitee + if ( + acceptedBy.email.toLowerCase() !== + invitation.value.inviteeEmail.toLowerCase() + ) + return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH); + + // add the user to the team + let teamMember: TeamMember; + try { + teamMember = await this.teamService.addMemberToTeam( + invitation.value.teamID, + acceptedBy.uid, + invitation.value.inviteeRole, + ); + } catch (e) { + return E.left(TEAM_INVITE_ALREADY_MEMBER); + } + + // delete the invite + await this.revokeInvitation(inviteID); + + return E.right(teamMember); + } + + /** + * Fetch all team invitations for a given team. + * @param teamID team id + * @returns array of team invitations for a team + */ + async getTeamInvitations(teamID: string) { + const dbInvitations = await this.prisma.teamInvitation.findMany({ where: { teamID: teamID, }, }); + const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) => + this.cast(dbInvitation), + ); + return invitations; } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts index f029ae63f..571872eab 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts @@ -1,21 +1,21 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { pipe } from 'fp-ts/function'; import { TeamService } from 'src/team/team.service'; import { TeamInvitationService } from './team-invitation.service'; import * as O from 'fp-ts/Option'; -import * as T from 'fp-ts/Task'; -import * as TE from 'fp-ts/TaskEither'; import { GqlExecutionContext } from '@nestjs/graphql'; import { BUG_AUTH_NO_USER_CTX, BUG_TEAM_INVITE_NO_INVITE_ID, TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, TEAM_NOT_REQUIRED_ROLE, } from 'src/errors'; -import { User } from 'src/user/user.model'; import { throwErr } from 'src/utils'; import { TeamMemberRole } from 'src/team/team.model'; +/** + * This guard only allows team owner to execute the resolver + */ @Injectable() export class TeamInviteTeamOwnerGuard implements CanActivate { constructor( @@ -24,48 +24,30 @@ export class TeamInviteTeamOwnerGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - return pipe( - TE.Do, + // Get GQL context + const gqlExecCtx = GqlExecutionContext.create(context); - TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); - // Get the invite - TE.bindW('invite', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID), - TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID), - TE.chainW((inviteID) => - pipe( - this.teamInviteService.getInvitation(inviteID), - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), - ), - ), - ), - ), + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); - TE.bindW('user', ({ gqlCtx }) => - pipe( - gqlCtx.getContext().req.user, - O.fromNullable, - TE.fromOption(() => BUG_AUTH_NO_USER_CTX), - ), - ), + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); - TE.bindW('userMember', ({ invite, user }) => - this.teamService.getTeamMemberTE(invite.teamID, user.uid), - ), + // Fetch team member details of this user + const teamMember = await this.teamService.getTeamMember( + invitation.value.teamID, + user.uid, + ); - TE.chainW( - TE.fromPredicate( - ({ userMember }) => userMember.role === TeamMemberRole.OWNER, - () => TEAM_NOT_REQUIRED_ROLE, - ), - ), + if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND); + if (teamMember.role !== TeamMemberRole.OWNER) + throwErr(TEAM_NOT_REQUIRED_ROLE); - TE.fold( - (err) => throwErr(err), - () => T.of(true), - ), - )(); + return true; } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts index 271d3031e..8be6f8a99 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts @@ -1,20 +1,23 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { TeamInvitationService } from './team-invitation.service'; -import { pipe, flow } from 'fp-ts/function'; -import * as TE from 'fp-ts/TaskEither'; -import * as T from 'fp-ts/Task'; import * as O from 'fp-ts/Option'; import { GqlExecutionContext } from '@nestjs/graphql'; import { BUG_AUTH_NO_USER_CTX, BUG_TEAM_INVITE_NO_INVITE_ID, - TEAM_INVITE_NOT_VALID_VIEWER, TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, } from 'src/errors'; -import { User } from 'src/user/user.model'; import { throwErr } from 'src/utils'; import { TeamService } from 'src/team/team.service'; +/** + * This guard only allows user to execute the resolver + * 1. If user is invitee, allow + * 2. Or else, if user is team member, allow + * + * TLDR: Allow if user is invitee or team member + */ @Injectable() export class TeamInviteViewerGuard implements CanActivate { constructor( @@ -23,50 +26,32 @@ export class TeamInviteViewerGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - return pipe( - TE.Do, + // Get GQL context + const gqlExecCtx = GqlExecutionContext.create(context); - // Get GQL Context - TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); - // Get user - TE.bindW('user', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getContext().req.user), - TE.fromOption(() => BUG_AUTH_NO_USER_CTX), - ), - ), + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); - // Get the invite - TE.bindW('invite', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID), - TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID), - TE.chainW( - flow( - this.teamInviteService.getInvitation, - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), - ), - ), - ), - ), + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); - // Check if the user and the invite email match, else if we can resolver the user as a team member - // any better solution ? - TE.chainW(({ user, invite }) => - user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase() - ? TE.of(true) - : pipe( - this.teamService.getTeamMemberTE(invite.teamID, user.uid), - TE.map(() => true), - ), - ), + // Check if the user and the invite email match, else if user is a team member + if ( + user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() + ) { + const teamMember = await this.teamService.getTeamMember( + invitation.value.teamID, + user.uid, + ); - TE.mapLeft((e) => - e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e, - ), + if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND); + } - TE.fold(throwErr, () => T.of(true)), - )(); + return true; } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts index b87615afa..c1ce61d88 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts @@ -1,9 +1,6 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { TeamInvitationService } from './team-invitation.service'; -import { pipe, flow } from 'fp-ts/function'; import * as O from 'fp-ts/Option'; -import * as T from 'fp-ts/Task'; -import * as TE from 'fp-ts/TaskEither'; import { GqlExecutionContext } from '@nestjs/graphql'; import { BUG_AUTH_NO_USER_CTX, @@ -23,45 +20,26 @@ export class TeamInviteeGuard implements CanActivate { constructor(private readonly teamInviteService: TeamInvitationService) {} async canActivate(context: ExecutionContext): Promise { - return pipe( - TE.Do, + // Get GQL Context + const gqlExecCtx = GqlExecutionContext.create(context); - // Get execution context - TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); - // Get user - TE.bindW('user', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getContext().req.user), - TE.fromOption(() => BUG_AUTH_NO_USER_CTX), - ), - ), + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); - // Get invite - TE.bindW('invite', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID), - TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID), - TE.chainW( - flow( - this.teamInviteService.getInvitation, - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), - ), - ), - ), - ), + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); - // Check if the emails match - TE.chainW( - TE.fromPredicate( - ({ user, invite }) => - user.email.toLowerCase() === invite.inviteeEmail.toLowerCase(), - () => TEAM_INVITE_EMAIL_DO_NOT_MATCH, - ), - ), + if ( + user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() + ) { + throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH); + } - // Fold it to a promise - TE.fold(throwErr, () => T.of(true)), - )(); + return true; } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts b/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts index b6ebe298c..c579c081b 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts @@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver { complexity: 10, }) teamInvitations(@Parent() team: Team): Promise { - return this.teamInviteService.getAllInvitationsInTeam(team)(); + return this.teamInviteService.getTeamInvitations(team.id); } }