Browse Source

refactor: team invitation module in pseudo fp-ts (#3175)

Mir Arif Hasan 1 year ago
parent
commit
525ba77739

+ 2 - 2
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);

+ 1 - 1
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,

+ 3 - 23
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,

+ 20 - 0
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;
+}

+ 37 - 81
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<TeamInvitation> {
-    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<TeamInvitation> {
-    return pipe(
-      TE.Do,
-
-      // 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),
-    )();
+    const teamInvitation = await this.teamInvitationService.createInvitation(
+      user,
+      args.teamID,
+      args.inviteeEmail,
+      args.inviteeRole,
+    );
+
+    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<true> {
-    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<TeamMember> {
-    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

+ 159 - 213
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);
-  }
+  ) {}
 
-  getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
-    return pipe(
-      () =>
-        this.prisma.teamInvitation.findUnique({
-          where: {
-            id: inviteID,
-          },
-        }),
-      TO.fromTask,
-      TO.chain(flow(O.fromNullable, TO.fromOption)),
-      TO.map((x) => x as TeamInvitation),
-    );
+  /**
+   * Cast a DBTeamInvitation to a TeamInvitation
+   * @param dbTeamInvitation database TeamInvitation
+   * @returns TeamInvitation model
+   */
+  cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
+    return {
+      ...dbTeamInvitation,
+      inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
+    };
   }
 
-  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)),
-    );
+  /**
+   * 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,
+        },
+      });
+
+      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),
-        ),
-
-        // 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),
-        ),
-
-        // 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),
-        ),
-      ]),
+    // validate email
+    const isEmailValid = validateEmail(inviteeEmail);
+    if (!isEmailValid) return E.left(INVALID_EMAIL);
 
-      // Create the invitation
-      TE.chainTaskK(
-        () => () =>
-          this.prisma.teamInvitation.create({
-            data: {
-              teamID: team.id,
-              inviteeEmail,
-              inviteeRole,
-              creatorUid: creator.uid,
-            },
-          }),
-      ),
+    // team ID should valid
+    const team = await this.teamService.getTeamWithID(teamID);
+    if (!team) return E.left(TEAM_INVALID_ID);
 
-      // 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,
-            },
-          }),
+    // 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);
+
+    // 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);
+    }
 
-          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
-        ),
-      ),
+    // 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);
+
+    // create the invitation
+    const dbInvitation = await this.prisma.teamInvitation.create({
+      data: {
+        teamID: team.id,
+        inviteeEmail,
+        inviteeRole,
+        creatorUid: creator.uid,
+      },
+    });
 
-      // 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],
-          };
+    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,
+      },
+    });
 
-          this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
-        }),
-      ),
+    const invitation = this.cast(dbInvitation);
+    this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
 
-      // Map to model type
-      TE.map((x) => x as TeamInvitation),
-    );
+    return E.right(invitation);
   }
 
-  revokeInvitation(inviteID: string) {
-    return pipe(
-      // Make sure invite exists
-      this.getInvitation(inviteID),
-      TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
-
-      // Delete team invitation
-      TE.chainTaskK(
-        () => () =>
-          this.prisma.teamInvitation.delete({
-            where: {
-              id: inviteID,
-            },
-          }),
-      ),
+  /**
+   * Revoke a team invitation
+   * @param inviteID invite id
+   * @returns an Either of true or error message
+   */
+  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);
 
-      // Emit Pubsub Event
-      TE.chainFirst((invitation) =>
-        TE.fromTask(() =>
-          this.pubsub.publish(
-            `team/${invitation.teamID}/invite_removed`,
-            invitation.id,
-          ),
-        ),
-      ),
+    // delete the invite
+    await this.prisma.teamInvitation.delete({
+      where: {
+        id: inviteID,
+      },
+    });
 
-      // We are not returning anything
-      TE.map(constVoid),
+    this.pubsub.publish(
+      `team/${invitation.value.teamID}/invite_removed`,
+      invitation.value.id,
     );
-  }
 
-  getAllInvitationsInTeam(team: Team) {
-    return pipe(
-      () =>
-        this.prisma.teamInvitation.findMany({
-          where: {
-            teamID: team.id,
-          },
-        }),
-      T.map((x) => x as TeamInvitation[]),
-    );
+    return E.right(true);
   }
 
-  acceptInvitation(inviteID: string, acceptedBy: User) {
-    return pipe(
-      TE.Do,
-
-      // First get the invitation
-      TE.bindW('invitation', () =>
-        pipe(
-          this.getInvitation(inviteID),
-          TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
-        ),
-      ),
-
-      // 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
-            ),
-          ),
+  /**
+   * 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 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,
-            ),
-          ),
-        ]),
-      ),
+    // 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 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
-          ),
-        ),
-      ),
+    // 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);
+    }
 
-      TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
+    // delete the invite
+    await this.revokeInvitation(inviteID);
 
-      TE.map(({ teamMember }) => teamMember),
-    );
+    return E.right(teamMember);
   }
 
   /**
-   * Fetch the count invitations for a given team.
+   * Fetch all team invitations for a given team.
    * @param teamID team id
-   * @returns a count team invitations for a team
+   * @returns array of team invitations for a team
    */
-  async getAllTeamInvitations(teamID: string) {
-    const invitations = await this.prisma.teamInvitation.findMany({
+  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;
   }
 }

+ 23 - 41
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<boolean> {
-    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;
   }
 }

+ 29 - 44
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<boolean> {
-    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;
   }
 }

+ 16 - 38
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<boolean> {
-    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;
   }
 }

+ 1 - 1
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<TeamInvitation[]> {
-    return this.teamInviteService.getAllInvitationsInTeam(team)();
+    return this.teamInviteService.getTeamInvitations(team.id);
   }
 }