diff --git a/packages/hoppscotch-backend/src/admin/admin.service.ts b/packages/hoppscotch-backend/src/admin/admin.service.ts index 1d7865bb0..e5c16bef0 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, ); @@ -258,7 +258,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..8be6f8a99 100644 --- a/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts +++ b/packages/hoppscotch-backend/src/team-invitation/team-invite-viewer.guard.ts @@ -1,20 +1,23 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { TeamInvitationService } from './team-invitation.service'; -import { pipe, flow } from 'fp-ts/function'; -import * as TE from 'fp-ts/TaskEither'; -import * as T from 'fp-ts/Task'; import * as O from 'fp-ts/Option'; import { GqlExecutionContext } from '@nestjs/graphql'; import { BUG_AUTH_NO_USER_CTX, BUG_TEAM_INVITE_NO_INVITE_ID, - TEAM_INVITE_NOT_VALID_VIEWER, TEAM_INVITE_NO_INVITE_FOUND, + TEAM_MEMBER_NOT_FOUND, } from 'src/errors'; -import { User } from 'src/user/user.model'; import { throwErr } from 'src/utils'; import { TeamService } from 'src/team/team.service'; +/** + * This guard only allows user to execute the resolver + * 1. If user is invitee, allow + * 2. Or else, if user is team member, allow + * + * TLDR: Allow if user is invitee or team member + */ @Injectable() export class TeamInviteViewerGuard implements CanActivate { constructor( @@ -23,50 +26,32 @@ export class TeamInviteViewerGuard implements CanActivate { ) {} async canActivate(context: ExecutionContext): Promise { - return pipe( - TE.Do, + // Get GQL context + const gqlExecCtx = GqlExecutionContext.create(context); - // Get GQL Context - TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), + // Get user + const { user } = gqlExecCtx.getContext().req; + if (!user) throwErr(BUG_AUTH_NO_USER_CTX); - // Get user - TE.bindW('user', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getContext().req.user), - TE.fromOption(() => BUG_AUTH_NO_USER_CTX), - ), - ), + // Get the invite + const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); + if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); - // Get the invite - TE.bindW('invite', ({ gqlCtx }) => - pipe( - O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID), - TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID), - TE.chainW( - flow( - this.teamInviteService.getInvitation, - TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), - ), - ), - ), - ), + const invitation = await this.teamInviteService.getInvitation(inviteID); + if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); - // Check if the user and the invite email match, else if we can resolver the user as a team member - // any better solution ? - TE.chainW(({ user, invite }) => - user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase() - ? TE.of(true) - : pipe( - this.teamService.getTeamMemberTE(invite.teamID, user.uid), - TE.map(() => true), - ), - ), + // Check if the user and the invite email match, else if user is a team member + if ( + user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() + ) { + const teamMember = await this.teamService.getTeamMember( + invitation.value.teamID, + user.uid, + ); - TE.mapLeft((e) => - e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e, - ), + if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND); + } - TE.fold(throwErr, () => T.of(true)), - )(); + return true; } } diff --git a/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts b/packages/hoppscotch-backend/src/team-invitation/team-invitee.guard.ts index 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/locales/tw.json b/packages/hoppscotch-common/locales/tw.json index 6d58298c9..cce86a565 100644 --- a/packages/hoppscotch-common/locales/tw.json +++ b/packages/hoppscotch-common/locales/tw.json @@ -19,7 +19,7 @@ "edit": "編輯", "filter": "篩選回應", "go_back": "返回", - "go_forward": "Go forward", + "go_forward": "向前", "group_by": "分組方式", "label": "標籤", "learn_more": "瞭解更多", @@ -117,37 +117,37 @@ "username": "使用者名稱" }, "collection": { - "created": "組合已建立", - "different_parent": "Cannot reorder collection with different parent", - "edit": "編輯組合", - "invalid_name": "請提供有效的組合名稱", - "invalid_root_move": "Collection already in the root", - "moved": "Moved Successfully", - "my_collections": "我的組合", - "name": "我的新組合", - "name_length_insufficient": "組合名稱至少要有 3 個字元。", - "new": "建立組合", - "order_changed": "Collection Order Updated", - "renamed": "組合已重新命名", + "created": "集合已建立", + "different_parent": "無法為父集合不同的集合重新排序", + "edit": "編輯集合", + "invalid_name": "請提供有效的集合名稱", + "invalid_root_move": "集合已在根目錄", + "moved": "移動成功", + "my_collections": "我的集合", + "name": "我的新集合", + "name_length_insufficient": "集合名稱至少要有 3 個字元。", + "new": "建立集合", + "order_changed": "集合順序已更新", + "renamed": "集合已重新命名", "request_in_use": "請求正在使用中", "save_as": "另存為", - "select": "選擇一個組合", + "select": "選擇一個集合", "select_location": "選擇位置", "select_team": "選擇一個團隊", - "team_collections": "團隊組合" + "team_collections": "團隊集合" }, "confirm": { "exit_team": "您確定要離開此團隊嗎?", "logout": "您確定要登出嗎?", - "remove_collection": "您確定要永久刪除該組合嗎?", + "remove_collection": "您確定要永久刪除該集合嗎?", "remove_environment": "您確定要永久刪除該環境嗎?", "remove_folder": "您確定要永久刪除該資料夾嗎?", "remove_history": "您確定要永久刪除全部歷史記錄嗎?", "remove_request": "您確定要永久刪除該請求嗎?", "remove_team": "您確定要刪除該團隊嗎?", "remove_telemetry": "您確定要退出遙測服務嗎?", - "request_change": "您確定要捨棄當前請求嗎?未儲存的變更將遺失。", - "save_unsaved_tab": "Do you want to save changes made in this tab?", + "request_change": "您確定要捨棄目前的請求嗎?未儲存的變更將遺失。", + "save_unsaved_tab": "您要儲存在此分頁做出的改動嗎?", "sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。" }, "count": { @@ -160,13 +160,13 @@ }, "documentation": { "generate": "產生文件", - "generate_message": "匯入 Hoppscotch 組合以隨時隨地產生 API 文件。" + "generate_message": "匯入 Hoppscotch 集合以隨時隨地產生 API 文件。" }, "empty": { "authorization": "該請求沒有使用任何授權", "body": "該請求沒有任何請求主體", - "collection": "組合為空", - "collections": "組合為空", + "collection": "集合為空", + "collections": "集合為空", "documentation": "連線到 GraphQL 端點以檢視文件", "endpoint": "端點不能留空", "environments": "環境為空", @@ -209,7 +209,7 @@ "browser_support_sse": "此瀏覽器似乎不支援 SSE。", "check_console_details": "檢查控制台日誌以獲悉詳情", "curl_invalid_format": "cURL 格式不正確", - "danger_zone": "Danger zone", + "danger_zone": "危險地帶", "delete_account": "您的帳號目前為這些團隊的擁有者:", "delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。", "empty_req_name": "空請求名稱", @@ -277,38 +277,38 @@ "tests": "編寫測試指令碼以自動除錯。" }, "hide": { - "collection": "隱藏組合面板", + "collection": "隱藏集合面板", "more": "隱藏更多", "preview": "隱藏預覽", "sidebar": "隱藏側邊欄" }, "import": { - "collections": "匯入組合", + "collections": "匯入集合", "curl": "匯入 cURL", "failed": "匯入失敗", "from_gist": "從 Gist 匯入", "from_gist_description": "從 Gist 網址匯入", "from_insomnia": "從 Insomnia 匯入", - "from_insomnia_description": "從 Insomnia 組合匯入", + "from_insomnia_description": "從 Insomnia 集合匯入", "from_json": "從 Hoppscotch 匯入", - "from_json_description": "從 Hoppscotch 組合檔匯入", - "from_my_collections": "從我的組合匯入", - "from_my_collections_description": "從我的組合檔匯入", + "from_json_description": "從 Hoppscotch 集合檔匯入", + "from_my_collections": "從我的集合匯入", + "from_my_collections_description": "從我的集合檔匯入", "from_openapi": "從 OpenAPI 匯入", "from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入", "from_postman": "從 Postman 匯入", - "from_postman_description": "從 Postman 組合匯入", + "from_postman_description": "從 Postman 集合匯入", "from_url": "從網址匯入", "gist_url": "輸入 Gist 網址", "import_from_url_invalid_fetch": "無法從網址取得資料", - "import_from_url_invalid_file_format": "匯入組合時發生錯誤", + "import_from_url_invalid_file_format": "匯入集合時發生錯誤", "import_from_url_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'", - "import_from_url_success": "已匯入組合", - "json_description": "從 Hoppscotch 組合 JSON 檔匯入組合", + "import_from_url_success": "已匯入集合", + "json_description": "從 Hoppscotch 集合 JSON 檔匯入集合", "title": "匯入" }, "layout": { - "collapse_collection": "隱藏或顯示組合", + "collapse_collection": "隱藏或顯示集合", "collapse_sidebar": "隱藏或顯示側邊欄", "column": "垂直版面", "name": "配置", @@ -316,8 +316,8 @@ "zen_mode": "專注模式" }, "modal": { - "close_unsaved_tab": "You have unsaved changes", - "collections": "組合", + "close_unsaved_tab": "您有未儲存的改動", + "collections": "集合", "confirm": "確認", "edit_request": "編輯請求", "import_export": "匯入/匯出" @@ -374,9 +374,9 @@ "email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。", "no_permission": "您沒有權限執行此操作。", "owner": "擁有者", - "owner_description": "擁有者可以新增、編輯和刪除請求、組合和團隊成員。", + "owner_description": "擁有者可以新增、編輯和刪除請求、集合和團隊成員。", "roles": "角色", - "roles_description": "角色用來控制對共用組合的存取權。", + "roles_description": "角色用來控制對共用集合的存取權。", "updated": "已更新個人檔案", "viewer": "檢視者", "viewer_description": "檢視者只能檢視和使用請求。" @@ -396,8 +396,8 @@ "text": "文字" }, "copy_link": "複製連結", - "different_collection": "Cannot reorder requests from different collections", - "duplicated": "Request duplicated", + "different_collection": "無法重新排列來自不同集合的請求", + "duplicated": "已複製請求", "duration": "持續時間", "enter_curl": "輸入 cURL", "generate_code": "產生程式碼", @@ -405,10 +405,10 @@ "header_list": "請求標頭列表", "invalid_name": "請提供請求名稱", "method": "方法", - "moved": "Request moved", + "moved": "已移動請求", "name": "請求名稱", "new": "新請求", - "order_changed": "Request Order Updated", + "order_changed": "已更新請求順序", "override": "覆寫", "override_help": "在標頭設置 Content-Type", "overriden": "已覆寫", @@ -432,7 +432,7 @@ "view_my_links": "檢視我的連結" }, "response": { - "audio": "Audio", + "audio": "音訊", "body": "回應本體", "filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)", "headers": "回應標頭", @@ -446,7 +446,7 @@ "status": "狀態", "time": "時間", "title": "回應", - "video": "Video", + "video": "視訊", "waiting_for_connection": "等待連線", "xml": "XML" }, @@ -494,7 +494,7 @@ "short_codes_description": "我們為您打造的快捷碼。", "sidebar_on_left": "左側邊欄", "sync": "同步", - "sync_collections": "組合", + "sync_collections": "集合", "sync_description": "這些設定會同步到雲端。", "sync_environments": "環境", "sync_history": "歷史", @@ -551,7 +551,7 @@ "previous_method": "選擇上一個方法", "put_method": "選擇 PUT 方法", "reset_request": "重置請求", - "save_to_collections": "儲存到組合", + "save_to_collections": "儲存到集合", "send_request": "傳送請求", "title": "請求" }, @@ -570,7 +570,7 @@ }, "show": { "code": "顯示程式碼", - "collection": "顯示組合面板", + "collection": "顯示集合面板", "more": "顯示更多", "sidebar": "顯示側邊欄" }, @@ -639,9 +639,9 @@ "tab": { "authorization": "授權", "body": "請求本體", - "collections": "組合", + "collections": "集合", "documentation": "幫助文件", - "environments": "Environments", + "environments": "環境", "headers": "請求標頭", "history": "歷史記錄", "mqtt": "MQTT", @@ -666,7 +666,7 @@ "email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。", "exit": "退出團隊", "exit_disabled": "團隊擁有者無法退出團隊", - "invalid_coll_id": "Invalid collection ID", + "invalid_coll_id": "集合 ID 無效", "invalid_email_format": "電子信箱格式無效", "invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。", "invalid_invite_link": "邀請連結無效", @@ -690,21 +690,21 @@ "member_removed": "使用者已移除", "member_role_updated": "使用者角色已更新", "members": "成員", - "more_members": "+{count} more", + "more_members": "還有 {count} 位", "name_length_insufficient": "團隊名稱至少為 6 個字元", "name_updated": "團隊名稱已更新", "new": "新團隊", "new_created": "已建立新團隊", "new_name": "我的新團隊", - "no_access": "您沒有編輯組合的許可權", + "no_access": "您沒有編輯集合的許可權", "no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。", - "no_request_found": "Request not found.", + "no_request_found": "找不到請求。", "not_found": "找不到團隊。請聯絡您的團隊擁有者。", "not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。", - "parent_coll_move": "Cannot move collection to a child collection", + "parent_coll_move": "無法將集合移動至子集合", "pending_invites": "待定邀請", "permissions": "許可權", - "same_target_destination": "Same target and destination", + "same_target_destination": "目標和目的地相同", "saved": "團隊已儲存", "select_a_team": "選擇團隊", "title": "團隊", @@ -734,9 +734,9 @@ "url": "網址" }, "workspace": { - "change": "Change workspace", - "personal": "My Workspace", - "team": "Team Workspace", - "title": "Workspaces" + "change": "切換工作區", + "personal": "我的工作區", + "team": "團隊工作區", + "title": "工作區" } } 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<{