From a938be3712f085399319a97affa76a8a41c94e72 Mon Sep 17 00:00:00 2001 From: Balu Babu Date: Fri, 3 Mar 2023 15:03:05 +0530 Subject: [PATCH] feat: Introducing user-collections into self-host (HBE-98) (#18) * feat: team module added * feat: teamEnvironment module added * feat: teamCollection module added * feat: team request module added * feat: team invitation module added * feat: selfhost auth frontend (#15) Co-authored-by: Andrew Bastin * feat: bringing shortcodes from central to selfhost * chore: added review changes in resolver * chore: commented out subscriptions * chore: bump backend prettier version * feat: created new user-collections module with base files * feat: added new models for user-collection and user-request tables in schema.prisma file * feat: mutations to create user-collections complete * feat: added user field resolver for userCollections * feat: added parent field resolver for userCollections * feat: added child field resolver with pagination for userCollections * feat: added query to fetch root user-collections with pagination for userCollections * feat: added query to fetch user-collections for userCollections * feat: added mutation to rename user-collections * feat: added mutation to delete user-collections * feat: added mutation to delete user-collections * refactor: changed the way we fetch root and child user-collection counts for other operations * feat: added mutation to move user-collections between root and other child collections * refactor: abstracted orderIndex update logic into helpert function * chore: mutation to update order root user-collections complete * feat: user-collections order can be updated when moving it to the end of list * feat: user-collections order update feature complete * feat: subscriptions for user-collection module complete * chore: removed all console.logs from user-collection.service file * test: added tests for all field resolvers for user-collection module * test: test cases for getUserCollection is complete * test: test cases for getUserRootCollections is complete * test: test cases for createUserCollection is complete * test: test cases for renameCollection is complete * test: test cases for moveUserCollection is complete * test: test cases for updateUserCollectionOrder is complete * chore: added createdOn and updatedOn fields to userCollections and userRequests schema * chore: created function to check if title are of valid size * refactor: simplified user-collection creation code * chore: made changed requested in initial PR review * chore: added requestType enum to user-collections * refactor: created two seperate queries to fetch root REST or GQL queries * chore: created seperate mutations and queries for REST and GQL root/child collections * chore: migrated all input args classess into a single file * chore: modified createUserCollection service method to work with different creation inputs args type * chore: rewrote all test cases for user-collections service methods with new CollType * fix: added updated and deleted subscription changes * fix: made all the changes requested in the initial PR review * fix: made all the changes requested in the second PR review * chore: removed migrations from prisma directory * fix: made all the changes requested in the third PR review * chore: added collection type checking to updateUserCollectionOrder service method * chore: refactored all test cases to reflect new additions to service methods * chore: fixed issues with pnpm-lock * chore: removed migrations from prisma directory * chore: hopefully fixed pnpm-lock issues * chore: removed console logs in auth controller --------- Co-authored-by: Mir Arif Hasan Co-authored-by: Akash K <57758277+amk-dev@users.noreply.github.com> Co-authored-by: Andrew Bastin Co-authored-by: ankitsridhar16 --- .../hoppscotch-backend/prisma/schema.prisma | 31 + packages/hoppscotch-backend/src/app.module.ts | 2 + .../src/auth/strategies/google.strategy.ts | 9 +- packages/hoppscotch-backend/src/errors.ts | 65 + .../src/pubsub/topicsDefs.ts | 7 + .../team-collection.resolver.ts | 2 +- .../src/types/RequestTypes.ts | 4 + .../src/user-collection/input-type.args.ts | 75 + .../user-collection/user-collection.module.ts | 12 + .../user-collection.resolver.ts | 348 ++++ .../user-collection.service.spec.ts | 1415 +++++++++++++++++ .../user-collection.service.ts | 616 +++++++ .../user-collection/user-collections.model.ts | 43 + packages/hoppscotch-backend/src/utils.ts | 16 +- 14 files changed, 2642 insertions(+), 3 deletions(-) create mode 100644 packages/hoppscotch-backend/src/types/RequestTypes.ts create mode 100644 packages/hoppscotch-backend/src/user-collection/input-type.args.ts create mode 100644 packages/hoppscotch-backend/src/user-collection/user-collection.module.ts create mode 100644 packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts create mode 100644 packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/user-collection/user-collection.service.ts create mode 100644 packages/hoppscotch-backend/src/user-collection/user-collections.model.ts diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 4ab3a1e28..91ede88f0 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -90,6 +90,8 @@ model User { settings UserSettings? UserHistory UserHistory[] UserEnvironments UserEnvironment[] + userCollections UserCollection[] + userRequests UserRequest[] currentRESTSession Json? currentGQLSession Json? createdOn DateTime @default(now()) @db.Timestamp(3) @@ -152,6 +154,35 @@ model UserEnvironment { isGlobal Boolean } +model UserCollection { + id String @id @default(cuid()) + parentID String? + parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade) + children UserCollection[] @relation("ParentUserCollection") + requests UserRequest[] + userUid String + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + title String + orderIndex Int + type ReqType + createdOn DateTime @default(now()) @db.Timestamp(3) + 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 46236e1b4..42293c9c2 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -13,6 +13,7 @@ import { TeamEnvironmentsModule } from './team-environments/team-environments.mo import { TeamCollectionModule } from './team-collection/team-collection.module'; import { TeamRequestModule } from './team-request/team-request.module'; import { TeamInvitationModule } from './team-invitation/team-invitation.module'; +import { UserCollectionModule } from './user-collection/user-collection.module'; import { ShortcodeModule } from './shortcode/shortcode.module'; import { COOKIES_NOT_FOUND } from './errors'; @@ -64,6 +65,7 @@ import { COOKIES_NOT_FOUND } from './errors'; TeamCollectionModule, TeamRequestModule, TeamInvitationModule, + UserCollectionModule, ShortcodeModule, ], providers: [GQLComplexityPlugin], diff --git a/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts index 4420dd919..7dd5cc2d7 100644 --- a/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts +++ b/packages/hoppscotch-backend/src/auth/strategies/google.strategy.ts @@ -17,10 +17,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy) { clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: process.env.GOOGLE_CALLBACK_URL, scope: process.env.GOOGLE_SCOPE.split(','), + passReqToCallback: true, }); } - async validate(accessToken, refreshToken, profile, done: VerifyCallback) { + async validate( + req: Request, + accessToken, + refreshToken, + profile, + done: VerifyCallback, + ) { const user = await this.usersService.findUserByEmail( profile.emails[0].value, ); diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 0a775fdbf..b5c36c914 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -373,3 +373,68 @@ export const INVALID_ACCESS_TOKEN = 'auth/invalid_access_token' as const; * (AuthService) */ export const INVALID_REFRESH_TOKEN = 'auth/invalid_refresh_token' as const; + +/** + * The provided title for the user collection is short (less than 3 characters) + * (UserCollectionService) + */ +export const USER_COLL_SHORT_TITLE = 'user_coll/short_title' as const; + +/** + * User Collection could not be found + * (UserCollectionService) + */ +export const USER_COLL_NOT_FOUND = 'user_coll/not_found' as const; + +/** + * UserCollection is already a root collection + * (UserCollectionService) + */ +export const USER_COL_ALREADY_ROOT = + 'user_coll/target_user_collection_is_already_root_user_collection' as const; + +/** + * Target and Parent user collections are the same + * (UserCollectionService) + */ +export const USER_COLL_DEST_SAME = + 'user_coll/target_and_destination_user_collection_are_same' as const; + +/** + * Target and Parent user collections are not from the same user + * (UserCollectionService) + */ +export const USER_COLL_NOT_SAME_USER = 'user_coll/not_same_user' as const; + +/** + * Target and Parent user collections are not from the same type + * (UserCollectionService) + */ +export const USER_COLL_NOT_SAME_TYPE = 'user_coll/type_mismatch' as const; + +/** + * Cannot make a parent user collection a child of itself + * (UserCollectionService) + */ +export const USER_COLL_IS_PARENT_COLL = + 'user_coll/user_collection_is_parent_coll' as const; + +/** + * User Collection Re-Ordering Failed + * (UserCollectionService) + */ +export const USER_COLL_REORDERING_FAILED = + 'user_coll/reordering_failed' as const; + +/** + * The Collection and Next User Collection are the same + * (UserCollectionService) + */ +export const USER_COLL_SAME_NEXT_COLL = + 'user_coll/user_collection_and_next_user_collection_are_same' as const; + +/** + * The User Collection does not belong to the logged-in user + * (UserCollectionService) + */ +export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const; diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index 1a9088a6f..b5e7bbde4 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -7,6 +7,8 @@ import { TeamEnvironment } from 'src/team-environments/team-environments.model'; import { TeamCollection } from 'src/team-collection/team-collection.model'; import { TeamRequest } from 'src/team-request/team-request.model'; import { TeamInvitation } from 'src/team-invitation/team-invitation.model'; +import { UserCollection } from '@prisma/client'; +import { UserCollectionReorderData } from 'src/user-collection/user-collections.model'; import { Shortcode } from 'src/shortcode/shortcode.model'; // A custom message type that defines the topic and the corresponding payload. @@ -21,6 +23,11 @@ export type TopicDef = { [ topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}` ]: UserHistory; + [ + topic: `user_coll/${string}/${'created' | 'updated' | 'moved'}` + ]: UserCollection; + [topic: `user_coll/${string}/${'deleted'}`]: string; + [topic: `user_coll/${string}/${'order_updated'}`]: UserCollectionReorderData; [topic: `team/${string}/member_removed`]: string; [topic: `team/${string}/${'member_added' | 'member_updated'}`]: TeamMember; [ diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts index 939fb4981..6475668e3 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts @@ -36,7 +36,7 @@ export class TeamCollectionResolver { @ResolveField(() => TeamCollection, { description: - 'The collection whom is the parent of this collection (null if this is root collection)', + 'The collection who is the parent of this collection (null if this is root collection)', nullable: true, complexity: 3, }) diff --git a/packages/hoppscotch-backend/src/types/RequestTypes.ts b/packages/hoppscotch-backend/src/types/RequestTypes.ts new file mode 100644 index 000000000..717db6ac7 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/RequestTypes.ts @@ -0,0 +1,4 @@ +export enum ReqType { + REST = 'REST', + GQL = 'GQL', +} diff --git a/packages/hoppscotch-backend/src/user-collection/input-type.args.ts b/packages/hoppscotch-backend/src/user-collection/input-type.args.ts new file mode 100644 index 000000000..815340df9 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/input-type.args.ts @@ -0,0 +1,75 @@ +import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { PaginationArgs } from 'src/types/input-types.args'; + +@ArgsType() +export class CreateRootUserCollectionArgs { + @Field({ name: 'title', description: 'Title of the new user collection' }) + title: string; +} +@ArgsType() +export class CreateChildUserCollectionArgs { + @Field({ name: 'title', description: 'Title of the new user collection' }) + title: string; + + @Field(() => ID, { + name: 'parentUserCollectionID', + description: 'ID of the parent to the new user collection', + }) + parentUserCollectionID: string; +} + +@ArgsType() +export class GetUserChildCollectionArgs extends PaginationArgs { + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the parent to the user collection', + }) + userCollectionID: string; +} + +@ArgsType() +export class RenameUserCollectionsArgs { + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the user collection', + }) + userCollectionID: string; + + @Field({ + name: 'newTitle', + description: 'The updated title of the user collection', + }) + newTitle: string; +} + +@ArgsType() +export class UpdateUserCollectionArgs { + @Field(() => ID, { + name: 'collectionID', + description: 'ID of collection being moved', + }) + collectionID: string; + + @Field(() => ID, { + name: 'nextCollectionID', + nullable: true, + description: 'ID of collection being moved', + }) + nextCollectionID: string; +} + +@ArgsType() +export class MoveUserCollectionArgs { + @Field(() => ID, { + name: 'destCollectionID', + description: 'ID of the parent to the new collection', + nullable: true, + }) + destCollectionID: string; + + @Field(() => ID, { + name: 'userCollectionID', + description: 'ID of the collection', + }) + userCollectionID: string; +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts new file mode 100644 index 000000000..d30dd2c28 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { UserCollectionService } from './user-collection.service'; +import { UserCollectionResolver } from './user-collection.resolver'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { UserModule } from 'src/user/user.module'; +import { PubSubModule } from 'src/pubsub/pubsub.module'; + +@Module({ + imports: [PrismaModule, UserModule, PubSubModule], + providers: [UserCollectionService, UserCollectionResolver], +}) +export class UserCollectionModule {} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts new file mode 100644 index 000000000..ed882ca8d --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts @@ -0,0 +1,348 @@ +import { UseGuards } from '@nestjs/common'; +import { + Resolver, + Mutation, + Args, + ID, + Query, + ResolveField, + Parent, + 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 { AuthUser } from 'src/types/AuthUser'; +import { UserCollectionService } from './user-collection.service'; +import { + UserCollection, + UserCollectionReorderData, +} from './user-collections.model'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { User } from 'src/user/user.model'; +import { PaginationArgs } from 'src/types/input-types.args'; +import { + CreateChildUserCollectionArgs, + CreateRootUserCollectionArgs, + MoveUserCollectionArgs, + RenameUserCollectionsArgs, + UpdateUserCollectionArgs, +} from './input-type.args'; +import { ReqType } from 'src/types/RequestTypes'; + +@Resolver(() => UserCollection) +export class UserCollectionResolver { + constructor( + private readonly userCollectionService: UserCollectionService, + private readonly pubSub: PubSubService, + ) {} + + // Field Resolvers + @ResolveField(() => User, { + description: 'User the collection belongs to', + }) + async user(@GqlUser() user: AuthUser) { + return user; + } + + @ResolveField(() => UserCollection, { + description: 'Parent user collection (null if root)', + nullable: true, + }) + async parent(@Parent() collection: UserCollection) { + return this.userCollectionService.getParentOfUserCollection(collection.id); + } + + @ResolveField(() => [UserCollection], { + description: 'List of children REST user collection', + complexity: 3, + }) + childrenREST( + @Parent() collection: UserCollection, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getChildrenOfUserCollection( + collection.id, + args.cursor, + args.take, + ReqType.REST, + ); + } + + @ResolveField(() => [UserCollection], { + description: 'List of children GraphQL user collection', + complexity: 3, + }) + childrenGQL( + @Parent() collection: UserCollection, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getChildrenOfUserCollection( + collection.id, + args.cursor, + args.take, + ReqType.GQL, + ); + } + + // Queries + @Query(() => [UserCollection], { + description: 'Get the root REST user collections for a user', + }) + @UseGuards(GqlAuthGuard) + rootRESTUserCollections( + @GqlUser() user: AuthUser, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getUserRootCollections( + user, + args.cursor, + args.take, + ReqType.REST, + ); + } + + @Query(() => [UserCollection], { + description: 'Get the root GraphQL user collections for a user', + }) + @UseGuards(GqlAuthGuard) + rootGQLUserCollections( + @GqlUser() user: AuthUser, + @Args() args: PaginationArgs, + ) { + return this.userCollectionService.getUserRootCollections( + user, + args.cursor, + args.take, + ReqType.GQL, + ); + } + + @Query(() => UserCollection, { + description: 'Get user collection with ID', + }) + @UseGuards(GqlAuthGuard) + async userCollection( + @Args({ + type: () => ID, + name: 'userCollectionID', + description: 'ID of the user collection', + }) + userCollectionID: string, + ) { + const userCollection = await this.userCollectionService.getUserCollection( + userCollectionID, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + // Mutations + @Mutation(() => UserCollection, { + description: 'Creates root REST user collection(no parent user collection)', + }) + @UseGuards(GqlAuthGuard) + async createRESTRootUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateRootUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + null, + ReqType.REST, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: + 'Creates root GraphQL user collection(no parent user collection)', + }) + @UseGuards(GqlAuthGuard) + async createGQLRootUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateRootUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + null, + ReqType.GQL, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Creates a new child REST user collection', + }) + @UseGuards(GqlAuthGuard) + async createGQLChildUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateChildUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + args.parentUserCollectionID, + ReqType.GQL, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Creates a new child GraphQL user collection', + }) + @UseGuards(GqlAuthGuard) + async createRESTChildUserCollection( + @GqlUser() user: AuthUser, + @Args() args: CreateChildUserCollectionArgs, + ) { + const userCollection = + await this.userCollectionService.createUserCollection( + user, + args.title, + args.parentUserCollectionID, + ReqType.REST, + ); + + if (E.isLeft(userCollection)) throwErr(userCollection.left); + return userCollection.right; + } + + @Mutation(() => UserCollection, { + description: 'Rename a user collection', + }) + @UseGuards(GqlAuthGuard) + async renameUserCollection( + @GqlUser() user: AuthUser, + @Args() args: RenameUserCollectionsArgs, + ) { + const updatedUserCollection = + await this.userCollectionService.renameCollection( + args.newTitle, + args.userCollectionID, + user.uid, + ); + + if (E.isLeft(updatedUserCollection)) throwErr(updatedUserCollection.left); + return updatedUserCollection.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a user collection', + }) + @UseGuards(GqlAuthGuard) + async deleteUserCollection( + @Args({ + name: 'userCollectionID', + description: 'ID of the user collection', + type: () => ID, + }) + userCollectionID: string, + @GqlUser() user: AuthUser, + ) { + const result = await this.userCollectionService.deleteUserCollection( + userCollectionID, + user.uid, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => UserCollection, { + description: 'Move user collection into new parent or root', + }) + @UseGuards(GqlAuthGuard) + async moveUserCollection( + @Args() args: MoveUserCollectionArgs, + @GqlUser() user: AuthUser, + ) { + const res = await this.userCollectionService.moveUserCollection( + args.userCollectionID, + args.destCollectionID, + user.uid, + ); + if (E.isLeft(res)) { + throwErr(res.left); + } + return res.right; + } + + @Mutation(() => Boolean, { + description: 'Move user collection into new parent or root', + }) + @UseGuards(GqlAuthGuard) + async updateUserCollectionOrder( + @Args() args: UpdateUserCollectionArgs, + @GqlUser() user: AuthUser, + ) { + const res = await this.userCollectionService.updateUserCollectionOrder( + args.collectionID, + args.nextCollectionID, + user.uid, + ); + if (E.isLeft(res)) { + throwErr(res.left); + } + return res.right; + } + + // Subscriptions + @Subscription(() => UserCollection, { + description: 'Listen for User Collection Creation', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userCollectionCreated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/created`); + } + + @Subscription(() => UserCollection, { + description: 'Listen to when a User Collection has been updated.', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userCollectionUpdated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/updated`); + } + + @Subscription(() => ID, { + description: 'Listen to when a User Collection has been deleted', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userCollectionRemoved(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/deleted`); + } + + @Subscription(() => UserCollection, { + description: 'Listen to when a User Collection has been moved', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userCollectionMoved(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/moved`); + } + + @Subscription(() => UserCollectionReorderData, { + description: 'Listen to when a User Collections position has changed', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userCollectionOrderUpdated(@GqlUser() user: AuthUser) { + return this.pubSub.asyncIterator(`user_coll/${user.uid}/order_updated`); + } +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts new file mode 100644 index 000000000..ee5e3e6e2 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts @@ -0,0 +1,1415 @@ +import { UserCollection } from '@prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + USER_COLL_DEST_SAME, + USER_COLL_IS_PARENT_COLL, + USER_COLL_NOT_FOUND, + USER_COLL_NOT_SAME_TYPE, + USER_COLL_NOT_SAME_USER, + USER_COLL_REORDERING_FAILED, + USER_COLL_SAME_NEXT_COLL, + USER_COLL_SHORT_TITLE, + USER_COL_ALREADY_ROOT, + USER_NOT_OWNER, +} from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { AuthUser } from 'src/types/AuthUser'; +import { ReqType } from 'src/types/RequestTypes'; +import { UserCollectionService } from './user-collection.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +// @ts-ignore +const userCollectionService = new UserCollectionService( + mockPrisma, + mockPubSub as any, +); + +const currentTime = new Date(); + +const user: AuthUser = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + createdOn: currentTime, + currentGQLSession: {}, + currentRESTSession: {}, +}; + +const rootRESTUserCollection: UserCollection = { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const rootGQLUserCollection: UserCollection = { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const rootRESTUserCollection_2: UserCollection = { + id: '4gf', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const rootGQLUserCollection_2: UserCollection = { + id: '4gf', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childRESTUserCollection: UserCollection = { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childGQLUserCollection: UserCollection = { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childRESTUserCollection_2: UserCollection = { + id: '0kn', + orderIndex: 2, + parentID: rootRESTUserCollection_2.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childGQLUserCollection_2: UserCollection = { + id: '0kn', + orderIndex: 2, + parentID: rootRESTUserCollection_2.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const childRESTUserCollectionList: UserCollection[] = [ + { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '345', + orderIndex: 2, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '456', + orderIndex: 3, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 3', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '567', + orderIndex: 4, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 4', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '678', + orderIndex: 5, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 5', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, +]; + +const childGQLUserCollectionList: UserCollection[] = [ + { + id: '234', + orderIndex: 1, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '345', + orderIndex: 2, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 2', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '456', + orderIndex: 3, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 3', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '567', + orderIndex: 4, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 4', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '678', + orderIndex: 5, + parentID: rootRESTUserCollection.id, + title: 'Child Collection 5', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, +]; + +const rootRESTUserCollectionList: UserCollection[] = [ + { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '234', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '345', + orderIndex: 3, + parentID: null, + title: 'Root Collection 3', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '456', + orderIndex: 4, + parentID: null, + title: 'Root Collection 4', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '567', + orderIndex: 5, + parentID: null, + title: 'Root Collection 5', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + }, +]; + +const rootGQLGQLUserCollectionList: UserCollection[] = [ + { + id: '123', + orderIndex: 1, + parentID: null, + title: 'Root Collection 1', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '234', + orderIndex: 2, + parentID: null, + title: 'Root Collection 2', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '345', + orderIndex: 3, + parentID: null, + title: 'Root Collection 3', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '456', + orderIndex: 4, + parentID: null, + title: 'Root Collection 4', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, + { + id: '567', + orderIndex: 5, + parentID: null, + title: 'Root Collection 5', + userUid: user.uid, + type: ReqType.GQL, + createdOn: currentTime, + updatedOn: currentTime, + }, +]; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('getParentOfUserCollection', () => { + test('should return a user-collection successfully with valid collectionID', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + ...childRESTUserCollection, + parent: rootRESTUserCollection, + } as any); + + const result = await userCollectionService.getParentOfUserCollection( + childRESTUserCollection.id, + ); + expect(result).toEqual(rootRESTUserCollection); + }); + test('should return null with invalid collectionID', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = await userCollectionService.getParentOfUserCollection( + 'invalidId', + ); + //TODO: check it not null + expect(result).toEqual(undefined); + }); +}); + +describe('getChildrenOfUserCollection', () => { + test('should return a list of paginated child REST user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + childRESTUserCollectionList, + ); + + const result = await userCollectionService.getChildrenOfUserCollection( + rootRESTUserCollection.id, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual(childRESTUserCollectionList); + }); + test('should return a list of paginated child GQL user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + childGQLUserCollectionList, + ); + + const result = await userCollectionService.getChildrenOfUserCollection( + rootGQLUserCollection.id, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual(childGQLUserCollectionList); + }); + test('should return a empty array with a invalid REST collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getChildrenOfUserCollection( + 'invalidID', + null, + 10, + ReqType.REST, + ); + expect(result).toEqual([]); + }); + test('should return a empty array with a invalid GQL collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getChildrenOfUserCollection( + 'invalidID', + null, + 10, + ReqType.GQL, + ); + expect(result).toEqual([]); + }); +}); + +describe('getUserCollection', () => { + test('should return a user-collection with valid collectionID', async () => { + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.getUserCollection( + rootRESTUserCollection.id, + ); + expect(result).toEqualRight(rootRESTUserCollection); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID is invalid', async () => { + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValue( + 'NotFoundError', + ); + + const result = await userCollectionService.getUserCollection('123'); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); +}); + +describe('getUserRootCollections', () => { + test('should return a list of paginated root REST user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + rootRESTUserCollectionList, + ); + + const result = await userCollectionService.getUserRootCollections( + user, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual(rootRESTUserCollectionList); + }); + test('should return a list of paginated root GQL user-collections with valid collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce( + rootGQLGQLUserCollectionList, + ); + + const result = await userCollectionService.getUserRootCollections( + user, + null, + 10, + ReqType.GQL, + ); + expect(result).toEqual(rootGQLGQLUserCollectionList); + }); + test('should return a empty array with a invalid REST collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getUserRootCollections( + { ...user, uid: 'invalidID' }, + null, + 10, + ReqType.REST, + ); + expect(result).toEqual([]); + }); + test('should return a empty array with a invalid GQL collectionID', async () => { + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + + const result = await userCollectionService.getUserRootCollections( + { ...user, uid: 'invalidID' }, + null, + 10, + ReqType.GQL, + ); + expect(result).toEqual([]); + }); +}); + +describe('createUserCollection', () => { + test('should throw USER_COLL_SHORT_TITLE when title is less than 3 characters', async () => { + const result = await userCollectionService.createUserCollection( + user, + 'ab', + rootRESTUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualLeft(USER_COLL_SHORT_TITLE); + }); + test('should throw USER_NOT_OWNER when user is not the owner of the collection', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.createUserCollection( + user, + rootRESTUserCollection.title, + rootRESTUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + test('should successfully create a new root REST user-collection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + //getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + rootRESTUserCollection.title, + rootRESTUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualRight(rootRESTUserCollection); + }); + test('should successfully create a new root GQL user-collection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootGQLUserCollection, + ); + + //getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootGQLUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + rootGQLUserCollection.title, + rootGQLUserCollection.id, + ReqType.GQL, + ); + expect(result).toEqualRight(rootGQLUserCollection); + }); + test('should successfully create a new child REST user-collection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + + //getChildCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + childRESTUserCollection.title, + childRESTUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualRight(childRESTUserCollection); + }); + test('should successfully create a new child GQL user-collection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + childGQLUserCollection, + ); + + //getChildCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childGQLUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + childGQLUserCollection.title, + childGQLUserCollection.id, + ReqType.REST, + ); + expect(result).toEqualRight(childGQLUserCollection); + }); + test('should send pubsub message to "user_coll//created" if child REST user-collection is created successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + + //getChildCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + childRESTUserCollection.title, + childRESTUserCollection.id, + ReqType.REST, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + childRESTUserCollection, + ); + }); + test('should send pubsub message to "user_coll//created" if child GQL user-collection is created successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + childGQLUserCollection, + ); + + //getChildCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + childGQLUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + childGQLUserCollection.title, + childGQLUserCollection.id, + ReqType.REST, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + childGQLUserCollection, + ); + }); + test('should send pubsub message to "user_coll//created" if REST root user-collection is created successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + //getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + rootRESTUserCollection.title, + rootRESTUserCollection.id, + ReqType.REST, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + rootRESTUserCollection, + ); + }); + test('should send pubsub message to "user_coll//created" if GQL root user-collection is created successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootGQLUserCollection, + ); + + //getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + mockPrisma.userCollection.create.mockResolvedValueOnce( + rootGQLUserCollection, + ); + + const result = await userCollectionService.createUserCollection( + user, + rootGQLUserCollection.title, + rootGQLUserCollection.id, + ReqType.REST, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/created`, + rootGQLUserCollection, + ); + }); +}); + +describe('renameCollection', () => { + test('should throw USER_COLL_SHORT_TITLE when title is less than 3 characters', async () => { + const result = await userCollectionService.renameCollection( + '', + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_SHORT_TITLE); + }); + test('should throw USER_NOT_OWNER when user is not the owner of the collection', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.renameCollection( + 'validTitle', + rootRESTUserCollection.id, + 'op09', + ); + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + test('should successfully update a user-collection with valid inputs', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...rootRESTUserCollection, + title: 'NewTitle', + }); + + const result = await userCollectionService.renameCollection( + 'NewTitle', + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight({ + ...rootRESTUserCollection, + title: 'NewTitle', + }); + }); + test('should throw USER_COLL_NOT_FOUND when userCollectionID is invalid', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + mockPrisma.userCollection.update.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.renameCollection( + 'NewTitle', + 'invalidID', + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should send pubsub message to "user_coll//updated" if user-collection title is updated successfully', async () => { + // isOwnerCheck + mockPrisma.userCollection.findFirstOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...rootRESTUserCollection, + title: 'NewTitle', + }); + + const result = await userCollectionService.renameCollection( + 'NewTitle', + rootRESTUserCollection.id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/updated`, + { + ...rootRESTUserCollection, + title: 'NewTitle', + }, + ); + }); +}); + +describe('deleteUserCollection', () => { + test('should successfully delete a user-collection with valid inputs', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.userCollection.delete.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight(true); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID is invalid ', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_NOT_OWNER when collectionID is invalid ', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + 'op09', + ); + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + test('should throw USER_COLL_NOT_FOUND when collectionID is invalid when deleting user-collection from UserCollectionTable ', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.userCollection.delete.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should send pubsub message to "user_coll//deleted" if user-collection is deleted successfully', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // deleteCollectionData + // deleteCollectionData --> FindMany query 1st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> FindMany query 2st time + mockPrisma.userCollection.findMany.mockResolvedValueOnce([]); + // deleteCollectionData --> DeleteMany query + mockPrisma.userRequest.deleteMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // deleteCollectionData --> removeUserCollection + mockPrisma.userCollection.delete.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.deleteUserCollection( + rootRESTUserCollection.id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/deleted`, + rootRESTUserCollection.id, + ); + }); +}); + +describe('moveUserCollection', () => { + test('should throw USER_COLL_NOT_FOUND if userCollectionID is invalid', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.moveUserCollection( + '234', + '009', + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_NOT_OWNER if user is not owner of collection', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + '234', + '009', + 'op09', + ); + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + test('should throw USER_COLL_DEST_SAME if userCollectionID and destCollectionID is the same', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_DEST_SAME); + }); + test('should throw USER_COLL_NOT_FOUND if destCollectionID is invalid', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.moveUserCollection( + 'invalidID', + rootRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_COLL_NOT_SAME_TYPE if userCollectionID and destCollectionID are not the same collection type', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childGQLUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childGQLUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_TYPE); + }); + test('should throw USER_COLL_NOT_SAME_USER if userCollectionID and destCollectionID are not from the same user', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...childRESTUserCollection_2, + userUid: 'differentUserUid', + }); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_USER); + }); + test('should throw USER_COLL_IS_PARENT_COLL if userCollectionID is parent of destCollectionID ', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_IS_PARENT_COLL); + }); + test('should throw USER_COL_ALREADY_ROOT when moving root user-collection to root', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COL_ALREADY_ROOT); + }); + test('should successfully move a child user-collection into root', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + // updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // changeParent + // changeParent --> getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.update.mockResolvedValue({ + ...childRESTUserCollection, + parentID: null, + orderIndex: 2, + }); + + const result = await userCollectionService.moveUserCollection( + childRESTUserCollection.id, + null, + user.uid, + ); + expect(result).toEqualRight({ + ...childRESTUserCollection, + parentID: null, + orderIndex: 2, + }); + }); + test('should throw USER_COLL_NOT_FOUND when trying to change parent of collection with invalid collectionID', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + // updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // changeParent + // changeParent --> getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.update.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.moveUserCollection( + childRESTUserCollection.id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should send pubsub message to "user_coll//moved" when user-collection is moved to root successfully', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollection, + ); + // updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // changeParent + // changeParent --> getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.update.mockResolvedValue({ + ...childRESTUserCollection, + parentID: null, + orderIndex: 2, + }); + + const result = await userCollectionService.moveUserCollection( + childRESTUserCollection.id, + null, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/moved`, + { + ...childRESTUserCollection, + parentID: null, + orderIndex: 2, + }, + ); + }); + test('should successfully move a root user-collection into a child user-collection', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(rootRESTUserCollection_2) + .mockResolvedValueOnce(null); + // isParent --> getUserCollection + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + childRESTUserCollection_2, + ); + // updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // changeParent + // changeParent --> getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.update.mockResolvedValue({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection_2.id, + orderIndex: 1, + }); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualRight({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection_2.id, + orderIndex: 1, + }); + }); + test('should successfully move a child user-collection into another child user-collection', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(rootRESTUserCollection_2) + .mockResolvedValueOnce(null); + // isParent --> getUserCollection + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + childRESTUserCollection, + ); + // updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // changeParent + // changeParent --> getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.update.mockResolvedValue({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection.id, + orderIndex: 1, + }); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection.id, + user.uid, + ); + expect(result).toEqualRight({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection.id, + orderIndex: 1, + }); + }); + test('should send pubsub message to "user_coll//moved" when user-collection is moved into another child user-collection successfully', async () => { + // getUserCollection + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollection, + ); + // getUserCollection for destCollection + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(rootRESTUserCollection_2) + .mockResolvedValueOnce(null); + // isParent --> getUserCollection + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + childRESTUserCollection, + ); + // updateOrderIndex + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 0 }); + // changeParent + // changeParent --> getRootCollectionsCount + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.findMany.mockResolvedValueOnce([ + rootRESTUserCollection, + ]); + mockPrisma.userCollection.update.mockResolvedValue({ + ...rootRESTUserCollection, + parentID: childRESTUserCollection.id, + orderIndex: 1, + }); + + const result = await userCollectionService.moveUserCollection( + rootRESTUserCollection.id, + childRESTUserCollection.id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/moved`, + { + ...rootRESTUserCollection, + parentID: childRESTUserCollection.id, + orderIndex: 1, + }, + ); + }); +}); + +describe('updateUserCollectionOrder', () => { + test('should throw USER_COLL_SAME_NEXT_COLL if collectionID and nextCollectionID are the same', async () => { + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[0].id, + childRESTUserCollectionList[0].id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_SAME_NEXT_COLL); + }); + test('should throw USER_COLL_NOT_FOUND if collectionID is invalid', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_FOUND); + }); + test('should throw USER_NOT_OWNER if userUID is of a different user', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + 'op09', + ); + expect(result).toEqualLeft(USER_NOT_OWNER); + }); + test('should successfully move the child user-collection to the end of the list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 4 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...childRESTUserCollectionList[4], + orderIndex: childRESTUserCollectionList.length, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualRight(true); + }); + test('should successfully move the root user-collection to the end of the list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + rootRESTUserCollectionList[4], + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 4 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...rootRESTUserCollectionList[4], + orderIndex: rootRESTUserCollectionList.length, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + rootRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualRight(true); + }); + test('should throw USER_COLL_REORDERING_FAILED when re-ordering operation failed for child user-collection list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.$transaction.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_REORDERING_FAILED); + }); + test('should send pubsub message to "user_coll//order_updated" when user-collection order is updated successfully', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow.mockResolvedValueOnce( + childRESTUserCollectionList[4], + ); + mockPrisma.userCollection.updateMany.mockResolvedValueOnce({ count: 4 }); + mockPrisma.userCollection.update.mockResolvedValueOnce({ + ...childRESTUserCollectionList[4], + orderIndex: childRESTUserCollectionList.length, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + null, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/order_updated`, + { + userCollection: { + ...childRESTUserCollectionList[4], + userID: childRESTUserCollectionList[4].userUid, + }, + nextUserCollection: null, + }, + ); + }); + test('should throw USER_COLL_NOT_SAME_USER when collectionID and nextCollectionID do not belong to the same user account', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce({ + ...childRESTUserCollection_2, + userUid: 'differendUID', + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_USER); + }); + test('should throw USER_COLL_NOT_SAME_TYPE when collectionID and nextCollectionID do not belong to the same collection type', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce({ + ...childRESTUserCollection_2, + type: ReqType.GQL, + }); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollection_2.id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_NOT_SAME_TYPE); + }); + test('should successfully update the order of the child user-collection list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollectionList[2].id, + user.uid, + ); + expect(result).toEqualRight(true); + }); + test('should throw USER_COLL_REORDERING_FAILED when re-ordering operation failed for child user-collection list', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + + mockPrisma.$transaction.mockRejectedValueOnce('RecordNotFound'); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollectionList[2].id, + user.uid, + ); + expect(result).toEqualLeft(USER_COLL_REORDERING_FAILED); + }); + test('should send pubsub message to "user_coll//order_updated" when user-collection order is updated successfully', async () => { + // getUserCollection; + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce(childRESTUserCollectionList[4]) + .mockResolvedValueOnce(childRESTUserCollectionList[2]); + + const result = await userCollectionService.updateUserCollectionOrder( + childRESTUserCollectionList[4].id, + childRESTUserCollectionList[2].id, + user.uid, + ); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user_coll/${user.uid}/order_updated`, + { + userCollection: { + ...childRESTUserCollectionList[4], + userID: childRESTUserCollectionList[4].userUid, + }, + nextUserCollection: { + ...childRESTUserCollectionList[2], + userID: childRESTUserCollectionList[2].userUid, + }, + }, + ); + }); +}); diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts new file mode 100644 index 000000000..572500076 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -0,0 +1,616 @@ +import { Injectable } from '@nestjs/common'; +import { + USER_COLL_DEST_SAME, + USER_COLL_IS_PARENT_COLL, + USER_COLL_NOT_FOUND, + USER_COLL_NOT_SAME_TYPE, + USER_COLL_NOT_SAME_USER, + USER_COLL_REORDERING_FAILED, + USER_COLL_SAME_NEXT_COLL, + USER_COLL_SHORT_TITLE, + USER_COL_ALREADY_ROOT, + USER_NOT_FOUND, + USER_NOT_OWNER, +} from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { AuthUser } from 'src/types/AuthUser'; +import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { Prisma, User, UserCollection } from '@prisma/client'; +import { UserCollection as UserCollectionModel } from './user-collections.model'; +import { ReqType } from 'src/types/RequestTypes'; +import { isValidLength } from 'src/utils'; +@Injectable() +export class UserCollectionService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + private cast(collection: UserCollection) { + return { + ...collection, + userID: collection.userUid, + }; + } + + private async getChildCollectionsCount(collectionID: string) { + const childCollectionCount = await this.prisma.userCollection.findMany({ + where: { parentID: collectionID }, + orderBy: { + orderIndex: 'desc', + }, + }); + if (!childCollectionCount.length) return 0; + return childCollectionCount[0].orderIndex; + } + + private async getRootCollectionsCount(userID: string) { + const rootCollectionCount = await this.prisma.userCollection.findMany({ + where: { userUid: userID, parentID: null }, + orderBy: { + orderIndex: 'desc', + }, + }); + if (!rootCollectionCount.length) return 0; + return rootCollectionCount[0].orderIndex; + } + + private async isOwnerCheck(collectionID: string, userID: string) { + try { + await this.prisma.userCollection.findFirstOrThrow({ + where: { + id: collectionID, + userUid: userID, + }, + }); + + return O.some(true); + } catch (error) { + return O.none; + } + } + + async getUserOfCollection(collectionID: string) { + try { + const userCollection = await this.prisma.userCollection.findUniqueOrThrow( + { + where: { + id: collectionID, + }, + include: { + user: true, + }, + }, + ); + return E.right(userCollection.user); + } catch (error) { + return E.left(USER_NOT_FOUND); + } + } + + async getParentOfUserCollection(collectionID: string) { + const { parent } = await this.prisma.userCollection.findUnique({ + where: { + id: collectionID, + }, + include: { + parent: true, + }, + }); + + return parent; + } + + async getChildrenOfUserCollection( + collectionID: string, + cursor: string | null, + take: number, + type: ReqType, + ) { + return this.prisma.userCollection.findMany({ + where: { + parentID: collectionID, + type: type, + }, + orderBy: { + orderIndex: 'asc', + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + } + + async getUserCollection(collectionID: string) { + try { + const userCollection = await this.prisma.userCollection.findUniqueOrThrow( + { + where: { + id: collectionID, + }, + }, + ); + return E.right(userCollection); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + async createUserCollection( + user: AuthUser, + title: string, + parentUserCollectionID: string | null, + type: ReqType, + ) { + const isTitleValid = isValidLength(title, 3); + if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE); + + if (parentUserCollectionID !== null) { + const isOwner = await this.isOwnerCheck(parentUserCollectionID, user.uid); + if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER); + } + + const isParent = parentUserCollectionID + ? { + connect: { + id: parentUserCollectionID, + }, + } + : undefined; + + const userCollection = await this.prisma.userCollection.create({ + data: { + title: title, + type: type, + user: { + connect: { + uid: user.uid, + }, + }, + parent: isParent, + orderIndex: !parentUserCollectionID + ? (await this.getRootCollectionsCount(user.uid)) + 1 + : (await this.getChildCollectionsCount(parentUserCollectionID)) + 1, + }, + }); + + await this.pubsub.publish(`user_coll/${user.uid}/created`, userCollection); + + return E.right(userCollection); + } + + async getUserRootCollections( + user: AuthUser, + cursor: string | null, + take: number, + type: ReqType, + ) { + return this.prisma.userCollection.findMany({ + where: { + userUid: user.uid, + parentID: null, + type: type, + }, + orderBy: { + orderIndex: 'asc', + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + } + + async getUserChildCollections( + user: AuthUser, + userCollectionID: string, + cursor: string | null, + take: number, + type: ReqType, + ) { + return this.prisma.userCollection.findMany({ + where: { + userUid: user.uid, + parentID: userCollectionID, + type: type, + }, + take: take, // default: 10 + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + }); + } + + async renameCollection( + newTitle: string, + userCollectionID: string, + userID: string, + ) { + const isTitleValid = isValidLength(newTitle, 3); + if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE); + + // Check to see is the collection belongs to the user + const isOwner = await this.isOwnerCheck(userCollectionID, userID); + if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER); + + try { + const updatedUserCollection = await this.prisma.userCollection.update({ + where: { + id: userCollectionID, + }, + data: { + title: newTitle, + }, + }); + + this.pubsub.publish( + `user_coll/${updatedUserCollection.userUid}/updated`, + updatedUserCollection, + ); + + return E.right(updatedUserCollection); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + private async removeUserCollection(collectionID: string) { + try { + const deletedUserCollection = await this.prisma.userCollection.delete({ + where: { + id: collectionID, + }, + }); + + return E.right(deletedUserCollection); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + private async deleteCollectionData(collection: UserCollection) { + // Get all child collections in collectionID + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + parentID: collection.id, + }, + }); + + // Delete child collections + await Promise.all( + childCollectionList.map((coll) => + this.deleteUserCollection(coll.id, coll.userUid), + ), + ); + + // Delete all requests in collectionID + await this.prisma.userRequest.deleteMany({ + where: { + collectionID: collection.id, + }, + }); + + // Update orderIndexes in userCollection table for user + await this.updateOrderIndex( + collection.parentID, + { gt: collection.orderIndex }, + { decrement: 1 }, + ); + + // Delete collection from UserCollection table + const deletedUserCollection = await this.removeUserCollection( + collection.id, + ); + if (E.isLeft(deletedUserCollection)) + return E.left(deletedUserCollection.left); + + this.pubsub.publish( + `user_coll/${deletedUserCollection.right.userUid}/deleted`, + deletedUserCollection.right.id, + ); + + return E.right(true); + } + + async deleteUserCollection(collectionID: string, userID: string) { + // Get collection details of collectionID + const collection = await this.getUserCollection(collectionID); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + // Check to see is the collection belongs to the user + if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER); + + // Delete all child collections and requests in the collection + const collectionData = await this.deleteCollectionData(collection.right); + if (E.isLeft(collectionData)) return E.left(collectionData.left); + + return E.right(true); + } + + private async changeParent( + collection: UserCollection, + parentCollectionID: string | null, + ) { + try { + let collectionCount: number; + + if (!parentCollectionID) + collectionCount = await this.getRootCollectionsCount( + collection.userUid, + ); + collectionCount = await this.getChildCollectionsCount(parentCollectionID); + + const updatedCollection = await this.prisma.userCollection.update({ + where: { + id: collection.id, + }, + data: { + // if parentCollectionID == null, collection becomes root collection + // if parentCollectionID != null, collection becomes child collection + parentID: parentCollectionID, + orderIndex: collectionCount + 1, + }, + }); + + return E.right(updatedCollection); + } catch (error) { + return E.left(USER_COLL_NOT_FOUND); + } + } + + private async isParent( + collection: UserCollection, + destCollection: UserCollection, + ): Promise> { + // Check if collection and destCollection are same + if (collection === destCollection) { + return O.none; + } + if (destCollection.parentID !== null) { + // Check if ID of collection is same as parent of destCollection + if (destCollection.parentID === collection.id) { + return O.none; + } + // Get collection details of collection one step above in the tree i.e the parent collection + const parentCollection = await this.getUserCollection( + destCollection.parentID, + ); + if (E.isLeft(parentCollection)) { + return O.none; + } + // Call isParent again now with parent collection + return await this.isParent(collection, parentCollection.right); + } else { + return O.some(true); + } + } + + private async updateOrderIndex( + parentID: string, + orderIndexCondition: Prisma.IntFilter, + dataCondition: Prisma.IntFieldUpdateOperationsInput, + ) { + const updatedUserCollection = await this.prisma.userCollection.updateMany({ + where: { + parentID: parentID, + orderIndex: orderIndexCondition, + }, + data: { orderIndex: dataCondition }, + }); + + return updatedUserCollection; + } + + async moveUserCollection( + userCollectionID: string, + destCollectionID: string | null, + userID: string, + ) { + // Get collection details of collectionID + const collection = await this.getUserCollection(userCollectionID); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + // Check to see is the collection belongs to the user + if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER); + + // destCollectionID == null i.e move collection to root + if (!destCollectionID) { + if (!collection.right.parentID) { + // collection is a root collection + // Throw error if collection is already a root collection + return E.left(USER_COL_ALREADY_ROOT); + } + // Move child collection into root and update orderIndexes for root userCollections + await this.updateOrderIndex( + collection.right.parentID, + { gt: collection.right.orderIndex }, + { decrement: 1 }, + ); + + // Change parent from child to root i.e child collection becomes a root collection + const updatedCollection = await this.changeParent(collection.right, null); + if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/moved`, + updatedCollection.right, + ); + + return E.right(updatedCollection.right); + } + + // destCollectionID != null i.e move into another collection + if (userCollectionID === destCollectionID) { + // Throw error if collectionID and destCollectionID are the same + return E.left(USER_COLL_DEST_SAME); + } + + // Get collection details of destCollectionID + const destCollection = await this.getUserCollection(destCollectionID); + if (E.isLeft(destCollection)) return E.left(USER_COLL_NOT_FOUND); + + // Check if collection and destCollection belong to the same collection type + if (collection.right.type !== destCollection.right.type) { + return E.left(USER_COLL_NOT_SAME_TYPE); + } + + // Check if collection and destCollection belong to the same user account + if (collection.right.userUid !== destCollection.right.userUid) { + return E.left(USER_COLL_NOT_SAME_USER); + } + + // Check if collection is present on the parent tree for destCollection + const checkIfParent = await this.isParent( + collection.right, + destCollection.right, + ); + if (O.isNone(checkIfParent)) { + return E.left(USER_COLL_IS_PARENT_COLL); + } + + // Move root/child collection into another child collection and update orderIndexes of the previous parent + await this.updateOrderIndex( + collection.right.parentID, + { gt: collection.right.orderIndex }, + { decrement: 1 }, + ); + + // Change parent from null to teamCollection i.e collection becomes a child collection + const updatedCollection = await this.changeParent( + collection.right, + destCollection.right.id, + ); + if (E.isLeft(updatedCollection)) return E.left(updatedCollection.left); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/moved`, + updatedCollection.right, + ); + + return E.right(updatedCollection.right); + } + + getCollectionCount(collectionID: string): Promise { + return this.prisma.userCollection.count({ + where: { parentID: collectionID }, + }); + } + + async updateUserCollectionOrder( + collectionID: string, + nextCollectionID: string | null, + userID: string, + ) { + // Throw error if collectionID and nextCollectionID are the same + if (collectionID === nextCollectionID) + return E.left(USER_COLL_SAME_NEXT_COLL); + + // Get collection details of collectionID + const collection = await this.getUserCollection(collectionID); + if (E.isLeft(collection)) return E.left(USER_COLL_NOT_FOUND); + + // Check to see is the collection belongs to the user + if (collection.right.userUid !== userID) return E.left(USER_NOT_OWNER); + + if (!nextCollectionID) { + // nextCollectionID == null i.e move collection to the end of the list + try { + await this.prisma.$transaction(async (tx) => { + // Step 1: Decrement orderIndex of all items that come after collection.orderIndex till end of list of items + await tx.userCollection.updateMany({ + where: { + parentID: collection.right.parentID, + orderIndex: { + gte: collection.right.orderIndex + 1, + }, + }, + data: { + orderIndex: { decrement: 1 }, + }, + }); + // Step 2: Update orderIndex of collection to length of list + const updatedUserCollection = await tx.userCollection.update({ + where: { id: collection.right.id }, + data: { + orderIndex: await this.getCollectionCount( + collection.right.parentID, + ), + }, + }); + }); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/order_updated`, + { + userCollection: this.cast(collection.right), + nextUserCollection: null, + }, + ); + + return E.right(true); + } catch (error) { + return E.left(USER_COLL_REORDERING_FAILED); + } + } + + // nextCollectionID != null i.e move to a certain position + // Get collection details of nextCollectionID + const subsequentCollection = await this.getUserCollection(nextCollectionID); + if (E.isLeft(subsequentCollection)) return E.left(USER_COLL_NOT_FOUND); + + if (collection.right.userUid !== subsequentCollection.right.userUid) + return E.left(USER_COLL_NOT_SAME_USER); + + // Check if collection and subsequentCollection belong to the same collection type + if (collection.right.type !== subsequentCollection.right.type) { + return E.left(USER_COLL_NOT_SAME_TYPE); + } + + try { + await this.prisma.$transaction(async (tx) => { + // Step 1: Determine if we are moving collection up or down the list + const isMovingUp = + subsequentCollection.right.orderIndex < collection.right.orderIndex; + // Step 2: Update OrderIndex of items in list depending on moving up or down + const updateFrom = isMovingUp + ? subsequentCollection.right.orderIndex + : collection.right.orderIndex + 1; + + const updateTo = isMovingUp + ? collection.right.orderIndex - 1 + : subsequentCollection.right.orderIndex - 1; + + await tx.userCollection.updateMany({ + where: { + parentID: collection.right.parentID, + orderIndex: { gte: updateFrom, lte: updateTo }, + }, + data: { + orderIndex: isMovingUp ? { increment: 1 } : { decrement: 1 }, + }, + }); + // Step 3: Update OrderIndex of collection + const updatedUserCollection = await tx.userCollection.update({ + where: { id: collection.right.id }, + data: { + orderIndex: isMovingUp + ? subsequentCollection.right.orderIndex + : subsequentCollection.right.orderIndex - 1, + }, + }); + }); + + this.pubsub.publish( + `user_coll/${collection.right.userUid}/order_updated`, + { + userCollection: this.cast(collection.right), + nextUserCollection: this.cast(subsequentCollection.right), + }, + ); + + return E.right(true); + } catch (error) { + return E.left(USER_COLL_REORDERING_FAILED); + } + } +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts b/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts new file mode 100644 index 000000000..f1ad987bc --- /dev/null +++ b/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts @@ -0,0 +1,43 @@ +import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql'; +import { ReqType } from 'src/types/RequestTypes'; + +@ObjectType() +export class UserCollection { + @Field(() => ID, { + description: 'ID of the user collection', + }) + id: string; + + @Field({ + description: 'Displayed title of the user collection', + }) + title: string; + + @Field(() => ReqType, { + description: 'Type of the user collection', + }) + type: ReqType; + + parentID: string | null; + + userID: string; +} + +@ObjectType() +export class UserCollectionReorderData { + @Field({ + description: 'User Collection being moved', + }) + userCollection: UserCollection; + + @Field({ + description: + 'User Collection succeeding the collection being moved in its new position', + nullable: true, + }) + nextUserCollection?: UserCollection; +} + +registerEnumType(ReqType, { + name: 'CollType', +}); diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 8a649363a..a10349bea 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -8,6 +8,7 @@ import * as T from 'fp-ts/Task'; import * as E from 'fp-ts/Either'; import * as A from 'fp-ts/Array'; import { TeamMemberRole } from './team/team.model'; +import { User } from './user/user.model'; import { JSON_INVALID } from './errors'; /** @@ -124,7 +125,7 @@ export const validateEmail = (email: string) => { ).test(email); }; -/* +/** * String to JSON parser * @param {str} str The string to parse * @returns {E.Right | E.Left<"json_invalid">} An Either of the parsed JSON @@ -138,3 +139,16 @@ export function stringToJson( return E.left(JSON_INVALID); } } +/** + * + * @param title string whose length we need to check + * @param length minimum length the title needs to be + * @returns boolean if title is of valid length or not + */ +export function isValidLength(title: string, length: number) { + if (title.length < length) { + return false; + } + + return true; +}