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/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 7666e50e8..2e45d5033 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -312,6 +312,13 @@ export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const; */ export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; +/** + * Invalid TEAM ENVIRONMENT name + * (TeamEnvironmentsService) + */ +export const TEAM_ENVIRONMENT_SHORT_NAME = + 'team_environment/short_name' as const; + /** * The user is not a member of the team of the given environment * (GqlTeamEnvTeamGuard) 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-environments/gql-team-env-team.guard.ts b/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts index 181fd6c76..735ae8f5f 100644 --- a/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts +++ b/packages/hoppscotch-backend/src/team-environments/gql-team-env-team.guard.ts @@ -1,15 +1,5 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import * as TE from 'fp-ts/TaskEither'; -import * as O from 'fp-ts/Option'; -import * as S from 'fp-ts/string'; -import { pipe } from 'fp-ts/function'; -import { - getAnnotatedRequiredRoles, - getGqlArg, - getUserFromGQLContext, - throwErr, -} from 'src/utils'; import { TeamEnvironmentsService } from './team-environments.service'; import { BUG_AUTH_NO_USER_CTX, @@ -19,6 +9,10 @@ import { TEAM_ENVIRONMENT_NOT_FOUND, } from 'src/errors'; import { TeamService } from 'src/team/team.service'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import * as E from 'fp-ts/Either'; +import { TeamMemberRole } from '@prisma/client'; +import { throwErr } from 'src/utils'; /** * A guard which checks whether the caller of a GQL Operation @@ -33,50 +27,31 @@ export class GqlTeamEnvTeamGuard implements CanActivate { private readonly teamService: TeamService, ) {} - canActivate(context: ExecutionContext): Promise { - return pipe( - TE.Do, + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES); - TE.bindW('requiredRoles', () => - pipe( - getAnnotatedRequiredRoles(this.reflector, context), - TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES), - ), - ), + const gqlExecCtx = GqlExecutionContext.create(context); - TE.bindW('user', () => - pipe( - getUserFromGQLContext(context), - TE.fromOption(() => BUG_AUTH_NO_USER_CTX), - ), - ), + const { user } = gqlExecCtx.getContext().req; + if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); - TE.bindW('envID', () => - pipe( - getGqlArg('id', context), - O.fromPredicate(S.isString), - TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID), - ), - ), + const { id } = gqlExecCtx.getArgs<{ id: string }>(); + if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID); - TE.bindW('membership', ({ envID, user }) => - pipe( - this.teamEnvironmentService.getTeamEnvironment(envID), - TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND), - TE.chainW((env) => - pipe( - this.teamService.getTeamMemberTE(env.teamID, user.uid), - TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER), - ), - ), - ), - ), + const teamEnvironment = + await this.teamEnvironmentService.getTeamEnvironment(id); + if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND); - TE.map(({ membership, requiredRoles }) => - requiredRoles.includes(membership.role), - ), + const member = await this.teamService.getTeamMember( + teamEnvironment.right.teamID, + user.uid, + ); + if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER); - TE.getOrElse(throwErr), - )(); + return requireRoles.includes(member.role); } } diff --git a/packages/hoppscotch-backend/src/team-environments/input-type.args.ts b/packages/hoppscotch-backend/src/team-environments/input-type.args.ts new file mode 100644 index 000000000..393865fab --- /dev/null +++ b/packages/hoppscotch-backend/src/team-environments/input-type.args.ts @@ -0,0 +1,41 @@ +import { ArgsType, Field, ID } from '@nestjs/graphql'; + +@ArgsType() +export class CreateTeamEnvironmentArgs { + @Field({ + name: 'name', + description: 'Name of the Team Environment', + }) + name: string; + + @Field(() => ID, { + name: 'teamID', + description: 'ID of the Team', + }) + teamID: string; + + @Field({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string; +} + +@ArgsType() +export class UpdateTeamEnvironmentArgs { + @Field(() => ID, { + name: 'id', + description: 'ID of the Team Environment', + }) + id: string; + @Field({ + name: 'name', + description: 'Name of the Team Environment', + }) + name: string; + @Field({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string; +} diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts index 6775fb4c3..6bece3854 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.resolver.ts @@ -13,6 +13,11 @@ import { throwErr } from 'src/utils'; import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironmentsService } from './team-environments.service'; +import * as E from 'fp-ts/Either'; +import { + CreateTeamEnvironmentArgs, + UpdateTeamEnvironmentArgs, +} from './input-type.args'; @UseGuards(GqlThrottlerGuard) @Resolver(() => 'TeamEnvironment') @@ -29,29 +34,18 @@ export class TeamEnvironmentsResolver { }) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) - createTeamEnvironment( - @Args({ - name: 'name', - description: 'Name of the Team Environment', - }) - name: string, - @Args({ - name: 'teamID', - description: 'ID of the Team', - type: () => ID, - }) - teamID: string, - @Args({ - name: 'variables', - description: 'JSON string of the variables object', - }) - variables: string, + async createTeamEnvironment( + @Args() args: CreateTeamEnvironmentArgs, ): Promise { - return this.teamEnvironmentsService.createTeamEnvironment( - name, - teamID, - variables, - )(); + const teamEnvironment = + await this.teamEnvironmentsService.createTeamEnvironment( + args.name, + args.teamID, + args.variables, + ); + + if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left); + return teamEnvironment.right; } @Mutation(() => Boolean, { @@ -59,7 +53,7 @@ export class TeamEnvironmentsResolver { }) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) - deleteTeamEnvironment( + async deleteTeamEnvironment( @Args({ name: 'id', description: 'ID of the Team Environment', @@ -67,10 +61,12 @@ export class TeamEnvironmentsResolver { }) id: string, ): Promise { - return pipe( - this.teamEnvironmentsService.deleteTeamEnvironment(id), - TE.getOrElse(throwErr), - )(); + const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment( + id, + ); + + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; } @Mutation(() => TeamEnvironment, { @@ -79,28 +75,19 @@ export class TeamEnvironmentsResolver { }) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) - updateTeamEnvironment( - @Args({ - name: 'id', - description: 'ID of the Team Environment', - type: () => ID, - }) - id: string, - @Args({ - name: 'name', - description: 'Name of the Team Environment', - }) - name: string, - @Args({ - name: 'variables', - description: 'JSON string of the variables object', - }) - variables: string, + async updateTeamEnvironment( + @Args() + args: UpdateTeamEnvironmentArgs, ): Promise { - return pipe( - this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables), - TE.getOrElse(throwErr), - )(); + const updatedTeamEnvironment = + await this.teamEnvironmentsService.updateTeamEnvironment( + args.id, + args.name, + args.variables, + ); + + if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left); + return updatedTeamEnvironment.right; } @Mutation(() => TeamEnvironment, { @@ -108,7 +95,7 @@ export class TeamEnvironmentsResolver { }) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) - deleteAllVariablesFromTeamEnvironment( + async deleteAllVariablesFromTeamEnvironment( @Args({ name: 'id', description: 'ID of the Team Environment', @@ -116,10 +103,13 @@ export class TeamEnvironmentsResolver { }) id: string, ): Promise { - return pipe( - this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id), - TE.getOrElse(throwErr), - )(); + const teamEnvironment = + await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( + id, + ); + + if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left); + return teamEnvironment.right; } @Mutation(() => TeamEnvironment, { @@ -127,7 +117,7 @@ export class TeamEnvironmentsResolver { }) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) - createDuplicateEnvironment( + async createDuplicateEnvironment( @Args({ name: 'id', description: 'ID of the Team Environment', @@ -135,10 +125,12 @@ export class TeamEnvironmentsResolver { }) id: string, ): Promise { - return pipe( - this.teamEnvironmentsService.createDuplicateEnvironment(id), - TE.getOrElse(throwErr), - )(); + const res = await this.teamEnvironmentsService.createDuplicateEnvironment( + id, + ); + + if (E.isLeft(res)) throwErr(res.left); + return res.right; } /* Subscriptions */ diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts index 742d4ca3e..719e58007 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.spec.ts @@ -2,7 +2,11 @@ import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from 'src/prisma/prisma.service'; import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironmentsService } from './team-environments.service'; -import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; +import { + JSON_INVALID, + TEAM_ENVIRONMENT_NOT_FOUND, + TEAM_ENVIRONMENT_SHORT_NAME, +} from 'src/errors'; const mockPrisma = mockDeep(); @@ -31,125 +35,81 @@ beforeEach(() => { describe('TeamEnvironmentsService', () => { describe('getTeamEnvironment', () => { - test('queries the db with the id', async () => { - mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); - - await teamEnvironmentsService.getTeamEnvironment('123')(); - - expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - id: '123', - }, - }), + test('should successfully return a TeamEnvironment with valid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( + teamEnvironment, ); - }); - test('requests prisma to reject the query promise if not found', async () => { - mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); - - await teamEnvironmentsService.getTeamEnvironment('123')(); - - expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - rejectOnNotFound: true, - }), + const result = await teamEnvironmentsService.getTeamEnvironment( + teamEnvironment.id, ); + expect(result).toEqualRight(teamEnvironment); }); - test('should return a Some of the correct environment if exists', async () => { - mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); + test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => { + mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce( + 'RejectOnNotFound', + ); - const result = await teamEnvironmentsService.getTeamEnvironment('123')(); - - expect(result).toEqualSome(teamEnvironment); - }); - - test('should return a None if the environment does not exist', async () => { - mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); - - const result = await teamEnvironmentsService.getTeamEnvironment('123')(); - - expect(result).toBeNone(); + const result = await teamEnvironmentsService.getTeamEnvironment( + teamEnvironment.id, + ); + expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); }); }); + describe('createTeamEnvironment', () => { - test('should create and return a new team environment given a valid name,variable and team ID', async () => { + test('should successfully create and return a new team environment given valid inputs', async () => { mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); const result = await teamEnvironmentsService.createTeamEnvironment( teamEnvironment.name, teamEnvironment.teamID, JSON.stringify(teamEnvironment.variables), - )(); + ); - expect(result).toEqual({ - id: teamEnvironment.id, - name: teamEnvironment.name, - teamID: teamEnvironment.teamID, + expect(result).toEqualRight({ + ...teamEnvironment, variables: JSON.stringify(teamEnvironment.variables), }); }); - test('should reject if given team ID is invalid', async () => { - mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); + test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => { + const result = await teamEnvironmentsService.createTeamEnvironment( + '12', + teamEnvironment.teamID, + JSON.stringify(teamEnvironment.variables), + ); - await expect( - teamEnvironmentsService.createTeamEnvironment( - teamEnvironment.name, - 'invalidteamid', - JSON.stringify(teamEnvironment.variables), - ), - ).rejects.toBeDefined(); - }); - - test('should reject if provided team environment name is not a string', async () => { - mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); - - await expect( - teamEnvironmentsService.createTeamEnvironment( - null as any, - teamEnvironment.teamID, - JSON.stringify(teamEnvironment.variables), - ), - ).rejects.toBeDefined(); - }); - - test('should reject if provided variable is not a string', async () => { - mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); - - await expect( - teamEnvironmentsService.createTeamEnvironment( - teamEnvironment.name, - teamEnvironment.teamID, - null as any, - ), - ).rejects.toBeDefined(); + expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME); }); test('should send pubsub message to "team_environment//created" if team environment is created successfully', async () => { - mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment); + mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); const result = await teamEnvironmentsService.createTeamEnvironment( teamEnvironment.name, teamEnvironment.teamID, JSON.stringify(teamEnvironment.variables), - )(); + ); expect(mockPubSub.publish).toHaveBeenCalledWith( `team_environment/${teamEnvironment.teamID}/created`, - result, + { + ...teamEnvironment, + variables: JSON.stringify(teamEnvironment.variables), + }, ); }); }); describe('deleteTeamEnvironment', () => { - test('should resolve to true given a valid team environment ID', async () => { + test('should successfully delete a TeamEnvironment with a valid ID', async () => { mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); const result = await teamEnvironmentsService.deleteTeamEnvironment( teamEnvironment.id, - )(); + ); expect(result).toEqualRight(true); }); @@ -159,7 +119,7 @@ describe('TeamEnvironmentsService', () => { const result = await teamEnvironmentsService.deleteTeamEnvironment( 'invalidid', - )(); + ); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); }); @@ -169,7 +129,7 @@ describe('TeamEnvironmentsService', () => { const result = await teamEnvironmentsService.deleteTeamEnvironment( teamEnvironment.id, - )(); + ); expect(mockPubSub.publish).toHaveBeenCalledWith( `team_environment/${teamEnvironment.teamID}/deleted`, @@ -182,7 +142,7 @@ describe('TeamEnvironmentsService', () => { }); describe('updateVariablesInTeamEnvironment', () => { - test('should add new variable to a team environment', async () => { + test('should successfully add new variable to a team environment', async () => { mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ ...teamEnvironment, variables: [{ key: 'value' }], @@ -192,7 +152,7 @@ describe('TeamEnvironmentsService', () => { teamEnvironment.id, teamEnvironment.name, JSON.stringify([{ key: 'value' }]), - )(); + ); expect(result).toEqualRight({ ...teamEnvironment, @@ -200,7 +160,7 @@ describe('TeamEnvironmentsService', () => { }); }); - test('should add new variable to already existing list of variables in a team environment', async () => { + test('should successfully add new variable to already existing list of variables in a team environment', async () => { mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ ...teamEnvironment, variables: [{ key: 'value' }, { key_2: 'value_2' }], @@ -210,7 +170,7 @@ describe('TeamEnvironmentsService', () => { teamEnvironment.id, teamEnvironment.name, JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), - )(); + ); expect(result).toEqualRight({ ...teamEnvironment, @@ -218,7 +178,7 @@ describe('TeamEnvironmentsService', () => { }); }); - test('should edit existing variables in a team environment', async () => { + test('should successfully edit existing variables in a team environment', async () => { mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ ...teamEnvironment, variables: [{ key: '1234' }], @@ -228,7 +188,7 @@ describe('TeamEnvironmentsService', () => { teamEnvironment.id, teamEnvironment.name, JSON.stringify([{ key: '1234' }]), - )(); + ); expect(result).toEqualRight({ ...teamEnvironment, @@ -236,22 +196,7 @@ describe('TeamEnvironmentsService', () => { }); }); - test('should delete existing variable in a team environment', async () => { - mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); - - const result = await teamEnvironmentsService.updateTeamEnvironment( - teamEnvironment.id, - teamEnvironment.name, - JSON.stringify([{}]), - )(); - - expect(result).toEqualRight({ - ...teamEnvironment, - variables: JSON.stringify([{}]), - }); - }); - - test('should edit name of an existing team environment', async () => { + test('should successfully edit name of an existing team environment', async () => { mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ ...teamEnvironment, variables: [{ key: '123' }], @@ -261,7 +206,7 @@ describe('TeamEnvironmentsService', () => { teamEnvironment.id, teamEnvironment.name, JSON.stringify([{ key: '123' }]), - )(); + ); expect(result).toEqualRight({ ...teamEnvironment, @@ -269,14 +214,24 @@ describe('TeamEnvironmentsService', () => { }); }); - test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => { + const result = await teamEnvironmentsService.updateTeamEnvironment( + teamEnvironment.id, + '12', + JSON.stringify([{ key: 'value' }]), + ); + + expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME); + }); + + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); const result = await teamEnvironmentsService.updateTeamEnvironment( 'invalidid', teamEnvironment.name, JSON.stringify(teamEnvironment.variables), - )(); + ); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); }); @@ -288,7 +243,7 @@ describe('TeamEnvironmentsService', () => { teamEnvironment.id, teamEnvironment.name, JSON.stringify([{ key: 'value' }]), - )(); + ); expect(mockPubSub.publish).toHaveBeenCalledWith( `team_environment/${teamEnvironment.teamID}/updated`, @@ -301,13 +256,13 @@ describe('TeamEnvironmentsService', () => { }); describe('deleteAllVariablesFromTeamEnvironment', () => { - test('should delete all variables in a team environment', async () => { + test('should successfully delete all variables in a team environment', async () => { mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); const result = await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( teamEnvironment.id, - )(); + ); expect(result).toEqualRight({ ...teamEnvironment, @@ -315,13 +270,13 @@ describe('TeamEnvironmentsService', () => { }); }); - test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); const result = await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( 'invalidid', - )(); + ); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); }); @@ -332,7 +287,7 @@ describe('TeamEnvironmentsService', () => { const result = await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( teamEnvironment.id, - )(); + ); expect(mockPubSub.publish).toHaveBeenCalledWith( `team_environment/${teamEnvironment.teamID}/updated`, @@ -345,7 +300,7 @@ describe('TeamEnvironmentsService', () => { }); describe('createDuplicateEnvironment', () => { - test('should duplicate an existing team environment', async () => { + test('should successfully duplicate an existing team environment', async () => { mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( teamEnvironment, ); @@ -357,21 +312,21 @@ describe('TeamEnvironmentsService', () => { const result = await teamEnvironmentsService.createDuplicateEnvironment( teamEnvironment.id, - )(); + ); expect(result).toEqualRight({ - ...teamEnvironment, id: 'newid', + ...teamEnvironment, variables: JSON.stringify(teamEnvironment.variables), }); }); - test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { + test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); const result = await teamEnvironmentsService.createDuplicateEnvironment( teamEnvironment.id, - )(); + ); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); }); @@ -388,13 +343,13 @@ describe('TeamEnvironmentsService', () => { const result = await teamEnvironmentsService.createDuplicateEnvironment( teamEnvironment.id, - )(); + ); expect(mockPubSub.publish).toHaveBeenCalledWith( `team_environment/${teamEnvironment.teamID}/created`, { - ...teamEnvironment, id: 'newid', + ...teamEnvironment, variables: JSON.stringify([{}]), }, ); diff --git a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts index 49e3d7bb1..d50a62a11 100644 --- a/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts +++ b/packages/hoppscotch-backend/src/team-environments/team-environments.service.ts @@ -1,15 +1,14 @@ import { Injectable } from '@nestjs/common'; -import { pipe } from 'fp-ts/function'; -import * as T from 'fp-ts/Task'; -import * as TO from 'fp-ts/TaskOption'; -import * as TE from 'fp-ts/TaskEither'; -import * as A from 'fp-ts/Array'; -import { Prisma } from '@prisma/client'; +import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { TeamEnvironment } from './team-environments.model'; -import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; - +import { + TEAM_ENVIRONMENT_NOT_FOUND, + TEAM_ENVIRONMENT_SHORT_NAME, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { isValidLength } from 'src/utils'; @Injectable() export class TeamEnvironmentsService { constructor( @@ -17,219 +16,218 @@ export class TeamEnvironmentsService { private readonly pubsub: PubSubService, ) {} - getTeamEnvironment(id: string) { - return TO.tryCatch(() => - this.prisma.teamEnvironment.findFirst({ - where: { id }, + TITLE_LENGTH = 3; + + /** + * TeamEnvironments are saved in the DB in the following way + * [{ key: value }, { key: value },....] + * + */ + + /** + * Typecast a database TeamEnvironment to a TeamEnvironment model + * @param teamEnvironment database TeamEnvironment + * @returns TeamEnvironment model + */ + private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment { + return { + id: teamEnvironment.id, + name: teamEnvironment.name, + teamID: teamEnvironment.teamID, + variables: JSON.stringify(teamEnvironment.variables), + }; + } + + /** + * Get details of a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of a TeamEnvironment or error message + */ + async getTeamEnvironment(id: string) { + try { + const teamEnvironment = + await this.prisma.teamEnvironment.findFirstOrThrow({ + where: { id }, + }); + return E.right(teamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Create a new TeamEnvironment. + * + * @param name name of new TeamEnvironment + * @param teamID teamID of new TeamEnvironment + * @param variables JSONified string of contents of new TeamEnvironment + * @returns Either of a TeamEnvironment or error message + */ + async createTeamEnvironment(name: string, teamID: string, variables: string) { + const isTitleValid = isValidLength(name, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME); + + const result = await this.prisma.teamEnvironment.create({ + data: { + name: name, + teamID: teamID, + variables: JSON.parse(variables), + }, + }); + + const createdTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${createdTeamEnvironment.teamID}/created`, + createdTeamEnvironment, + ); + + return E.right(createdTeamEnvironment); + } + + /** + * Delete a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of boolean or error message + */ + async deleteTeamEnvironment(id: string) { + try { + const result = await this.prisma.teamEnvironment.delete({ + where: { + id: id, + }, + }); + + const deletedTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${deletedTeamEnvironment.teamID}/deleted`, + deletedTeamEnvironment, + ); + + return E.right(true); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Update a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @param name TeamEnvironment name + * @param variables JSONified string of contents of new TeamEnvironment + * @returns Either of a TeamEnvironment or error message + */ + async updateTeamEnvironment(id: string, name: string, variables: string) { + try { + const isTitleValid = isValidLength(name, this.TITLE_LENGTH); + if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME); + + const result = await this.prisma.teamEnvironment.update({ + where: { id: id }, + data: { + name, + variables: JSON.parse(variables), + }, + }); + + const updatedTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${updatedTeamEnvironment.teamID}/updated`, + updatedTeamEnvironment, + ); + + return E.right(updatedTeamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Clear contents of a TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of a TeamEnvironment or error message + */ + async deleteAllVariablesFromTeamEnvironment(id: string) { + try { + const result = await this.prisma.teamEnvironment.update({ + where: { id: id }, + data: { + variables: [], + }, + }); + + const teamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${teamEnvironment.teamID}/updated`, + teamEnvironment, + ); + + return E.right(teamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } + } + + /** + * Create a duplicate of a existing TeamEnvironment. + * + * @param id TeamEnvironment ID + * @returns Either of a TeamEnvironment or error message + */ + async createDuplicateEnvironment(id: string) { + try { + const environment = await this.prisma.teamEnvironment.findFirst({ + where: { + id: id, + }, rejectOnNotFound: true, - }), - ); + }); + + const result = await this.prisma.teamEnvironment.create({ + data: { + name: environment.name, + teamID: environment.teamID, + variables: environment.variables as Prisma.JsonArray, + }, + }); + + const duplicatedTeamEnvironment = this.cast(result); + + this.pubsub.publish( + `team_environment/${duplicatedTeamEnvironment.teamID}/created`, + duplicatedTeamEnvironment, + ); + + return E.right(duplicatedTeamEnvironment); + } catch (error) { + return E.left(TEAM_ENVIRONMENT_NOT_FOUND); + } } - createTeamEnvironment(name: string, teamID: string, variables: string) { - return pipe( - () => - this.prisma.teamEnvironment.create({ - data: { - name: name, - teamID: teamID, - variables: JSON.parse(variables), - }, - }), - T.chainFirst( - (environment) => () => - this.pubsub.publish( - `team_environment/${environment.teamID}/created`, - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ), - T.map((data) => { - return { - id: data.id, - name: data.name, - teamID: data.teamID, - variables: JSON.stringify(data.variables), - }; - }), - ); - } + /** + * Fetch all TeamEnvironments of a team. + * + * @param teamID teamID of new TeamEnvironment + * @returns List of TeamEnvironments + */ + async fetchAllTeamEnvironments(teamID: string) { + const result = await this.prisma.teamEnvironment.findMany({ + where: { + teamID: teamID, + }, + }); + const teamEnvironments = result.map((item) => { + return this.cast(item); + }); - deleteTeamEnvironment(id: string) { - return pipe( - TE.tryCatch( - () => - this.prisma.teamEnvironment.delete({ - where: { - id: id, - }, - }), - () => TEAM_ENVIRONMENT_NOT_FOUND, - ), - TE.chainFirst((environment) => - TE.fromTask(() => - this.pubsub.publish( - `team_environment/${environment.teamID}/deleted`, - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ), - ), - TE.map((data) => true), - ); - } - - updateTeamEnvironment(id: string, name: string, variables: string) { - return pipe( - TE.tryCatch( - () => - this.prisma.teamEnvironment.update({ - where: { id: id }, - data: { - name, - variables: JSON.parse(variables), - }, - }), - () => TEAM_ENVIRONMENT_NOT_FOUND, - ), - TE.chainFirst((environment) => - TE.fromTask(() => - this.pubsub.publish( - `team_environment/${environment.teamID}/updated`, - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ), - ), - TE.map( - (environment) => - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ); - } - - deleteAllVariablesFromTeamEnvironment(id: string) { - return pipe( - TE.tryCatch( - () => - this.prisma.teamEnvironment.update({ - where: { id: id }, - data: { - variables: [], - }, - }), - () => TEAM_ENVIRONMENT_NOT_FOUND, - ), - TE.chainFirst((environment) => - TE.fromTask(() => - this.pubsub.publish( - `team_environment/${environment.teamID}/updated`, - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ), - ), - TE.map( - (environment) => - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ); - } - - createDuplicateEnvironment(id: string) { - return pipe( - TE.tryCatch( - () => - this.prisma.teamEnvironment.findFirst({ - where: { - id: id, - }, - rejectOnNotFound: true, - }), - () => TEAM_ENVIRONMENT_NOT_FOUND, - ), - TE.chain((environment) => - TE.fromTask(() => - this.prisma.teamEnvironment.create({ - data: { - name: environment.name, - teamID: environment.teamID, - variables: environment.variables as Prisma.JsonArray, - }, - }), - ), - ), - TE.chainFirst((environment) => - TE.fromTask(() => - this.pubsub.publish( - `team_environment/${environment.teamID}/created`, - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ), - ), - TE.map( - (environment) => - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ); - } - - fetchAllTeamEnvironments(teamID: string) { - return pipe( - () => - this.prisma.teamEnvironment.findMany({ - where: { - teamID: teamID, - }, - }), - T.map( - A.map( - (environment) => - { - id: environment.id, - name: environment.name, - teamID: environment.teamID, - variables: JSON.stringify(environment.variables), - }, - ), - ), - ); + return teamEnvironments; } /** diff --git a/packages/hoppscotch-backend/src/team-environments/team.resolver.ts b/packages/hoppscotch-backend/src/team-environments/team.resolver.ts index fb83db9d7..5416c4d41 100644 --- a/packages/hoppscotch-backend/src/team-environments/team.resolver.ts +++ b/packages/hoppscotch-backend/src/team-environments/team.resolver.ts @@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver { description: 'Returns all Team Environments for the given Team', }) teamEnvironments(@Parent() team: Team): Promise { - return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)(); + return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id); } } 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 98610bde1..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,211 +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( - (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), - ); + 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..ad4f08d77 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 1f2bdaa5d..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,11 +1,7 @@ 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, @@ -24,44 +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 === invite.inviteeEmail, - () => 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); } } diff --git a/packages/hoppscotch-common/assets/icons/star-off.svg b/packages/hoppscotch-common/assets/icons/star-off.svg new file mode 100644 index 000000000..3d542a9c1 --- /dev/null +++ b/packages/hoppscotch-common/assets/icons/star-off.svg @@ -0,0 +1 @@ + diff --git a/packages/hoppscotch-common/src/components/history/graphql/Card.vue b/packages/hoppscotch-common/src/components/history/graphql/Card.vue index 600cfca49..4b7e1bac5 100644 --- a/packages/hoppscotch-common/src/components/history/graphql/Card.vue +++ b/packages/hoppscotch-common/src/components/history/graphql/Card.vue @@ -63,7 +63,7 @@ import { GQLHistoryEntry } from "~/newstore/history" import { shortDateTime } from "~/helpers/utils/date" import IconStar from "~icons/lucide/star" -import IconStarOff from "~icons/lucide/star-off" +import IconStarOff from "~icons/hopp/star-off" import IconTrash from "~icons/lucide/trash" import IconMinimize2 from "~icons/lucide/minimize-2" import IconMaximize2 from "~icons/lucide/maximize-2" diff --git a/packages/hoppscotch-common/src/components/history/rest/Card.vue b/packages/hoppscotch-common/src/components/history/rest/Card.vue index dc5348aaf..19cd4b3ec 100644 --- a/packages/hoppscotch-common/src/components/history/rest/Card.vue +++ b/packages/hoppscotch-common/src/components/history/rest/Card.vue @@ -55,7 +55,7 @@ import { RESTHistoryEntry } from "~/newstore/history" import { shortDateTime } from "~/helpers/utils/date" import IconStar from "~icons/lucide/star" -import IconStarOff from "~icons/lucide/star-off" +import IconStarOff from "~icons/hopp/star-off" import IconTrash from "~icons/lucide/trash" const props = defineProps<{