diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 4c7e07193..4ef2bb89f 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -12,6 +12,7 @@ import { TeamModule } from './team/team.module'; import { TeamEnvironmentsModule } from './team-environments/team-environments.module'; import { TeamCollectionModule } from './team-collection/team-collection.module'; import { TeamRequestModule } from './team-request/team-request.module'; +import { TeamInvitationModule } from './team-invitation/team-invitation.module'; @Module({ imports: [ @@ -53,6 +54,7 @@ import { TeamRequestModule } from './team-request/team-request.module'; TeamEnvironmentsModule, TeamCollectionModule, TeamRequestModule, + TeamInvitationModule, ], providers: [GQLComplexityPlugin], }) diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index 39d7e26f1..40a0f27ca 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -6,6 +6,7 @@ import { TeamMember } from 'src/team/team.model'; import { TeamEnvironment } from 'src/team-environments/team-environments.model'; import { TeamCollection } from 'src/team-collection/team-collection.model'; import { TeamRequest } from 'src/team-request/team-request.model'; +import { TeamInvitation } from 'src/team-invitation/team-invitation.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. @@ -33,4 +34,6 @@ export type TopicDef = { [topic: `user_history/${string}/deleted_many`]: number; [topic: `team_req/${string}/${'req_created' | 'req_updated'}`]: TeamRequest; [topic: `team_req/${string}/req_deleted`]: string; + [topic: `team/${string}/invite_added`]: TeamInvitation; + [topic: `team/${string}/invite_removed`]: string; }; diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.model.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.model.ts new file mode 100644 index 000000000..99a5ea1c8 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.model.ts @@ -0,0 +1,30 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { TeamMemberRole } from '../team/team.model'; + +@ObjectType() +export class TeamInvitation { + @Field(() => ID, { + description: 'ID of the invite', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the team the invite is to', + }) + teamID: string; + + @Field(() => ID, { + description: 'UID of the creator of the invite', + }) + creatorUid: string; + + @Field(() => ID, { + description: 'Email of the invitee', + }) + inviteeEmail: string; + + @Field(() => TeamMemberRole, { + description: 'The role that will be given to the invitee', + }) + inviteeRole: TeamMemberRole; +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.module.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.module.ts new file mode 100644 index 000000000..17598fedb --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { MailerModule } from 'src/mailer/mailer.module'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { PubSubModule } from 'src/pubsub/pubsub.module'; +import { TeamModule } from 'src/team/team.module'; +import { UserModule } from 'src/user/user.module'; +import { TeamInvitationResolver } from './team-invitation.resolver'; +import { TeamInvitationService } from './team-invitation.service'; +import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard'; +import { TeamInviteViewerGuard } from './team-invite-viewer.guard'; +import { TeamInviteeGuard } from './team-invitee.guard'; +import { TeamTeamInviteExtResolver } from './team-teaminvite-ext.resolver'; + +@Module({ + imports: [PrismaModule, TeamModule, PubSubModule, UserModule, MailerModule], + providers: [ + TeamInvitationService, + TeamInvitationResolver, + TeamTeamInviteExtResolver, + TeamInviteeGuard, + TeamInviteViewerGuard, + TeamInviteTeamOwnerGuard, + ], + exports: [TeamInvitationService], +}) +export class TeamInvitationModule {} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts new file mode 100644 index 000000000..b2948a9f1 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.resolver.ts @@ -0,0 +1,239 @@ +import { + Args, + ID, + Mutation, + Parent, + Query, + ResolveField, + Resolver, + Subscription, +} from '@nestjs/graphql'; +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 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 { GqlUser } from 'src/decorators/gql-user.decorator'; +import { User } from 'src/user/user.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { TeamService } from 'src/team/team.service'; +import { throwErr } from 'src/utils'; +import { TeamInviteeGuard } from './team-invitee.guard'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamInviteViewerGuard } from './team-invite-viewer.guard'; +import { TeamInviteTeamOwnerGuard } from './team-invite-team-owner.guard'; +import { UserService } from 'src/user/user.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; + +@Resolver(() => TeamInvitation) +export class TeamInvitationResolver { + constructor( + private readonly userService: UserService, + private readonly teamService: TeamService, + private readonly teamInvitationService: TeamInvitationService, + + private readonly pubsub: PubSubService, + ) {} + + @ResolveField(() => Team, { + complexity: 5, + description: 'Get the team associated to the invite', + }) + async team(@Parent() teamInvitation: TeamInvitation): Promise { + return pipe( + this.teamService.getTeamWithIDTE(teamInvitation.teamID), + TE.getOrElse(throwErr), + )(); + } + + @ResolveField(() => User, { + complexity: 5, + description: 'Get the creator of the invite', + }) + async creator(@Parent() teamInvitation: TeamInvitation): Promise { + let user = await this.userService.findUserById(teamInvitation.creatorUid); + if (O.isNone(user)) throwErr(USER_NOT_FOUND); + + return { + ...user.value, + currentGQLSession: JSON.stringify(user.value.currentGQLSession), + currentRESTSession: JSON.stringify(user.value.currentRESTSession), + }; + } + + @Query(() => TeamInvitation, { + description: + 'Gets the Team Invitation with the given ID, or null if not exists', + }) + @UseGuards(GqlAuthGuard, TeamInviteViewerGuard) + teamInvitation( + @GqlUser() user: User, + @Args({ + name: 'inviteID', + description: 'ID of the Team Invitation to lookup', + type: () => ID, + }) + 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), + )(); + } + + @Mutation(() => TeamInvitation, { + description: 'Creates a Team Invitation', + }) + @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, + ): Promise { + 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), + )(); + } + + @Mutation(() => Boolean, { + description: 'Revokes an invitation and deletes it', + }) + @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) + @RequiresTeamRole(TeamMemberRole.OWNER) + revokeTeamInvitation( + @Args({ + name: 'inviteID', + type: () => ID, + description: 'ID of the invite to revoke', + }) + inviteID: string, + ): Promise { + return pipe( + this.teamInvitationService.revokeInvitation(inviteID), + TE.map(() => true as const), + TE.getOrElse(throwErr), + )(); + } + + @Mutation(() => TeamMember, { + description: 'Accept an Invitation', + }) + @UseGuards(GqlAuthGuard, TeamInviteeGuard) + acceptTeamInvitation( + @GqlUser() user: User, + @Args({ + name: 'inviteID', + type: () => ID, + description: 'ID of the Invite to accept', + }) + inviteID: string, + ): Promise { + return pipe( + this.teamInvitationService.acceptInvitation(inviteID, user), + TE.getOrElse(throwErr), + )(); + } + + // Subscriptions + @Subscription(() => TeamInvitation, { + description: 'Listens to when a Team Invitation is added', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + teamInvitationAdded( + @Args({ + name: 'teamID', + type: () => ID, + description: 'ID of the Team to listen to', + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/invite_added`); + } + + @Subscription(() => ID, { + description: 'Listens to when a Team Invitation is removed', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.OWNER, + TeamMemberRole.EDITOR, + TeamMemberRole.VIEWER, + ) + teamInvitationRemoved( + @Args({ + name: 'teamID', + type: () => ID, + description: 'ID of the Team to listen to', + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team/${teamID}/invite_removed`); + } +} diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts new file mode 100644 index 000000000..6dbf8ae65 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitation.service.ts @@ -0,0 +1,258 @@ +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 { 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 { TeamService } from 'src/team/team.service'; +import { + TEAM_INVITE_ALREADY_MEMBER, + TEAM_INVITE_EMAIL_DO_NOT_MATCH, + TEAM_INVITE_MEMBER_HAS_INVITE, + TEAM_INVITE_NO_INVITE_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'; + +@Injectable() +export class TeamInvitationService { + constructor( + private readonly prisma: PrismaService, + private readonly userService: UserService, + private readonly teamService: TeamService, + private readonly mailerService: MailerService, + + private readonly pubsub: PubSubService, + ) { + this.getInvitation = this.getInvitation.bind(this); + } + + 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), + ); + } + + 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)), + ); + } + + createInvitation( + creator: User, + team: Team, + inviteeEmail: Email, + 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), + ), + ]), + + // 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: `https://hoppscotch.io/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), + ); + } + + 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, + }, + }), + ), + + // 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), + ); + } + + getAllInvitationsInTeam(team: Team) { + return pipe( + () => + this.prisma.teamInvitation.findMany({ + where: { + teamID: team.id, + }, + }), + T.map((x) => x as TeamInvitation[]), + ); + } + + 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 + ), + ), + + // Make sure the invited user and accepting user has the same email + pipe( + undefined, + TE.fromPredicate( + (a) => acceptedBy.email === invitation.inviteeEmail, + () => 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), + ); + } +} 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 new file mode 100644 index 000000000..f029ae63f --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-team-owner.guard.ts @@ -0,0 +1,71 @@ +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_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'; + +@Injectable() +export class TeamInviteTeamOwnerGuard implements CanActivate { + constructor( + private readonly teamService: TeamService, + private readonly teamInviteService: TeamInvitationService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + return pipe( + TE.Do, + + TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + + // 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), + ), + ), + ), + ), + + TE.bindW('user', ({ gqlCtx }) => + pipe( + gqlCtx.getContext().req.user, + O.fromNullable, + TE.fromOption(() => BUG_AUTH_NO_USER_CTX), + ), + ), + + TE.bindW('userMember', ({ invite, user }) => + this.teamService.getTeamMemberTE(invite.teamID, user.uid), + ), + + TE.chainW( + TE.fromPredicate( + ({ userMember }) => userMember.role === TeamMemberRole.OWNER, + () => TEAM_NOT_REQUIRED_ROLE, + ), + ), + + TE.fold( + (err) => throwErr(err), + () => T.of(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 new file mode 100644 index 000000000..a46f5e037 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts @@ -0,0 +1,72 @@ +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, +} from 'src/errors'; +import { User } from 'src/user/user.model'; +import { throwErr } from 'src/utils'; +import { TeamService } from 'src/team/team.service'; + +@Injectable() +export class TeamInviteViewerGuard implements CanActivate { + constructor( + private readonly teamInviteService: TeamInvitationService, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + return pipe( + TE.Do, + + // Get GQL Context + TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + + // Get user + TE.bindW('user', ({ gqlCtx }) => + pipe( + O.fromNullable(gqlCtx.getContext<{ user?: User }>().user), + TE.fromOption(() => 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( + flow( + this.teamInviteService.getInvitation, + TE.fromTaskOption(() => 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), + ), + ), + + TE.mapLeft((e) => + e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e, + ), + + TE.fold(throwErr, () => T.of(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 new file mode 100644 index 000000000..577ad595b --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts @@ -0,0 +1,67 @@ +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 { User } from 'src/user/user.model'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_INVITE_NO_INVITE_ID, + TEAM_INVITE_EMAIL_DO_NOT_MATCH, + TEAM_INVITE_NO_INVITE_FOUND, +} from 'src/errors'; +import { throwErr } from 'src/utils'; + +/** + * This guard only allows the invitee to execute the resolver + * + * REQUIRES GqlAuthGuard + */ +@Injectable() +export class TeamInviteeGuard implements CanActivate { + constructor(private readonly teamInviteService: TeamInvitationService) {} + + async canActivate(context: ExecutionContext): Promise { + return pipe( + TE.Do, + + // Get execution context + TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + + // Get user + TE.bindW('user', ({ gqlCtx }) => + pipe( + O.fromNullable(gqlCtx.getContext<{ user?: User }>().user), + TE.fromOption(() => BUG_AUTH_NO_USER_CTX), + ), + ), + + // 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), + ), + ), + ), + ), + + // Check if the emails match + TE.chainW( + TE.fromPredicate( + ({ user, invite }) => user.email === invite.inviteeEmail, + () => TEAM_INVITE_EMAIL_DO_NOT_MATCH, + ), + ), + + // Fold it to a promise + TE.fold(throwErr, () => T.of(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 new file mode 100644 index 000000000..b6ebe298c --- /dev/null +++ b/packages/hoppscotch-backend/src/team-invitation/team-teaminvite-ext.resolver.ts @@ -0,0 +1,17 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Team } from 'src/team/team.model'; +import { TeamInvitation } from './team-invitation.model'; +import { TeamInvitationService } from './team-invitation.service'; + +@Resolver(() => Team) +export class TeamTeamInviteExtResolver { + constructor(private readonly teamInviteService: TeamInvitationService) {} + + @ResolveField(() => [TeamInvitation], { + description: 'Get all the active invites in the team', + complexity: 10, + }) + teamInvitations(@Parent() team: Team): Promise { + return this.teamInviteService.getAllInvitationsInTeam(team)(); + } +}