From be46ed2686d29b4678d53649caef6da12dbce389 Mon Sep 17 00:00:00 2001 From: Balu Babu Date: Tue, 14 Mar 2023 18:31:47 +0530 Subject: [PATCH] refactor: adding JSON import/export functions to UserCollections module (HBE-169) (#37) * feat: created exportUserCollectionsToJSON mutation for UserCollection module * chore: added comments to export functions * chore: added type and user ownership checking to creation methods * chore: replaced request property with spread request object instead * chore: completed all changes requested in inital review of PR * chore: explicitly exporting request title in export function * chore: explicitly exporting request title in export function * chore: added codegen folder to gitignore * chore: removed gql-code gen file from repo --- packages/hoppscotch-backend/src/errors.ts | 11 +- .../src/types/CollectionFolder.ts | 3 +- .../src/user-collection/input-type.args.ts | 22 ++ .../user-collection.resolver.ts | 46 +++ .../user-collection.service.spec.ts | 4 +- .../user-collection.service.ts | 280 +++++++++++++++++- 6 files changed, 352 insertions(+), 14 deletions(-) diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index e8a029a2a..ee40d7d97 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -223,8 +223,7 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID = * Tried to reorder team request but failed * (TeamRequestService) */ -export const TEAM_REQ_REORDERING_FAILED = - 'team_req/reordering_failed' as const; +export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const; /** * No Postmark Sender Email defined @@ -487,7 +486,7 @@ 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 = +export const USER_COLL_ALREADY_ROOT = 'user_coll/target_user_collection_is_already_root_user_collection' as const; /** @@ -535,3 +534,9 @@ export const USER_COLL_SAME_NEXT_COLL = * (UserCollectionService) */ export const USER_NOT_OWNER = 'user_coll/user_not_owner' as const; + +/** + * The JSON used is not valid + * (UserCollectionService) + */ +export const USER_COLL_INVALID_JSON = 'user_coll/invalid_json'; diff --git a/packages/hoppscotch-backend/src/types/CollectionFolder.ts b/packages/hoppscotch-backend/src/types/CollectionFolder.ts index ca2d24e72..e7dd7e194 100644 --- a/packages/hoppscotch-backend/src/types/CollectionFolder.ts +++ b/packages/hoppscotch-backend/src/types/CollectionFolder.ts @@ -1,6 +1,5 @@ -import { Prisma } from '@prisma/client'; - export interface CollectionFolder { + id?: string; folders: CollectionFolder[]; requests: any[]; name: string; diff --git a/packages/hoppscotch-backend/src/user-collection/input-type.args.ts b/packages/hoppscotch-backend/src/user-collection/input-type.args.ts index 815340df9..4aea0cff3 100644 --- a/packages/hoppscotch-backend/src/user-collection/input-type.args.ts +++ b/packages/hoppscotch-backend/src/user-collection/input-type.args.ts @@ -1,4 +1,5 @@ import { Field, ID, ArgsType } from '@nestjs/graphql'; +import { ReqType } from '@prisma/client'; import { PaginationArgs } from 'src/types/input-types.args'; @ArgsType() @@ -73,3 +74,24 @@ export class MoveUserCollectionArgs { }) userCollectionID: string; } + +@ArgsType() +export class ImportUserCollectionsFromJSONArgs { + @Field({ + name: 'jsonString', + description: 'JSON string to import', + }) + jsonString: string; + @Field({ + name: 'reqType', + description: 'Type of UserCollection', + }) + reqType: ReqType; + @Field(() => ID, { + name: 'parentCollectionID', + description: + 'ID to the collection to which to import into (null if to import into the root of the user)', + nullable: true, + }) + parentCollectionID?: string; +} diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts index a5a91841e..5c27497a3 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.resolver.ts @@ -24,6 +24,7 @@ import { PaginationArgs } from 'src/types/input-types.args'; import { CreateChildUserCollectionArgs, CreateRootUserCollectionArgs, + ImportUserCollectionsFromJSONArgs, MoveUserCollectionArgs, RenameUserCollectionsArgs, UpdateUserCollectionArgs, @@ -139,6 +140,32 @@ export class UserCollectionResolver { return userCollection.right; } + @Query(() => String, { + description: + 'Returns the JSON string giving the collections and their contents of a user', + }) + @UseGuards(GqlAuthGuard) + async exportUserCollectionsToJSON( + @GqlUser() user: AuthUser, + @Args({ + type: () => ID, + name: 'collectionID', + description: 'ID of the user collection', + nullable: true, + defaultValue: null, + }) + collectionID: string, + ) { + const jsonString = + await this.userCollectionService.exportUserCollectionsToJSON( + user.uid, + collectionID, + ); + + if (E.isLeft(jsonString)) throwErr(jsonString.left as string); + return jsonString.right; + } + // Mutations @Mutation(() => UserCollection, { description: 'Creates root REST user collection(no parent user collection)', @@ -300,6 +327,25 @@ export class UserCollectionResolver { return res.right; } + @Mutation(() => Boolean, { + description: 'Import collections from JSON string to the specified Team', + }) + @UseGuards(GqlAuthGuard) + async importUserCollectionsFromJSON( + @Args() args: ImportUserCollectionsFromJSONArgs, + @GqlUser() user: AuthUser, + ) { + const importedCollection = + await this.userCollectionService.importCollectionsFromJSON( + args.jsonString, + user.uid, + args.parentCollectionID, + args.reqType, + ); + if (E.isLeft(importedCollection)) throwErr(importedCollection.left); + return importedCollection.right; + } + // Subscriptions @Subscription(() => UserCollection, { description: 'Listen for User Collection Creation', 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 index 934daad0e..97eb03489 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts @@ -9,7 +9,7 @@ import { USER_COLL_REORDERING_FAILED, USER_COLL_SAME_NEXT_COLL, USER_COLL_SHORT_TITLE, - USER_COL_ALREADY_ROOT, + USER_COLL_ALREADY_ROOT, USER_NOT_OWNER, } from 'src/errors'; import { PrismaService } from 'src/prisma/prisma.service'; @@ -997,7 +997,7 @@ describe('moveUserCollection', () => { null, user.uid, ); - expect(result).toEqualLeft(USER_COL_ALREADY_ROOT); + expect(result).toEqualLeft(USER_COLL_ALREADY_ROOT); }); test('should successfully move a child user-collection into root', async () => { // getUserCollection diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts index 9e31fec13..c0475726e 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -8,19 +8,26 @@ import { USER_COLL_REORDERING_FAILED, USER_COLL_SAME_NEXT_COLL, USER_COLL_SHORT_TITLE, - USER_COL_ALREADY_ROOT, + USER_COLL_ALREADY_ROOT, USER_NOT_FOUND, USER_NOT_OWNER, + USER_COLL_INVALID_JSON, } 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 { + Prisma, + User, + UserCollection, + ReqType as DBReqType, +} from '@prisma/client'; import { UserCollection as UserCollectionModel } from './user-collections.model'; import { ReqType } from 'src/types/RequestTypes'; -import { isValidLength } from 'src/utils'; +import { isValidLength, stringToJson } from 'src/utils'; +import { CollectionFolder } from 'src/types/CollectionFolder'; @Injectable() export class UserCollectionService { constructor( @@ -209,10 +216,20 @@ export class UserCollectionService { const isTitleValid = isValidLength(title, 3); if (!isTitleValid) return E.left(USER_COLL_SHORT_TITLE); - // Check to see if parentUserCollectionID belongs to this User + // If creating a child collection if (parentUserCollectionID !== null) { - const isOwner = await this.isOwnerCheck(parentUserCollectionID, user.uid); - if (O.isNone(isOwner)) return E.left(USER_NOT_OWNER); + const parentCollection = await this.getUserCollection( + parentUserCollectionID, + ); + if (E.isLeft(parentCollection)) return E.left(parentCollection.left); + + // Check to see if parentUserCollectionID belongs to this User + if (parentCollection.right.userUid !== user.uid) + return E.left(USER_NOT_OWNER); + + // Check to see if parent collection is of the same type of new collection being created + if (parentCollection.right.type !== type) + return E.left(USER_COLL_NOT_SAME_TYPE); } const isParent = parentUserCollectionID @@ -555,7 +572,7 @@ export class UserCollectionService { 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); + return E.left(USER_COLL_ALREADY_ROOT); } // Move child collection into root and update orderIndexes for root userCollections await this.updateOrderIndex( @@ -764,4 +781,253 @@ export class UserCollectionService { return E.left(USER_COLL_REORDERING_FAILED); } } + + /** + * Generate a JSON containing all the contents of a collection + * + * @param userUID The User UID + * @param collectionID The Collection ID + * @returns A JSON string containing all the contents of a collection + */ + private async exportUserCollectionToJSONObject( + userUID: string, + collectionID: string, + ): Promise | E.Right> { + // Get Collection details + const collection = await this.getUserCollection(collectionID); + if (E.isLeft(collection)) return E.left(collection.left); + + // Get all child collections whose parentID === collectionID + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + parentID: collectionID, + userUid: userUID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + // Create a list of child collection and request data ready for export + const childrenCollectionObjects: CollectionFolder[] = []; + for (const coll of childCollectionList) { + const result = await this.exportUserCollectionToJSONObject( + userUID, + coll.id, + ); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); + } + + // Fetch all child requests that belong to collectionID + const requests = await this.prisma.userRequest.findMany({ + where: { + userUid: userUID, + collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + const result: CollectionFolder = { + id: collection.right.id, + name: collection.right.title, + folders: childrenCollectionObjects, + requests: requests.map((x) => { + return { + id: x.id, + name: x.title, + ...(x.request as Record), // type casting x.request of type Prisma.JSONValue to an object to enable spread + }; + }), + }; + + return E.right(result); + } + + /** + * Generate a JSON containing all the contents of collections and requests of a team + * + * @param userUID The User UID + * @returns A JSON string containing all the contents of collections and requests of a team + */ + async exportUserCollectionsToJSON( + userUID: string, + collectionID: string | null, + ) { + // Get all child collections details + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + userUid: userUID, + parentID: collectionID, + }, + }); + + // Create a list of child collection and request data ready for export + const collectionListObjects: CollectionFolder[] = []; + for (const coll of childCollectionList) { + const result = await this.exportUserCollectionToJSONObject( + userUID, + coll.id, + ); + if (E.isLeft(result)) return E.left(result.left); + + collectionListObjects.push(result.right); + } + + // If collectionID is not null, return JSONified data for specific collection + if (collectionID) { + // Get Details of collection + const parentCollection = await this.getUserCollection(collectionID); + if (E.isLeft(parentCollection)) return E.left(parentCollection.left); + + // Fetch all child requests that belong to collectionID + const requests = await this.prisma.userRequest.findMany({ + where: { + userUid: userUID, + collectionID: parentCollection.right.id, + }, + orderBy: { + orderIndex: 'asc', + }, + }); + + return E.right( + JSON.stringify({ + id: parentCollection.right.id, + name: parentCollection.right.title, + folders: collectionListObjects, + requests: requests.map((x) => { + return { + id: x.id, + name: x.title, + ...(x.request as Record), // type casting x.request of type Prisma.JSONValue to an object to enable spread + }; + }), + }), + ); + } + + return E.right(JSON.stringify(collectionListObjects)); + } + + /** + * Generate a Prisma query object representation of a collection and its child collections and requests + * + * @param folder CollectionFolder from client + * @param userID The User ID + * @param orderIndex Initial OrderIndex of + * @param reqType The Type of Collection + * @returns A Prisma query object to create a collection, its child collections and requests + */ + private generatePrismaQueryObj( + folder: CollectionFolder, + userID: string, + orderIndex: number, + reqType: DBReqType, + ): Prisma.UserCollectionCreateInput { + return { + title: folder.name, + user: { + connect: { + uid: userID, + }, + }, + requests: { + create: folder.requests.map((r, index) => ({ + title: r.name, + user: { + connect: { + uid: userID, + }, + }, + type: reqType, + request: r, + orderIndex: index + 1, + })), + }, + orderIndex: orderIndex, + type: reqType, + children: { + create: folder.folders.map((f, index) => + this.generatePrismaQueryObj(f, userID, index + 1, reqType), + ), + }, + }; + } + + /** + * Create new UserCollections and UserRequests from JSON string + * + * @param jsonString The JSON string of the content + * @param userID The User ID + * @param destCollectionID The Collection ID + * @param reqType The Type of Collection + * @returns An Either of a Boolean if the creation operation was successful + */ + async importCollectionsFromJSON( + jsonString: string, + userID: string, + destCollectionID: string | null, + reqType: DBReqType, + ) { + // Check to see if jsonString is valid + const collectionsList = stringToJson(jsonString); + if (E.isLeft(collectionsList)) return E.left(USER_COLL_INVALID_JSON); + + // Check to see if parsed jsonString is an array + if (!Array.isArray(collectionsList.right)) + return E.left(USER_COLL_INVALID_JSON); + + // Check to see if destCollectionID belongs to this User + if (destCollectionID) { + const parentCollection = await this.getUserCollection(destCollectionID); + if (E.isLeft(parentCollection)) return E.left(parentCollection.left); + + // Check to see if parentUserCollectionID belongs to this User + if (parentCollection.right.userUid !== userID) + return E.left(USER_NOT_OWNER); + + // Check to see if parent collection is of the same type of new collection being created + if (parentCollection.right.type !== reqType) + return E.left(USER_COLL_NOT_SAME_TYPE); + } + + // Get number of root or child collections for destCollectionID(if destcollectionID != null) or destTeamID(if destcollectionID == null) + const count = !destCollectionID + ? await this.getRootCollectionsCount(userID) + : await this.getChildCollectionsCount(destCollectionID); + + // Generate Prisma Query Object for all child collections in collectionsList + const queryList = collectionsList.right.map((x) => + this.generatePrismaQueryObj(x, userID, count + 1, reqType), + ); + + const parent = destCollectionID + ? { + connect: { + id: destCollectionID, + }, + } + : undefined; + + const userCollections = await this.prisma.$transaction( + queryList.map((x) => + this.prisma.userCollection.create({ + data: { + ...x, + parent, + }, + }), + ), + ); + + userCollections.forEach((x) => + this.pubsub.publish(`user_coll/${userID}/created`, x), + ); + + return E.right(true); + } }