diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 91ede88f0..edbcc8512 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -154,6 +154,20 @@ model UserEnvironment { isGlobal Boolean } +model UserRequest { + id String @id @default(cuid()) + userCollection UserCollection @relation(fields: [collectionID], references: [id]) + collectionID String + userUid String + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + title String + request Json + type ReqType + orderIndex Int + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) +} + model UserCollection { id String @id @default(cuid()) parentID String? @@ -169,20 +183,6 @@ model UserCollection { updatedOn DateTime @updatedAt @db.Timestamp(3) } -model UserRequest { - id String @id @default(cuid()) - collectionID String - collection UserCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) - userUid String - user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) - title String - request Json - type ReqType - orderIndex Int - createdOn DateTime @default(now()) @db.Timestamp(3) - updatedOn DateTime @updatedAt @db.Timestamp(3) -} - enum TeamMemberRole { OWNER VIEWER diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 42293c9c2..fb34b3b1b 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -6,6 +6,7 @@ import { GQLComplexityPlugin } from './plugins/GQLComplexityPlugin'; import { AuthModule } from './auth/auth.module'; import { UserSettingsModule } from './user-settings/user-settings.module'; import { UserEnvironmentsModule } from './user-environment/user-environments.module'; +import { UserRequestModule } from './user-request/user-request.module'; import { UserHistoryModule } from './user-history/user-history.module'; import { subscriptionContextCookieParser } from './auth/helper'; import { TeamModule } from './team/team.module'; @@ -60,6 +61,7 @@ import { COOKIES_NOT_FOUND } from './errors'; UserSettingsModule, UserEnvironmentsModule, UserHistoryModule, + UserRequestModule, TeamModule, TeamEnvironmentsModule, TeamCollectionModule, diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index b5c36c914..e5802059e 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -44,6 +44,36 @@ export const USER_DELETION_FAILED = 'user/deletion_failed' as const; */ export const USER_IS_OWNER = 'user/is_owner' as const; +/** + * Tried to find user collection but failed + * (UserRequestService) + */ +export const USER_COLLECTION_NOT_FOUND = 'user_collection/not_found' as const; + +/** + * Tried to reorder user request but failed + * (UserRequestService) + */ +export const USER_REQUEST_CREATION_FAILED = 'user_request/creation_failed' as const; + +/** + * Tried to do an action on a user request but user request is not matched with user collection + * (UserRequestService) + */ +export const USER_REQUEST_INVALID_TYPE = 'user_request/type_mismatch' as const; + +/** + * Tried to do an action on a user request where user request is not found + * (UserRequestService) + */ +export const USER_REQUEST_NOT_FOUND = 'user_request/not_found' as const; + +/** + * Tried to reorder user request but failed + * (UserRequestService) + */ +export const USER_REQUEST_REORDERING_FAILED = 'user_request/reordering_failed' as const; + /** * Tried to perform action on a team which they are not a member of * (GqlTeamMemberGuard) diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index b5e7bbde4..44a38467a 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -1,3 +1,4 @@ +import { UserRequest } from 'src/user-request/user-request.model'; import { User } from 'src/user/user.model'; import { UserSettings } from 'src/user-settings/user-settings.model'; import { UserEnvironment } from '../user-environment/user-environments.model'; @@ -20,6 +21,13 @@ export type TopicDef = { topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}` ]: UserEnvironment; [topic: `user_environment/${string}/deleted_many`]: number; + [ + topic: `user_request/${string}/${ + | 'created' + | 'updated' + | 'deleted' + | 'moved'}` + ]: UserRequest; [ topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}` ]: UserHistory; diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts index d30dd2c28..aadc17028 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts @@ -8,5 +8,6 @@ import { PubSubModule } from 'src/pubsub/pubsub.module'; @Module({ imports: [PrismaModule, UserModule, PubSubModule], providers: [UserCollectionService, UserCollectionResolver], + exports: [UserCollectionService], }) export class UserCollectionModule {} diff --git a/packages/hoppscotch-backend/src/user-request/input-type.args.ts b/packages/hoppscotch-backend/src/user-request/input-type.args.ts new file mode 100644 index 000000000..3b428f946 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/input-type.args.ts @@ -0,0 +1,76 @@ +import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { ReqType } from 'src/user-history/user-history.model'; + +@ArgsType() +export class GetUserRequestArgs extends PaginationArgs { + @Field(() => ID, { + nullable: true, + defaultValue: undefined, + description: 'Collection ID of the user request', + }) + collectionID?: string; + + @Field({ + nullable: true, + defaultValue: undefined, + description: 'Title of the user request', + }) + title?: string; +} + +@ArgsType() +export class MoveUserRequestArgs { + @Field(() => ID, { + description: 'ID of the collection, where the request is belongs to', + }) + sourceCollectionID: string; + + @Field(() => ID, { + description: 'ID of the request being moved', + }) + requestID: string; + + @Field(() => ID, { + description: 'ID of the collection, where the request is moving to', + }) + destinationCollectionID: string; + + @Field(() => ID, { + nullable: true, + description: + 'ID of the request that comes after the updated request in its new position', + }) + nextRequestID: string; +} + +@ArgsType() +export class CreateUserRequestArgs { + @Field({ nullable: false, description: 'Collection ID of the user request' }) + collectionID: string; + + @Field({ nullable: false, description: 'Title of the user request' }) + title: string; + + @Field({ nullable: false, description: 'content/body of the user request' }) + request: string; + + type: ReqType; +} + +@ArgsType() +export class UpdateUserRequestArgs { + @Field({ + nullable: true, + defaultValue: undefined, + description: 'Title of the user request', + }) + title: string; + + @Field({ + nullable: true, + defaultValue: undefined, + description: 'content/body of the user request', + }) + request: string; +} diff --git a/packages/hoppscotch-backend/src/user-request/resolvers/user-collection.resolver.ts b/packages/hoppscotch-backend/src/user-request/resolvers/user-collection.resolver.ts new file mode 100644 index 000000000..44699ef89 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/resolvers/user-collection.resolver.ts @@ -0,0 +1,32 @@ +import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { UserRequestService } from '../user-request.service'; +import { UserRequest } from '../user-request.model'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserCollection } from 'src/user-collection/user-collections.model'; +import { PaginationArgs } from 'src/types/input-types.args'; + +@Resolver(() => UserCollection) +export class UserRequestUserCollectionResolver { + constructor(private readonly userRequestService: UserRequestService) {} + + @ResolveField(() => [UserRequest], { + description: 'Returns user requests of a user collection', + }) + async requests( + @Parent() user: AuthUser, + @Parent() collection: UserCollection, + @Args() args: PaginationArgs, + ) { + const requests = await this.userRequestService.fetchUserRequests( + collection.id, + collection.type, + args.cursor, + args.take, + user, + ); + if (E.isLeft(requests)) throwErr(requests.left); + return requests.right; + } +} diff --git a/packages/hoppscotch-backend/src/user-request/resolvers/user-request.resolver.ts b/packages/hoppscotch-backend/src/user-request/resolvers/user-request.resolver.ts new file mode 100644 index 000000000..1855d4d3a --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/resolvers/user-request.resolver.ts @@ -0,0 +1,266 @@ +import { UseGuards } from '@nestjs/common'; +import { + Args, + Context, + ID, + Mutation, + Query, + ResolveField, + Resolver, + Subscription, +} from '@nestjs/graphql'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { UserRequest } from '../user-request.model'; +import { UserRequestService } from '../user-request.service'; +import { + GetUserRequestArgs, + CreateUserRequestArgs, + UpdateUserRequestArgs, + MoveUserRequestArgs, +} from '../input-type.args'; +import { AuthUser } from 'src/types/AuthUser'; +import { User } from 'src/user/user.model'; +import { ReqType } from 'src/user-history/user-history.model'; + +@Resolver(() => UserRequest) +export class UserRequestResolver { + constructor( + private readonly userRequestService: UserRequestService, + private readonly pubSub: PubSubService, + ) {} + + @ResolveField(() => User, { + description: 'Returns the user of the user request', + }) + async user(@GqlUser() user: AuthUser) { + return user; + } + + /* Queries */ + + @Query(() => [UserRequest], { + description: 'Get REST user requests', + }) + @UseGuards(GqlAuthGuard) + async userRESTRequests( + @GqlUser() user: AuthUser, + @Args() args: GetUserRequestArgs, + ): Promise { + const requests = await this.userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + if (E.isLeft(requests)) throwErr(requests.left); + return requests.right; + } + + @Query(() => [UserRequest], { + description: 'Get GraphQL user requests', + }) + @UseGuards(GqlAuthGuard) + async userGQLRequests( + @GqlUser() user: AuthUser, + @Args() args: GetUserRequestArgs, + ): Promise { + const requests = await this.userRequestService.fetchUserRequests( + args.collectionID, + ReqType.GQL, + args.cursor, + args.take, + user, + ); + if (E.isLeft(requests)) throwErr(requests.left); + return requests.right; + } + + @Query(() => UserRequest, { + description: 'Get a user request by ID', + }) + @UseGuards(GqlAuthGuard) + async userRequest( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + type: () => ID, + description: 'ID of the user request', + }) + id: string, + ): Promise { + const request = await this.userRequestService.fetchUserRequest(id, user); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + /* Mutations */ + + @Mutation(() => UserRequest, { + description: 'Create a new user REST request', + }) + @UseGuards(GqlAuthGuard) + async createRESTUserRequest( + @GqlUser() user: AuthUser, + @Args() args: CreateUserRequestArgs, + ) { + const request = await this.userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + ReqType.REST, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => UserRequest, { + description: 'Create a new user GraphQL request', + }) + @UseGuards(GqlAuthGuard) + async createGQLUserRequest( + @GqlUser() user: AuthUser, + @Args() args: CreateUserRequestArgs, + ) { + const request = await this.userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + ReqType.GQL, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => UserRequest, { + description: 'Update a user REST request', + }) + @UseGuards(GqlAuthGuard) + async updateRESTUserRequest( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + description: 'ID of the user REST request', + type: () => ID, + }) + id: string, + @Args() args: UpdateUserRequestArgs, + ) { + const request = await this.userRequestService.updateRequest( + id, + args.title, + ReqType.REST, + args.request, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => UserRequest, { + description: 'Update a user GraphQL request', + }) + @UseGuards(GqlAuthGuard) + async updateGQLUserRequest( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + description: 'ID of the user GraphQL request', + type: () => ID, + }) + id: string, + @Args() args: UpdateUserRequestArgs, + ) { + const request = await this.userRequestService.updateRequest( + id, + args.title, + ReqType.GQL, + args.request, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a user request', + }) + @UseGuards(GqlAuthGuard) + async deleteUserRequest( + @GqlUser() user: AuthUser, + @Args({ + name: 'id', + description: 'ID of the user request', + type: () => ID, + }) + id: string, + ): Promise { + const isDeleted = await this.userRequestService.deleteRequest(id, user); + if (E.isLeft(isDeleted)) throwErr(isDeleted.left); + return isDeleted.right; + } + + @Mutation(() => UserRequest, { + description: + 'Move and re-order of a user request within same or across collection', + }) + @UseGuards(GqlAuthGuard) + async moveUserRequest( + @GqlUser() user: AuthUser, + @Args() args: MoveUserRequestArgs, + ): Promise { + const request = await this.userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + if (E.isLeft(request)) throwErr(request.left); + return request.right; + } + + /* Subscriptions */ + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Creation', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userRequestCreated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/created`); + } + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Update', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userRequestUpdated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/updated`); + } + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Deletion', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userRequestDeleted(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/deleted`); + } + + @Subscription(() => UserRequest, { + description: 'Listen for User Request Moved', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userRequestMoved(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_request/${user.uid}/moved`); + } +} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.model.ts b/packages/hoppscotch-backend/src/user-request/user-request.model.ts new file mode 100644 index 000000000..061d77bfe --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.model.ts @@ -0,0 +1,35 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; +import { ReqType } from 'src/user-history/user-history.model'; + +@ObjectType() +export class UserRequest { + @Field(() => ID, { + description: 'ID of the user request', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the parent collection ID', + }) + collectionID: string; + + @Field({ + description: 'Title of the user request', + }) + title: string; + + @Field({ + description: 'Content/Body of the user request', + }) + request: string; + + @Field(() => ReqType, { + description: 'Type (GRAPHQL/REST) of the user request', + }) + type: ReqType; + + @Field(() => Date, { + description: 'Date of the user request creation', + }) + createdOn: Date; +} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.module.ts b/packages/hoppscotch-backend/src/user-request/user-request.module.ts new file mode 100644 index 000000000..e91eacdd8 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PubSubModule } from '../pubsub/pubsub.module'; +import { UserRequestUserCollectionResolver } from './resolvers/user-collection.resolver'; +import { UserRequestResolver } from './resolvers/user-request.resolver'; +import { UserRequestService } from './user-request.service'; + +@Module({ + imports: [PrismaModule, PubSubModule, UserCollectionModule], + providers: [ + UserRequestResolver, + UserRequestUserCollectionResolver, + UserRequestService, + ], +}) +export class UserRequestModule {} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts new file mode 100644 index 000000000..23528b8b4 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts @@ -0,0 +1,848 @@ +import { + ReqType as DbRequestType, + UserRequest as DbUserRequest, +} from '@prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + JSON_INVALID, + USER_REQUEST_NOT_FOUND, + USER_REQUEST_REORDERING_FAILED, +} from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { GetUserRequestArgs } from './input-type.args'; +import { MoveUserRequestArgs } from './input-type.args'; +import { + CreateUserRequestArgs, + UpdateUserRequestArgs, +} from './input-type.args'; +import { UserRequest } from './user-request.model'; +import { UserRequestService } from './user-request.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { ReqType } from 'src/user-history/user-history.model'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); +const mockUserCollectionService = mockDeep(); + +// @ts-ignore +const userRequestService = new UserRequestService( + mockPrisma, + mockPubSub as any, + mockUserCollectionService, +); + +const user: AuthUser = { + uid: 'user-uid', + email: 'test@gmail.com', + displayName: 'Test User', + photoURL: 'https://example.com/photo.png', + isAdmin: false, + refreshToken: null, + createdOn: new Date(), + currentGQLSession: null, + currentRESTSession: null, +}; +const dbUserRequests: DbUserRequest[] = [ + { + id: 'user-request-id-11', + collectionID: 'collection-id-1', + orderIndex: 1, + userUid: user.uid, + title: 'Request 1', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-12', + collectionID: 'collection-id-1', + orderIndex: 2, + userUid: user.uid, + title: 'Request 2', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-13', + collectionID: 'collection-id-1', + orderIndex: 3, + userUid: user.uid, + title: 'Request 3', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-14', + collectionID: 'collection-id-1', + orderIndex: 4, + userUid: user.uid, + title: 'Request 4', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-21', + collectionID: 'collection-id-2', + orderIndex: 1, + userUid: user.uid, + title: 'Request 1', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-22', + collectionID: 'collection-id-2', + orderIndex: 2, + userUid: user.uid, + title: 'Request 2', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-23', + collectionID: 'collection-id-2', + orderIndex: 3, + userUid: user.uid, + title: 'Request 3', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, + { + id: 'user-request-id-24', + collectionID: 'collection-id-2', + orderIndex: 4, + userUid: user.uid, + title: 'Request 4', + request: {}, + type: DbRequestType.REST, + createdOn: new Date(), + updatedOn: new Date(), + }, +]; +const userRequests: UserRequest[] = dbUserRequests.map((r) => { + return { + ...r, + request: JSON.stringify(r.request), + type: ReqType[r.type], + }; +}); + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); + mockUserCollectionService.getUserCollection.mockClear(); +}); + +describe('UserRequestService', () => { + describe('fetchUserRequests', () => { + test('Should resolve right and fetch user requests (with collection ID)', () => { + const args: GetUserRequestArgs = { + collectionID: 'collection-id-1', + cursor: undefined, + take: undefined, + }; + const expectedDbUserRequests = dbUserRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + const expectedUserRequests = userRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + + mockPrisma.userRequest.findMany.mockResolvedValue(expectedDbUserRequests); + const result = userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequests); + }); + + test('Should resolve right and fetch user requests (with collection ID and take)', () => { + const args: GetUserRequestArgs = { + collectionID: 'collection-id-1', + cursor: undefined, + take: 2, + }; + const expectedDbUserRequests = dbUserRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + const expectedUserRequests = userRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + + mockPrisma.userRequest.findMany.mockResolvedValue(expectedDbUserRequests); + const result = userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequests); + }); + test('Should resolve right and fetch user requests (with all params)', () => { + const args: GetUserRequestArgs = { + collectionID: 'collection-id-1', + cursor: 'user-request-id-12', + take: 2, + }; + const expectedDbUserRequests = dbUserRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + const expectedUserRequests = userRequests.filter( + (r) => r.collectionID === args.collectionID, + ); + + mockPrisma.userRequest.findMany.mockResolvedValue(expectedDbUserRequests); + const result = userRequestService.fetchUserRequests( + args.collectionID, + ReqType.REST, + args.cursor, + args.take, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequests); + }); + }); + + describe('fetchUserRequest', () => { + test('Should resolve right and fetch user request', () => { + const expectedDbUserRequest = dbUserRequests[0]; + const expectedUserRequest = userRequests[0]; + + mockPrisma.userRequest.findUnique.mockResolvedValue( + expectedDbUserRequest, + ); + const result = userRequestService.fetchUserRequest( + expectedUserRequest.id, + user, + ); + + expect(result).resolves.toEqualRight(expectedUserRequest); + }); + + test('Should resolve left if user request not exist', () => { + mockPrisma.userRequest.findUnique.mockResolvedValue(null); + const result = userRequestService.fetchUserRequest( + userRequests[0].id, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + + test('Should resolve left if another users user-request asked', () => { + mockPrisma.userRequest.findUnique.mockResolvedValue({ + ...dbUserRequests[0], + userUid: 'another-user', + }); + const result = userRequestService.fetchUserRequest( + userRequests[0].id, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + }); + + describe('createRequest', () => { + test('Should resolve right and create user request', () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: userRequests[0].request, + type: userRequests[0].type, + }; + + mockPrisma.userRequest.count.mockResolvedValue( + dbUserRequests[0].orderIndex - 1, + ); + mockUserCollectionService.getUserCollection.mockResolvedValue( + E.right({ type: userRequests[0].type, userUid: user.uid } as any), + ); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(result).resolves.toEqualRight(userRequests[0]); + }); + test('Should execute prisma.create() with correct params', async () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: userRequests[0].request, + type: userRequests[0].type, + }; + + mockPrisma.userRequest.count.mockResolvedValue( + dbUserRequests[0].orderIndex - 1, + ); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(mockPrisma.userRequest.create).toHaveBeenCalledWith({ + data: { + ...args, + request: JSON.parse(args.request), + type: DbRequestType[args.type], + orderIndex: dbUserRequests[0].orderIndex, + userUid: user.uid, + }, + }); + }); + test('Should publish user request created message in pubnub', async () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: userRequests[0].request, + type: userRequests[0].type, + }; + + mockPrisma.userRequest.count.mockResolvedValue( + dbUserRequests[0].orderIndex - 1, + ); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/created`, + userRequests[0], + ); + }); + test('Should resolve left for json-invalid request', () => { + const args: CreateUserRequestArgs = { + collectionID: userRequests[0].collectionID, + title: userRequests[0].title, + request: 'invalid json', + type: userRequests[0].type, + }; + + mockPrisma.userRequest.count.mockResolvedValue( + dbUserRequests[0].orderIndex - 1, + ); + mockPrisma.userRequest.create.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.createRequest( + args.collectionID, + args.title, + args.request, + args.type, + user, + ); + + expect(result).resolves.toEqualLeft(JSON_INVALID); + }); + }); + + describe('updateRequest', () => { + test('Should resolve right and update user request', () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + mockPrisma.userRequest.update.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(result).resolves.toEqualRight(userRequests[0]); + }); + test('Should resolve right and perform prisma.update with correct param', async () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + mockPrisma.userRequest.update.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(mockPrisma.userRequest.update).toHaveBeenCalledWith({ + where: { id }, + data: { + ...args, + request: JSON.parse(args.request), + }, + }); + }); + test('Should resolve right and publish to pubnub with correct param', async () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + mockPrisma.userRequest.update.mockResolvedValue(dbUserRequests[0]); + + await userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/updated`, + userRequests[0], + ); + }); + test('Should resolve left if user request not found', () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + title: userRequests[0].title, + request: userRequests[0].request, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValue(null); + + const result = userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + test('Should resolve left if stringToJson returns error', () => { + const id = userRequests[0].id; + const type = userRequests[0].type; + const args: UpdateUserRequestArgs = { + title: userRequests[0].title, + request: 'invalid json', + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(dbUserRequests[0]); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce({} as any); + + const result = userRequestService.updateRequest( + id, + args.title, + type, + args.request, + user, + ); + + expect(result).resolves.toEqualLeft(JSON_INVALID); + }); + }); + + describe('deleteRequest', () => { + test('Should resolve right and delete user request', () => { + const id = userRequests[0].id; + + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.delete.mockResolvedValue(dbUserRequests[0]); + + const result = userRequestService.deleteRequest(id, user); + + expect(result).resolves.toEqualRight(true); + }); + test('Should resolve right and perform prisma.delete with correct param', async () => { + const id = userRequests[0].id; + + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.delete.mockResolvedValue(null); + + await userRequestService.deleteRequest(id, user); + + expect(mockPrisma.userRequest.delete).toHaveBeenCalledWith({ + where: { id }, + }); + }); + test('Should resolve right and perform prisma.updateMany with correct param', async () => { + const id = userRequests[0].id; + + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.delete.mockResolvedValue(null); + + await userRequestService.deleteRequest(id, user); + + expect(mockPrisma.userRequest.updateMany).toHaveBeenCalledWith({ + where: { + collectionID: dbUserRequests[0].collectionID, + orderIndex: { gt: dbUserRequests[0].orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + }); + test('Should resolve and publish message to pubnub', async () => { + const id = userRequests[0].id; + + mockPrisma.userRequest.findFirst.mockResolvedValue(dbUserRequests[0]); + mockPrisma.userRequest.delete.mockResolvedValue(null); + + const result = await userRequestService.deleteRequest(id, user); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/deleted`, + userRequests[0], + ); + }); + test('Should resolve error if the user request is not found', () => { + const id = userRequests[0].id; + mockPrisma.userRequest.findFirst.mockResolvedValue(null); + + const result = userRequestService.deleteRequest(id, user); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + expect(mockPrisma.userRequest.findFirst).toHaveBeenCalledWith({ + where: { id, userUid: dbUserRequests[0].userUid }, + }); + expect(mockPrisma.userRequest.updateMany).not.toHaveBeenCalled(); + expect(mockPrisma.userRequest.delete).not.toHaveBeenCalled(); + expect(mockPubSub.publish).not.toHaveBeenCalled(); + }); + }); + + describe('reorderRequests', () => { + test('Should resolve left if transaction throws an error', async () => { + const srcCollID = dbUserRequests[0].collectionID; + const request = dbUserRequests[0]; + const destCollID = dbUserRequests[4].collectionID; + const nextRequest = dbUserRequests[4]; + + mockPrisma.$transaction.mockRejectedValueOnce(new Error()); + const result = await userRequestService.reorderRequests( + srcCollID, + request, + destCollID, + nextRequest, + ); + expect(result).toEqual(E.left(USER_REQUEST_REORDERING_FAILED)); + }); + test('Should resolve right and call transaction with the correct data', async () => { + const srcCollID = dbUserRequests[0].collectionID; + const request = dbUserRequests[0]; + const destCollID = dbUserRequests[4].collectionID; + const nextRequest = dbUserRequests[4]; + + const updatedReq: DbUserRequest = { + ...request, + collectionID: destCollID, + orderIndex: nextRequest.orderIndex, + }; + + mockPrisma.$transaction.mockResolvedValueOnce(E.right(updatedReq)); + const result = await userRequestService.reorderRequests( + srcCollID, + request, + destCollID, + nextRequest, + ); + expect(mockPrisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + ); + expect(result).toEqual(E.right(updatedReq)); + }); + }); + + describe('findRequestAndNextRequest', () => { + test('Should resolve right if the request and the next request are found', async () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[4].collectionID, + requestID: userRequests[0].id, + nextRequestID: userRequests[4].id, + }; + + mockPrisma.userRequest.findFirst + .mockResolvedValueOnce(dbUserRequests[0]) + .mockResolvedValueOnce(dbUserRequests[4]); + + const result = await userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).toEqualRight({ + request: dbUserRequests[0], + nextRequest: dbUserRequests[4], + }); + }); + test('Should resolve right if the request and next request null', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[1].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.userRequest.findFirst + .mockResolvedValueOnce(dbUserRequests[0]) + .mockResolvedValueOnce(null); + + const result = userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualRight({ + request: dbUserRequests[0], + nextRequest: null, + }); + }); + test('Should resolve left if the request is not found', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[1].collectionID, + requestID: 'invalid', + nextRequestID: null, + }; + + mockPrisma.userRequest.findFirst.mockResolvedValueOnce(null); + + const result = userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + test('Should resolve left if the nextRequest is not found', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[1].collectionID, + requestID: userRequests[0].id, + nextRequestID: 'invalid', + }; + + mockPrisma.userRequest.findFirst + .mockResolvedValueOnce(dbUserRequests[0]) + .mockResolvedValueOnce(null); + + const result = userRequestService.findRequestAndNextRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + }); + + describe('moveRequest', () => { + test('Should resolve right and the request', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbUserRequests[0], nextRequest: null }), + ); + jest + .spyOn(userRequestService, 'reorderRequests') + .mockResolvedValue(E.right(dbUserRequests[0])); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + const result = userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualRight(userRequests[0]); + }); + test('Should resolve right and publish message to pubnub', async () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ request: dbUserRequests[0], nextRequest: null }), + ); + jest + .spyOn(userRequestService, 'reorderRequests') + .mockResolvedValue(E.right(dbUserRequests[0])); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + await userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_request/${dbUserRequests[0].userUid}/moved`, + userRequests[0], + ); + }); + test('Should resolve left if finding the requests fails', () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue(E.left(USER_REQUEST_NOT_FOUND)); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + const result = userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).resolves.toEqualLeft(USER_REQUEST_NOT_FOUND); + }); + test('Should resolve left if reordering the request fails', async () => { + const args: MoveUserRequestArgs = { + sourceCollectionID: userRequests[0].collectionID, + destinationCollectionID: userRequests[0].collectionID, + requestID: userRequests[0].id, + nextRequestID: null, + }; + + jest + .spyOn(userRequestService, 'findRequestAndNextRequest') + .mockResolvedValue( + E.right({ + request: dbUserRequests[0], + nextRequest: null, + }), + ); + jest + .spyOn(userRequestService, 'reorderRequests') + .mockResolvedValue(E.left(USER_REQUEST_REORDERING_FAILED)); + jest + .spyOn(userRequestService, 'validateTypeEqualityForMoveRequest') + .mockResolvedValue(E.right(true)); + + const result = await userRequestService.moveRequest( + args.sourceCollectionID, + args.destinationCollectionID, + args.requestID, + args.nextRequestID, + user, + ); + + expect(result).toEqualLeft(USER_REQUEST_REORDERING_FAILED); + }); + }); + + describe('validateTypeEqualityForMoveRequest', () => { + test('Should resolve right if the types are equal', () => { + const srcCollID = 'srcCollID'; + const destCollID = 'destCollID'; + + mockUserCollectionService.getUserCollection.mockResolvedValueOnce( + E.right({ type: userRequests[0].type, userUid: user.uid } as any), + ); + mockUserCollectionService.getUserCollection.mockResolvedValueOnce( + E.right({ type: userRequests[1].type, userUid: user.uid } as any), + ); + + const result = userRequestService.validateTypeEqualityForMoveRequest( + srcCollID, + destCollID, + userRequests[0], + userRequests[1], + ); + + expect(result).resolves.toEqualRight(true); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.ts new file mode 100644 index 000000000..7414043eb --- /dev/null +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.ts @@ -0,0 +1,448 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; +import { UserRequest } from './user-request.model'; +import { UserRequest as DbUserRequest } from '@prisma/client'; +import { + USER_COLLECTION_NOT_FOUND, + USER_REQUEST_CREATION_FAILED, + USER_REQUEST_INVALID_TYPE, + USER_REQUEST_NOT_FOUND, + USER_REQUEST_REORDERING_FAILED, +} from 'src/errors'; +import { stringToJson } from 'src/utils'; +import { AuthUser } from 'src/types/AuthUser'; +import { ReqType } from 'src/user-history/user-history.model'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; + +@Injectable() +export class UserRequestService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + private readonly userCollectionService: UserCollectionService, + ) {} + + /** + * Typecast a database user request to a user request + * @param dbRequest Database user request + * @returns User request + */ + private cast(dbRequest: DbUserRequest): UserRequest { + return { + ...dbRequest, + type: ReqType[dbRequest.type], + request: JSON.stringify(dbRequest.request), + }; + } + + /** + * Get paginated user requests + * @param collectionID ID of the collection to which the request belongs + * @param take Number of requests to fetch + * @param cursor ID of the request after which to fetch requests + * @param user User who owns the requests + * @returns Either of an Array of user requests + */ + async fetchUserRequests( + collectionID: string, + type: ReqType, + cursor: string, + take: number, + user: AuthUser, + ) { + const dbRequests = await this.prisma.userRequest.findMany({ + where: { + userUid: user.uid, + collectionID: collectionID, + type, + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + orderBy: { orderIndex: 'asc' }, + }); + + const userRequests: UserRequest[] = dbRequests.map((r) => this.cast(r)); + + return E.right(userRequests); + } + + /** + * Get a user request by ID + * @param id ID of the request to fetch + * @param user User who owns the request + * @returns Either of the user request + */ + async fetchUserRequest( + id: string, + user: AuthUser, + ): Promise | E.Right> { + const dbRequest = await this.prisma.userRequest.findUnique({ + where: { id }, + }); + if (!dbRequest || dbRequest.userUid !== user.uid) { + return E.left(USER_REQUEST_NOT_FOUND); + } + + return E.right(this.cast(dbRequest)); + } + + /** + * Get the number of requests in a collection + * @param collectionID ID of the collection to which the request belongs + * @param user User who owns the collection + * @returns Number of requests in the collection + */ + getRequestsCountInCollection(collectionID: string): Promise { + return this.prisma.userRequest.count({ + where: { collectionID }, + }); + } + + /** + * Create a user request + * @param collectionID ID of the collection to which the request belongs + * @param title title of the request + * @param request request to create + * @param type type of the request + * @param user User who owns the request + * @returns Either of the created user request + */ + async createRequest( + collectionID: string, + title: string, + request: string, + type: ReqType, + user: AuthUser, + ): Promise | E.Right> { + const jsonRequest = stringToJson(request); + if (E.isLeft(jsonRequest)) return E.left(jsonRequest.left); + + const collection = await this.userCollectionService.getUserCollection( + collectionID, + ); + if (E.isLeft(collection)) return E.left(collection.left); + + if (collection.right.userUid !== user.uid) + return E.left(USER_COLLECTION_NOT_FOUND); + + if (collection.right.type !== ReqType[type]) + return E.left(USER_REQUEST_INVALID_TYPE); + + try { + const requestCount = await this.getRequestsCountInCollection( + collectionID, + ); + + const request = await this.prisma.userRequest.create({ + data: { + collectionID, + title, + request: jsonRequest.right, + type: ReqType[type], + orderIndex: requestCount + 1, + userUid: user.uid, + }, + }); + + const userRequest = this.cast(request); + + await this.pubsub.publish( + `user_request/${user.uid}/created`, + userRequest, + ); + + return E.right(userRequest); + } catch (err) { + return E.left(USER_REQUEST_CREATION_FAILED); + } + } + + /** + * Update a user request + * @param id ID of the request to update + * @param title title of the request to update + * @param type type of the request to update + * @param request request to update + * @param user User who owns the request + */ + async updateRequest( + id: string, + title: string, + type: ReqType, + request: string, + user: AuthUser, + ): Promise | E.Right> { + const existRequest = await this.prisma.userRequest.findFirst({ + where: { id, userUid: user.uid }, + }); + if (!existRequest) return E.left(USER_REQUEST_NOT_FOUND); + + if (existRequest.type !== ReqType[type]) + return E.left(USER_REQUEST_INVALID_TYPE); + + let jsonRequest = undefined; + if (request) { + const jsonRequestE = stringToJson(request); + if (E.isLeft(jsonRequestE)) return E.left(jsonRequestE.left); + jsonRequest = jsonRequestE.right; + } + + const updatedRequest = await this.prisma.userRequest.update({ + where: { id }, + data: { + title, + request: jsonRequest, + }, + }); + + const userRequest: UserRequest = this.cast(updatedRequest); + + await this.pubsub.publish(`user_request/${user.uid}/updated`, userRequest); + + return E.right(userRequest); + } + + /** + * Delete a user request + * @param id ID of the request to delete + * @param user User who owns the request + * @returns Either of a boolean + */ + async deleteRequest( + id: string, + user: AuthUser, + ): Promise | E.Right> { + const request = await this.prisma.userRequest.findFirst({ + where: { id, userUid: user.uid }, + }); + if (!request) return E.left(USER_REQUEST_NOT_FOUND); + + await this.prisma.userRequest.updateMany({ + where: { + collectionID: request.collectionID, + orderIndex: { gt: request.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + await this.prisma.userRequest.delete({ where: { id } }); + + await this.pubsub.publish( + `user_request/${user.uid}/deleted`, + this.cast(request), + ); + + return E.right(true); + } + + /** + * Move a request for re-ordering inside/across collections + * @param srcCollID ID of the source collection + * @param destCollID ID of the destination collection + * @param requestID ID of the request to move + * @param nextRequestID ID of the request after which the request should be moved + * @param user User who owns the request + * @returns Either of the updated request + */ + async moveRequest( + srcCollID: string, + destCollID: string, + requestID: string, + nextRequestID: string, + user: AuthUser, + ): Promise | E.Right> { + const twoRequests = await this.findRequestAndNextRequest( + srcCollID, + destCollID, + requestID, + nextRequestID, + user, + ); + if (E.isLeft(twoRequests)) return twoRequests; + const { request, nextRequest } = twoRequests.right; + + const isTypeValidate = await this.validateTypeEqualityForMoveRequest( + srcCollID, + destCollID, + request, + nextRequest, + ); + if (E.isLeft(isTypeValidate)) return E.left(isTypeValidate.left); + + const updatedRequest = await this.reorderRequests( + srcCollID, + request, + destCollID, + nextRequest, + ); + if (E.isLeft(updatedRequest)) return updatedRequest; + + const userRequest: UserRequest = this.cast(updatedRequest.right); + + await this.pubsub.publish(`user_request/${user.uid}/moved`, userRequest); + + return E.right(userRequest); + } + + /** + * This function validate/ensure the same type (REST/GQL) in the source and destination collection and the request + * @param srcCollID ID of the source collection + * @param destCollID ID of the destination collection + * @param request Request to move + * @param nextRequest Request after which the request should be moved + * @returns Either of a boolean + */ + async validateTypeEqualityForMoveRequest( + srcCollID, + destCollID, + request, + nextRequest, + ) { + const collections = await Promise.all([ + this.userCollectionService.getUserCollection(srcCollID), + this.userCollectionService.getUserCollection(destCollID), + ]); + + const srcColl = collections[0]; + if (E.isLeft(srcColl)) return E.left(srcColl.left); + + const destColl = collections[1]; + if (E.isLeft(destColl)) return E.left(destColl.left); + + if ( + srcColl.right.type !== destColl.right.type || + (nextRequest && request.type !== nextRequest.type) + ) { + return E.left(USER_REQUEST_INVALID_TYPE); + } + + return E.right(true); + } + + /** + * A helper function. + * Find the request and the next request(destination collection) + * @param srcCollID Source collection ID + * @param destCollID Destination collection ID + * @param requestID Request ID + * @param nextRequestID Next request ID + * @param user User who owns the collection + * @returns Either Left with error message or Right with the request and the next request + */ + async findRequestAndNextRequest( + srcCollID: string, + destCollID: string, + requestID: string, + nextRequestID: string, + user: AuthUser, + ): Promise< + | E.Left + | E.Right<{ + request: DbUserRequest; + nextRequest: DbUserRequest; + }> + > { + const request = await this.prisma.userRequest.findFirst({ + where: { id: requestID, collectionID: srcCollID, userUid: user.uid }, + }); + if (!request) return E.left(USER_REQUEST_NOT_FOUND); + + let nextRequest: DbUserRequest = null; + if (nextRequestID) { + nextRequest = await this.prisma.userRequest.findFirst({ + where: { + id: nextRequestID, + collectionID: destCollID, + userUid: user.uid, + }, + }); + if (!nextRequest) return E.left(USER_REQUEST_NOT_FOUND); + } + + return E.right({ request, nextRequest }); + } + + /** + * Update order indexes of requests in collection + * @param srcCollID - id of collection, where the request is moving from + * @param request - request to be moved + * @param destCollID - id of collection, where the request is moving to + * @param nextRequest - request that comes after the updated request in its new position + * @returns Promise of an Either of `DbUserRequest` object or error message + */ + async reorderRequests( + srcCollID: string, + request: DbUserRequest, + destCollID: string, + nextRequest: DbUserRequest, + ): Promise | E.Right> { + try { + return await this.prisma.$transaction< + E.Left | E.Right + >(async (tx) => { + const isSameCollection = srcCollID === destCollID; + const isMovingUp = nextRequest?.orderIndex < request.orderIndex; // false, if nextRequest is null + + const nextReqOrderIndex = nextRequest?.orderIndex; + const reqCountInDestColl = nextRequest + ? undefined + : await this.getRequestsCountInCollection(destCollID); + + // Updating order indexes of other requests in collection(s) + if (isSameCollection) { + const updateFrom = isMovingUp + ? nextReqOrderIndex + : request.orderIndex + 1; + const updateTo = isMovingUp ? request.orderIndex : nextReqOrderIndex; + + await tx.userRequest.updateMany({ + where: { + collectionID: srcCollID, + orderIndex: { gte: updateFrom, lt: updateTo }, + }, + data: { + orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 }, + }, + }); + } else { + await tx.userRequest.updateMany({ + where: { + collectionID: srcCollID, + orderIndex: { gte: request.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); + + if (nextRequest) { + await tx.userRequest.updateMany({ + where: { + collectionID: destCollID, + orderIndex: { gte: nextReqOrderIndex }, + }, + data: { orderIndex: { increment: 1 } }, + }); + } + } + + // Updating order index of the request + let adjust: number; + if (isSameCollection) adjust = nextRequest ? (isMovingUp ? 0 : -1) : 0; + else adjust = nextRequest ? 0 : 1; + + const newOrderIndex = + (nextReqOrderIndex ?? reqCountInDestColl) + adjust; + + const updatedRequest = await tx.userRequest.update({ + where: { id: request.id }, + data: { orderIndex: newOrderIndex, collectionID: destCollID }, + }); + + return E.right(updatedRequest); + }); + } catch (err) { + return E.left(USER_REQUEST_REORDERING_FAILED); + } + } +}