From 2ed5a045de769a2bd174d6ba88650a2c393d3c43 Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Wed, 8 Feb 2023 16:39:19 +0600 Subject: [PATCH] feat: team request module added --- packages/hoppscotch-backend/src/app.module.ts | 2 + .../src/pubsub/topicsDefs.ts | 3 + .../guards/gql-request-team-member.guard.ts | 58 + .../src/team-request/team-request.model.ts | 62 + .../src/team-request/team-request.module.ts | 26 + .../src/team-request/team-request.resolver.ts | 282 ++++ .../team-request/team-request.service.spec.ts | 1141 +++++++++++++++++ .../src/team-request/team-request.service.ts | 331 +++++ 8 files changed, 1905 insertions(+) create mode 100644 packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts create mode 100644 packages/hoppscotch-backend/src/team-request/team-request.model.ts create mode 100644 packages/hoppscotch-backend/src/team-request/team-request.module.ts create mode 100644 packages/hoppscotch-backend/src/team-request/team-request.resolver.ts create mode 100644 packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/team-request/team-request.service.ts diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index bd81838ea..4c7e07193 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -11,6 +11,7 @@ import { subscriptionContextCookieParser } from './auth/helper'; import { TeamModule } from './team/team.module'; import { TeamEnvironmentsModule } from './team-environments/team-environments.module'; import { TeamCollectionModule } from './team-collection/team-collection.module'; +import { TeamRequestModule } from './team-request/team-request.module'; @Module({ imports: [ @@ -51,6 +52,7 @@ import { TeamCollectionModule } from './team-collection/team-collection.module'; TeamModule, TeamEnvironmentsModule, TeamCollectionModule, + TeamRequestModule, ], providers: [GQLComplexityPlugin], }) diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index cd7580168..39d7e26f1 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -5,6 +5,7 @@ import { UserHistory } from '../user-history/user-history.model'; import { TeamMember } from 'src/team/team.model'; import { TeamEnvironment } from 'src/team-environments/team-environments.model'; import { TeamCollection } from 'src/team-collection/team-collection.model'; +import { TeamRequest } from 'src/team-request/team-request.model'; // A custom message type that defines the topic and the corresponding payload. // For every module that publishes a subscription add its type def and the possible subscription type. @@ -30,4 +31,6 @@ export type TopicDef = { | 'coll_removed'}` ]: TeamCollection; [topic: `user_history/${string}/deleted_many`]: number; + [topic: `team_req/${string}/${'req_created' | 'req_updated'}`]: TeamRequest; + [topic: `team_req/${string}/req_deleted`]: string; }; diff --git a/packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts b/packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts new file mode 100644 index 000000000..43bfbfad0 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/guards/gql-request-team-member.guard.ts @@ -0,0 +1,58 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { TeamRequestService } from '../team-request.service'; +import { TeamService } from '../../team/team.service'; +import { User } from '../../user/user.model'; +import { Reflector } from '@nestjs/core'; +import { TeamMemberRole } from '../../team/team.model'; +import { + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_REQ_NO_REQ_ID, + TEAM_REQ_NOT_REQUIRED_ROLE, + TEAM_REQ_NOT_MEMBER, +} from 'src/errors'; +import { throwErr } from 'src/utils'; + +@Injectable() +export class GqlRequestTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamRequestService: TeamRequestService, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + + const gqlExecCtx = GqlExecutionContext.create(context); + + const { user } = gqlExecCtx.getContext().req; + if (!user) throw new Error(BUG_AUTH_NO_USER_CTX); + + const { requestID } = gqlExecCtx.getArgs<{ requestID: string }>(); + if (!requestID) throw new Error(BUG_TEAM_REQ_NO_REQ_ID); + + const team = await this.teamRequestService.getTeamOfRequestFromID( + requestID, + ); + + const member = + (await this.teamService.getTeamMember(team.id, user.uid)) ?? + throwErr(TEAM_REQ_NOT_MEMBER); + + if (requireRoles) { + if (requireRoles.includes(member.role)) { + return true; + } else { + throw new Error(TEAM_REQ_NOT_REQUIRED_ROLE); + } + } + + if (member) return true; + + throw new Error(TEAM_REQ_NOT_MEMBER); + } +} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.model.ts b/packages/hoppscotch-backend/src/team-request/team-request.model.ts new file mode 100644 index 000000000..914124f5c --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.model.ts @@ -0,0 +1,62 @@ +import { ObjectType, Field, ID, InputType } from '@nestjs/graphql'; + +@InputType() +export class CreateTeamRequestInput { + @Field(() => ID, { + description: 'ID of the team the collection belongs to', + }) + teamID: string; + + @Field({ + description: 'JSON string representing the request data', + }) + request: string; + + @Field({ + description: 'Displayed title of the request', + }) + title: string; +} + +@InputType() +export class UpdateTeamRequestInput { + @Field({ + description: 'JSON string representing the request data', + nullable: true, + }) + request?: string; + + @Field({ + description: 'Displayed title of the request', + nullable: true, + }) + title?: string; +} + +@ObjectType() +export class TeamRequest { + @Field(() => ID, { + description: 'ID of the request', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the collection the request belongs to.', + }) + collectionID: string; + + @Field(() => ID, { + description: 'ID of the team the request belongs to.', + }) + teamID: string; + + @Field({ + description: 'JSON string representing the request data', + }) + request: string; + + @Field({ + description: 'Displayed title of the request', + }) + title: string; +} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.module.ts b/packages/hoppscotch-backend/src/team-request/team-request.module.ts new file mode 100644 index 000000000..4b3bd4a63 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TeamRequestService } from './team-request.service'; +import { TeamRequestResolver } from './team-request.resolver'; +import { PrismaModule } from '../prisma/prisma.module'; +import { TeamModule } from '../team/team.module'; +import { TeamCollectionModule } from '../team-collection/team-collection.module'; +import { GqlRequestTeamMemberGuard } from './guards/gql-request-team-member.guard'; +import { UserModule } from '../user/user.module'; +import { PubSubModule } from 'src/pubsub/pubsub.module'; + +@Module({ + imports: [ + PrismaModule, + TeamModule, + TeamCollectionModule, + UserModule, + PubSubModule, + ], + providers: [ + TeamRequestService, + TeamRequestResolver, + GqlRequestTeamMemberGuard, + ], + exports: [TeamRequestService, GqlRequestTeamMemberGuard], +}) +export class TeamRequestModule {} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.resolver.ts b/packages/hoppscotch-backend/src/team-request/team-request.resolver.ts new file mode 100644 index 000000000..f78ca4626 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.resolver.ts @@ -0,0 +1,282 @@ +import { + Resolver, + ResolveField, + Parent, + Args, + Query, + Mutation, + Subscription, + ID, +} from '@nestjs/graphql'; +import { + TeamRequest, + CreateTeamRequestInput, + UpdateTeamRequestInput, +} from './team-request.model'; +import { Team, TeamMemberRole } from '../team/team.model'; +import { TeamRequestService } from './team-request.service'; +import { TeamCollection } from '../team-collection/team-collection.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlRequestTeamMemberGuard } from './guards/gql-request-team-member.guard'; +import { GqlCollectionTeamMemberGuard } from '../team-collection/guards/gql-collection-team-member.guard'; +import { RequiresTeamRole } from '../team/decorators/requires-team-role.decorator'; +import { GqlTeamMemberGuard } from '../team/guards/gql-team-member.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { pipe } from 'fp-ts/function'; +import * as TE from 'fp-ts/TaskEither'; +import { throwErr } from 'src/utils'; + +@Resolver(() => TeamRequest) +export class TeamRequestResolver { + constructor( + private readonly teamRequestService: TeamRequestService, + private readonly pubsub: PubSubService, + ) {} + + // Field resolvers + @ResolveField(() => Team, { + description: 'Team the request belongs to', + complexity: 3, + }) + team(@Parent() req: TeamRequest): Promise { + return this.teamRequestService.getTeamOfRequest(req); + } + + @ResolveField(() => TeamCollection, { + description: 'Collection the request belongs to', + complexity: 3, + }) + collection(@Parent() req: TeamRequest): Promise { + return this.teamRequestService.getCollectionOfRequest(req); + } + + // Query + @Query(() => [TeamRequest], { + description: 'Search the team for a specific request with title', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + searchForRequest( + @Args({ + name: 'teamID', + description: 'ID of the team to look in', + type: () => ID, + }) + teamID: string, + @Args({ name: 'searchTerm', description: 'The title to search for' }) + searchTerm: string, + @Args({ + name: 'cursor', + type: () => ID, + description: 'ID of the last returned request or null', + nullable: true, + }) + cursor?: string, + ): Promise { + return this.teamRequestService.searchRequest( + teamID, + searchTerm, + cursor ?? null, + ); + } + + @Query(() => TeamRequest, { + description: 'Gives a request with the given ID or null (if not exists)', + nullable: true, + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + request( + @Args({ + name: 'requestID', + description: 'ID of the request', + type: () => ID, + }) + requestID: string, + ): Promise { + return this.teamRequestService.getRequest(requestID); + } + + @Query(() => [TeamRequest], { + description: 'Gives a list of requests in the collection', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.EDITOR, + TeamMemberRole.OWNER, + TeamMemberRole.VIEWER, + ) + requestsInCollection( + @Args({ + name: 'collectionID', + description: 'ID of the collection', + type: () => ID, + }) + collectionID: string, + @Args({ + name: 'cursor', + nullable: true, + type: () => ID, + description: 'ID of the last returned request (for pagination)', + }) + cursor?: string, + ): Promise { + return this.teamRequestService.getRequestsInCollection( + collectionID, + cursor ?? null, + ); + } + + // Mutation + @Mutation(() => TeamRequest, { + description: 'Create a request in the given collection.', + }) + @UseGuards(GqlAuthGuard, GqlCollectionTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.EDITOR, TeamMemberRole.OWNER) + createRequestInCollection( + @Args({ + name: 'collectionID', + description: 'ID of the collection', + type: () => ID, + }) + collectionID: string, + @Args({ + name: 'data', + type: () => CreateTeamRequestInput, + description: + 'The request data (stringified JSON of Hoppscotch request object)', + }) + data: CreateTeamRequestInput, + ): Promise { + return this.teamRequestService.createTeamRequest(collectionID, data); + } + + @Mutation(() => TeamRequest, { + description: 'Update a request with the given ID', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.EDITOR, TeamMemberRole.OWNER) + updateRequest( + @Args({ + name: 'requestID', + description: 'ID of the request', + type: () => ID, + }) + requestID: string, + @Args({ + name: 'data', + type: () => UpdateTeamRequestInput, + description: + 'The updated request data (stringified JSON of Hoppscotch request object)', + }) + data: UpdateTeamRequestInput, + ): Promise { + return this.teamRequestService.updateTeamRequest(requestID, data); + } + + @Mutation(() => Boolean, { + description: 'Delete a request with the given ID', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.EDITOR, TeamMemberRole.OWNER) + async deleteRequest( + @Args({ + name: 'requestID', + description: 'ID of the request', + type: () => ID, + }) + requestID: string, + ): Promise { + await this.teamRequestService.deleteTeamRequest(requestID); + return true; + } + + @Mutation(() => TeamRequest, { + description: 'Move a request to the given collection', + }) + @UseGuards(GqlAuthGuard, GqlRequestTeamMemberGuard) + @RequiresTeamRole(TeamMemberRole.EDITOR, TeamMemberRole.OWNER) + moveRequest( + @Args({ + name: 'requestID', + description: 'ID of the request to move', + type: () => ID, + }) + requestID: string, + @Args({ + name: 'destCollID', + description: 'ID of the collection to move the request to', + type: () => ID, + }) + destCollID: string, + ): Promise { + return pipe( + this.teamRequestService.moveRequest(requestID, destCollID), + TE.getOrElse((e) => throwErr(e)), + )(); + } + + // Subscriptions + @Subscription(() => TeamRequest, { + description: 'Emits when a new request is added to a team', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.VIEWER, + TeamMemberRole.EDITOR, + TeamMemberRole.OWNER, + ) + teamRequestAdded( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_created`); + } + + @Subscription(() => TeamRequest, { + description: 'Emitted when a request has been updated', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.VIEWER, + TeamMemberRole.EDITOR, + TeamMemberRole.OWNER, + ) + teamRequestUpdated( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_updated`); + } + + @Subscription(() => ID, { + description: + 'Emitted when a request has been deleted. Only the id of the request is emitted.', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamMemberRole.VIEWER, + TeamMemberRole.EDITOR, + TeamMemberRole.OWNER, + ) + teamRequestDeleted( + @Args({ + name: 'teamID', + description: 'ID of the team to listen to', + type: () => ID, + }) + teamID: string, + ): AsyncIterator { + return this.pubsub.asyncIterator(`team_req/${teamID}/req_deleted`); + } +} diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts new file mode 100644 index 000000000..fbb5880ac --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts @@ -0,0 +1,1141 @@ +import { PrismaService } from '../prisma/prisma.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { TeamService } from '../team/team.service'; +import { TeamRequestService } from './team-request.service'; +import { + TEAM_REQ_INVALID_TARGET_COLL_ID, + TEAM_INVALID_COLL_ID, + TEAM_INVALID_ID, + TEAM_REQ_NOT_FOUND, +} from 'src/errors'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import * as TO from 'fp-ts/TaskOption'; +import { TeamCollection } from 'src/team-collection/team-collection.model'; +import { TeamRequest } from './team-request.model'; + +const mockPrisma = mockDeep(); + +const mockTeamService = mockDeep(); + +const mockTeamCollectionService = mockDeep(); + +const mockPubSub = { + publish: jest.fn().mockResolvedValue(null), +}; + +const teamRequestService = new TeamRequestService( + mockPrisma as any, + mockTeamService as any, + mockTeamCollectionService as any, + mockPubSub as any, +); + +beforeEach(async () => { + mockReset(mockPrisma); +}); + +describe('updateTeamRequest', () => { + test('resolves correctly if title is null', async () => { + mockPrisma.teamRequest.update.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + await expect( + teamRequestService.updateTeamRequest('testrequest', { + request: '{}', + title: undefined, + }), + ).resolves.toBeDefined(); + }); + + test('resolves correctly if request is null', async () => { + mockPrisma.teamRequest.update.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + await expect( + teamRequestService.updateTeamRequest('testrequest', { + request: undefined, + title: 'Test Request', + }), + ).resolves.toBeDefined(); + }); + + test('resolves correctly if both request and title are null', async () => { + mockPrisma.teamRequest.update.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + await expect( + teamRequestService.updateTeamRequest('testrequest', { + request: undefined, + title: undefined, + }), + ).resolves.toBeDefined(); + }); + + test('resolves correctly for non-null request and title', async () => { + mockPrisma.teamRequest.update.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + await expect( + teamRequestService.updateTeamRequest('testrequest', { + request: '{}', + title: 'Test Request', + }), + ).resolves.toBeDefined(); + }); + + test('rejects for invalid request id', async () => { + mockPrisma.teamRequest.update.mockRejectedValue('RecordNotFound'); + + await expect( + teamRequestService.updateTeamRequest('invalidtestreq', { + request: undefined, + title: undefined, + }), + ).rejects.toBeDefined(); + }); + + test('resolves for valid request id', async () => { + mockPrisma.teamRequest.update.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + await expect( + teamRequestService.updateTeamRequest('testrequest', { + request: undefined, + title: undefined, + }), + ).resolves.toBeDefined(); + }); + + test('publishes update to pubsub topic "team_req//req_updated"', async () => { + mockPrisma.teamRequest.update.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + const result = await teamRequestService.updateTeamRequest('testrequest', { + request: undefined, + title: undefined, + }); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + 'team_req/3170/req_updated', + result, + ); + }); +}); + +describe('searchRequest', () => { + test('resolves with the correct info with a null cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }, + ]); + + await expect( + teamRequestService.searchRequest('3170', 'Test', null), + ).resolves.toBeDefined(); + }); + + test('resolves with an empty array when a match with the search term is not found', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([]); + + await expect( + teamRequestService.searchRequest('3170', 'Test', null), + ).resolves.toBeDefined(); + }); + + test('resolves with the correct info with a set cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest1', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 1', + }, + { + id: 'testrequest2', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 2', + }, + { + id: 'testrequest3', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 3', + }, + { + id: 'testrequest4', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 4', + }, + { + id: 'testrequest5', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 5', + }, + { + id: 'testrequest6', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 6', + }, + { + id: 'testrequest7', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 7', + }, + { + id: 'testrequest8', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 8', + }, + { + id: 'testrequest9', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 9', + }, + { + id: 'testrequest10', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 10', + }, + ]); + + const secondColl = ( + await teamRequestService.searchRequest('3170', 'Test', null) + )[1]; + + mockReset(mockPrisma); + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest11', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 11', + }, + ]); + + await expect( + teamRequestService.searchRequest('3170', 'Test', secondColl.id), + ).resolves.toBeDefined(); + }); + + test('resolves with the first ten elements when a null cursor is entered', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest1', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 1', + }, + { + id: 'testrequest2', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 2', + }, + { + id: 'testrequest3', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 3', + }, + { + id: 'testrequest4', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 4', + }, + { + id: 'testrequest5', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 5', + }, + { + id: 'testrequest6', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 6', + }, + { + id: 'testrequest7', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 7', + }, + { + id: 'testrequest8', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 8', + }, + { + id: 'testrequest9', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 9', + }, + { + id: 'testrequest10', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 10', + }, + ]); + + await expect( + teamRequestService.searchRequest('3170', 'Test', null), + ).resolves.toHaveLength(10); + }); +}); + +describe('deleteTeamRequest', () => { + test('rejects if the request id is not found', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(null as any); + + mockPrisma.teamRequest.delete.mockRejectedValue('RecordNotFound'); + + await expect( + teamRequestService.deleteTeamRequest('invalidrequest'), + ).rejects.toThrow(TEAM_REQ_NOT_FOUND); + expect(mockPrisma.teamRequest.delete).not.toHaveBeenCalled(); + }); + + test('resolves for a valid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + mockPrisma.teamRequest.delete.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + await expect( + teamRequestService.deleteTeamRequest('testrequest'), + ).resolves.toBeUndefined(); + }); + + test('publishes deletion to pubsub topic "team_req//req_deleted"', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + mockPrisma.teamRequest.delete.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + 'team_req/3170/req_deleted', + 'testrequest', + ); + }); +}); + +describe('createTeamRequest', () => { + test('rejects for invalid collection id', async () => { + mockPrisma.teamCollection.findUnique.mockRejectedValue( + TEAM_INVALID_COLL_ID, + ); + + mockPrisma.teamRequest.create.mockRejectedValue(null as any); + + await expect( + teamRequestService.createTeamRequest('invalidcollid', { + teamID: '3170', + request: '{}', + title: 'Test Request', + }), + ).rejects.toBeDefined(); + + expect(mockPrisma.teamRequest.create).not.toHaveBeenCalled(); + }); + + test('resolves for valid collection id', async () => { + mockTeamCollectionService.getTeamOfCollection.mockResolvedValue({ + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + team: { + id: '3170', + name: 'Test Team', + }, + } as any); + + mockPrisma.teamRequest.create.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + team: { + id: '3170', + name: 'Test Team', + }, + collection: { + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + }, + } as any); + + await expect( + teamRequestService.createTeamRequest('testcoll', { + teamID: '3170', + request: '{}', + title: 'Test Request', + }), + ).resolves.toBeDefined(); + }); + + test('publishes creation to pubsub topic "team_req//req_created"', async () => { + mockTeamCollectionService.getTeamOfCollection.mockResolvedValue({ + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + team: { + id: '3170', + name: 'Test Team', + }, + } as any); + + mockPrisma.teamRequest.create.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + team: { + id: '3170', + name: 'Test Team', + }, + collection: { + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + }, + } as any); + + const result = await teamRequestService.createTeamRequest('testcoll', { + teamID: '3170', + request: '{}', + title: 'Test Request', + }); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + 'team_req/3170/req_created', + result, + ); + }); +}); + +describe('getRequestsInCollection', () => { + test('resolves with an empty array if the collection id does not exist', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([]); + + await expect( + teamRequestService.getRequestsInCollection('invalidcoll', null), + ).resolves.toEqual([]); + }); + + test('resolves with the correct info for the collection id and null cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }, + ]); + + await expect( + teamRequestService.getRequestsInCollection('testcoll', null), + ).resolves.toBeDefined(); + }); + + test('resolves with the correct info for the collection id and a valid cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest1', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 1', + }, + { + id: 'testrequest2', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 2', + }, + { + id: 'testrequest3', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 3', + }, + { + id: 'testrequest4', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 4', + }, + { + id: 'testrequest5', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 5', + }, + { + id: 'testrequest6', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 6', + }, + { + id: 'testrequest7', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 7', + }, + { + id: 'testrequest8', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 8', + }, + { + id: 'testrequest9', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 9', + }, + { + id: 'testrequest10', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 10', + }, + ]); + + const secondColl = ( + await teamRequestService.getRequestsInCollection('testcoll', null) + )[1]; + + mockReset(mockPrisma); + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest11', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 11', + }, + ]); + + await expect( + teamRequestService.getRequestsInCollection('testcoll', secondColl.id), + ).resolves.toBeDefined(); + }); + + test('resolves with the correct info for the collection id and a valid cursor', async () => { + mockPrisma.teamRequest.findMany.mockResolvedValue([ + { + id: 'testrequest1', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 1', + }, + { + id: 'testrequest2', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 2', + }, + { + id: 'testrequest3', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 3', + }, + { + id: 'testrequest4', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 4', + }, + { + id: 'testrequest5', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 5', + }, + { + id: 'testrequest6', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 6', + }, + { + id: 'testrequest7', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 7', + }, + { + id: 'testrequest8', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 8', + }, + { + id: 'testrequest9', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 9', + }, + { + id: 'testrequest10', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request 10', + }, + ]); + + await expect( + teamRequestService.getRequestsInCollection('testcoll', null), + ).resolves.toHaveLength(10); + }); +}); + +describe('getRequest', () => { + test('resolves with the correct request info for valid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + team: { + id: '3170', + name: 'Test Team', + }, + collection: { + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + }, + } as any); + + await expect(teamRequestService.getRequest('testrequest')).resolves.toEqual( + expect.objectContaining({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '"{}"', + title: 'Test Request', + }), + ); + }); + + test('resolves with null if the request id does not exist', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(null as any); + + await expect( + teamRequestService.getRequest('testrequest'), + ).resolves.toBeNull(); + }); +}); + +describe('getTeamOfRequest', () => { + test('rejects for invalid team id', async () => { + mockTeamService.getTeamWithID.mockResolvedValue(null as any); + + await expect( + teamRequestService.getTeamOfRequest({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: 'invalidteamid', + request: '{}', + title: 'Test Request', + }), + ).rejects.toThrow(TEAM_INVALID_ID); + }); + + test('resolves for valid team id', async () => { + mockTeamService.getTeamWithID.mockResolvedValue({ + id: '3170', + name: 'Test Team', + }); + + await expect( + teamRequestService.getTeamOfRequest({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }), + ).resolves.toEqual( + expect.objectContaining({ + id: '3170', + name: 'Test Team', + }), + ); + }); +}); + +describe('getCollectionOfRequest', () => { + test('rejects for invalid collection id', async () => { + mockTeamCollectionService.getCollection.mockResolvedValue(null as any); + + await expect( + teamRequestService.getCollectionOfRequest({ + id: 'testrequest', + collectionID: 'invalidcollid', + teamID: '3170', + request: '{}', + title: 'Test Request', + }), + ).rejects.toThrow(TEAM_INVALID_COLL_ID); + }); + + test('resolves for valid collection id', async () => { + mockTeamCollectionService.getCollection.mockResolvedValue({ + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + }); + + await expect( + teamRequestService.getCollectionOfRequest({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + }), + ).resolves.toEqual( + expect.objectContaining({ + id: 'testcoll', + title: 'Test Collection', + parentID: null, + teamID: '3170', + }), + ); + }); +}); + +describe('getTeamOfRequestFromID', () => { + test('rejects for invalid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(null as any); + + await expect( + teamRequestService.getTeamOfRequestFromID('invalidrequest'), + ).rejects.toThrow(TEAM_REQ_NOT_FOUND); + }); + + test('resolves for valid request id', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + await expect( + teamRequestService.getTeamOfRequestFromID('testrequest'), + ).resolves.toEqual( + expect.objectContaining({ + id: '3170', + name: 'Test team', + }), + ); + }); + + describe('getRequestTO', () => { + test('should resolve to Some for valid collection ID', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: '{}', + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + expect(await teamRequestService.getRequestTO('testrequest')()).toBeSome(); + }); + + test('should resolve to the correct Some value for a valid collection ID', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + expect( + await teamRequestService.getRequestTO('testrequest')(), + ).toEqualSome({ + id: 'testrequest', + teamID: '3170', + collectionID: 'testcoll', + request: '{}', + title: 'Test Request', + }); + }); + + test('should resolve a None value if the the request ID does not exist', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce(null); + + expect(await teamRequestService.getRequestTO('testrequest')()).toBeNone(); + }); + }); + + describe('moveRequest', () => { + test('resolves to right when the move was valid', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + mockTeamCollectionService.getCollectionTO.mockImplementationOnce(() => + TO.some({ + id: 'testcoll2', + parentID: 'testcoll', + teamID: '3170', + title: 'Test Team', + }), + ); + + mockPrisma.teamRequest.update.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll2', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + expect( + await teamRequestService.moveRequest('testrequest', 'testcoll2')(), + ).toBeRight(); + }); + + test('resolves to a left with TEAM_REQ_NOT_FOUND if the reqID is invalid', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValue(null); + + expect( + await teamRequestService.moveRequest( + 'invalidtestrequest', + 'testcoll2', + )(), + ).toEqualLeft(TEAM_REQ_NOT_FOUND); + }); + + test('resolves to the left with TEAM_REQ_INVALID_TARGET_COLL_ID if the collection is invalid', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + mockTeamCollectionService.getCollectionTO.mockImplementationOnce( + () => TO.none, + ); + + expect( + await teamRequestService.moveRequest( + 'testrequest', + 'invalidcollection', + )(), + ).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); + }); + + test('resolves to a left with TEAM_REQ_INVALID_TARGET_ID if the request and destination collection are not in the same team', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + mockTeamCollectionService.getCollectionTO.mockImplementationOnce(() => + TO.some({ + id: 'testcoll2', + parentID: 'testcoll', + teamID: 'differentteamID', + title: 'Test Team', + }), + ); + + expect( + await teamRequestService.moveRequest('testrequest', 'testcoll2')(), + ).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); + }); + + test('resolves to the right with the correct output model object for a valid query', async () => { + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + mockTeamCollectionService.getCollectionTO.mockImplementationOnce(() => + TO.some({ + id: 'testcoll2', + parentID: 'testcoll', + teamID: '3170', + title: 'Test Team', + }), + ); + + mockPrisma.teamRequest.update.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll2', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + expect( + await teamRequestService.moveRequest('testrequest', 'testcoll2')(), + ).toEqualRight({ + id: 'testrequest', + collectionID: 'testcoll2', + request: '{}', + teamID: '3170', + title: 'Test Request', + }); + }); + + test('publishes to the pubsub on a valid move', async () => { + mockPubSub.publish.mockReset(); + + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + mockTeamCollectionService.getCollectionTO.mockImplementationOnce(() => + TO.some({ + id: 'testcoll2', + parentID: 'testcoll', + teamID: '3170', + title: 'Test Team', + }), + ); + + mockPrisma.teamRequest.update.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll2', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + await teamRequestService.moveRequest('testrequest', 'testcoll2')(); + + expect(mockPubSub.publish).toHaveBeenCalledTimes(2); + expect(mockPubSub.publish).toHaveBeenNthCalledWith( + 1, + `team_req/3170/req_deleted`, + 'testrequest', + ); + expect(mockPubSub.publish).toHaveBeenNthCalledWith( + 2, + `team_req/3170/req_created`, + { + id: 'testrequest', + collectionID: 'testcoll2', + teamID: '3170', + request: '{}', + title: 'Test Request', + }, + ); + }); + + test('does not publish to the pubsub on an invalid move', async () => { + mockPubSub.publish.mockReset(); + + mockPrisma.teamRequest.findUnique.mockResolvedValueOnce({ + id: 'testrequest', + collectionID: 'testcoll', + teamID: '3170', + request: {}, + title: 'Test Request', + team: { + id: '3170', + name: 'Test team', + }, + } as any); + + mockTeamCollectionService.getCollectionTO.mockImplementationOnce( + () => TO.none, + ); + + await teamRequestService.moveRequest('testrequest', 'testcoll')(); + + expect(mockPubSub.publish).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts new file mode 100644 index 000000000..b59f62b84 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -0,0 +1,331 @@ +import { Injectable } from '@nestjs/common'; +import { TeamService } from '../team/team.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + TeamRequest, + CreateTeamRequestInput, + UpdateTeamRequestInput, +} from './team-request.model'; +import { Team } from '../team/team.model'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { TeamCollection } from '../team-collection/team-collection.model'; +import { + TEAM_REQ_INVALID_TARGET_COLL_ID, + TEAM_INVALID_COLL_ID, + TEAM_INVALID_ID, + TEAM_REQ_NOT_FOUND, +} from 'src/errors'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { throwErr } from 'src/utils'; +import { pipe } from 'fp-ts/function'; +import * as TO from 'fp-ts/TaskOption'; +import * as TE from 'fp-ts/TaskEither'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class TeamRequestService { + constructor( + private readonly prisma: PrismaService, + private readonly teamService: TeamService, + private readonly teamCollectionService: TeamCollectionService, + private readonly pubsub: PubSubService, + ) {} + + async updateTeamRequest( + requestID: string, + input: UpdateTeamRequestInput, + ): Promise { + const updateData = {}; + if (input.request) updateData['request'] = JSON.parse(input.request); + if (input.title !== null) updateData['title'] = input.title; + + const data = await this.prisma.teamRequest.update({ + where: { + id: requestID, + }, + data: { + ...updateData, + }, + }); + + const result: TeamRequest = { + id: data.id, + collectionID: data.collectionID, + request: JSON.stringify(data.request), + title: data.title, + teamID: data.teamID, + }; + + this.pubsub.publish(`team_req/${data.teamID}/req_updated`, result); + + return result; + } + + async searchRequest( + teamID: string, + searchTerm: string, + cursor: string | null, + ): Promise { + if (!cursor) { + const data = await this.prisma.teamRequest.findMany({ + take: 10, + where: { + teamID, + title: { + contains: searchTerm, + }, + }, + }); + + return data.map((d) => { + return { + title: d.title, + id: d.id, + teamID: d.teamID, + collectionID: d.collectionID, + request: d.request!.toString(), + }; + }); + } else { + const data = await this.prisma.teamRequest.findMany({ + take: 10, + skip: 1, + cursor: { + id: cursor, + }, + where: { + teamID, + title: { + contains: searchTerm, + }, + }, + }); + + return data.map((d) => { + return { + title: d.title, + id: d.id, + teamID: d.teamID, + collectionID: d.collectionID, + request: d.request!.toString(), + }; + }); + } + } + + async deleteTeamRequest(requestID: string): Promise { + const req = await this.getRequest(requestID); + + if (!req) throw new Error(TEAM_REQ_NOT_FOUND); + + await this.prisma.teamRequest.delete({ + where: { + id: requestID, + }, + }); + + this.pubsub.publish(`team_req/${req.teamID}/req_deleted`, requestID); + } + + async createTeamRequest( + collectionID: string, + input: CreateTeamRequestInput, + ): Promise { + const team = await this.teamCollectionService.getTeamOfCollection( + collectionID, + ); + + const data = await this.prisma.teamRequest.create({ + data: { + team: { + connect: { + id: team.id, + }, + }, + request: JSON.parse(input.request), + title: input.title, + collection: { + connect: { + id: collectionID, + }, + }, + }, + }); + + const result = { + id: data.id, + collectionID: data.collectionID, + title: data.title, + request: JSON.stringify(data.request), + teamID: data.teamID, + }; + + this.pubsub.publish(`team_req/${result.teamID}/req_created`, result); + + return result; + } + + async getRequestsInCollection( + collectionID: string, + cursor: string | null, + ): Promise { + if (!cursor) { + const res = await this.prisma.teamRequest.findMany({ + take: 10, + where: { + collectionID, + }, + }); + + return res.map((e) => { + return { + id: e.id, + collectionID: e.collectionID, + teamID: e.teamID, + request: JSON.stringify(e.request), + title: e.title, + }; + }); + } else { + const res = await this.prisma.teamRequest.findMany({ + cursor: { + id: cursor, + }, + take: 10, + skip: 1, + where: { + collectionID, + }, + }); + + return res.map((e) => { + return { + id: e.id, + collectionID: e.collectionID, + teamID: e.teamID, + request: JSON.stringify(e.request), + title: e.title, + }; + }); + } + } + + async getRequest(reqID: string): Promise { + const res = await this.prisma.teamRequest.findUnique({ + where: { + id: reqID, + }, + }); + + if (!res) return null; + + return { + id: res.id, + teamID: res.teamID, + collectionID: res.collectionID, + request: JSON.stringify(res.request), + title: res.title, + }; + } + + getRequestTO(reqID: string): TO.TaskOption { + return pipe( + TO.fromTask(() => this.getRequest(reqID)), + TO.chain(TO.fromNullable), + ); + } + + async getTeamOfRequest(req: TeamRequest): Promise { + return ( + (await this.teamService.getTeamWithID(req.teamID)) ?? + throwErr(TEAM_INVALID_ID) + ); + } + + async getCollectionOfRequest(req: TeamRequest): Promise { + return ( + (await this.teamCollectionService.getCollection(req.collectionID)) ?? + throwErr(TEAM_INVALID_COLL_ID) + ); + } + + async getTeamOfRequestFromID(reqID: string): Promise { + const req = + (await this.prisma.teamRequest.findUnique({ + where: { + id: reqID, + }, + include: { + team: true, + }, + })) ?? throwErr(TEAM_REQ_NOT_FOUND); + + return req.team; + } + + moveRequest(reqID: string, destinationCollID: string) { + return pipe( + TE.Do, + + // Check if the request exists + TE.bind('request', () => + pipe( + this.getRequestTO(reqID), + TE.fromTaskOption(() => TEAM_REQ_NOT_FOUND), + ), + ), + + // Check if the destination collection exists (or null) + TE.bindW('targetCollection', () => + pipe( + this.teamCollectionService.getCollectionTO(destinationCollID), + TE.fromTaskOption(() => TEAM_REQ_INVALID_TARGET_COLL_ID), + ), + ), + + // Block operation if target collection is not part of the same team + // as the request + TE.chainW( + TE.fromPredicate( + ({ request, targetCollection }) => + request.teamID === targetCollection.teamID, + () => TEAM_REQ_INVALID_TARGET_COLL_ID, + ), + ), + + // Update the collection + TE.chain(({ request, targetCollection }) => + TE.fromTask(() => + this.prisma.teamRequest.update({ + where: { + id: request.id, + }, + data: { + collectionID: targetCollection.id, + }, + }), + ), + ), + + // Generate TeamRequest model object + TE.map( + (request) => + { + id: request.id, + collectionID: request.collectionID, + request: JSON.stringify(request.request), + teamID: request.teamID, + title: request.title, + }, + ), + + // Update on PubSub + TE.chainFirst((req) => { + this.pubsub.publish(`team_req/${req.teamID}/req_deleted`, req.id); + this.pubsub.publish(`team_req/${req.teamID}/req_created`, req); + + return TE.of({}); // We don't care about the return type + }), + ); + } +}