diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts new file mode 100644 index 000000000..552299f60 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.model.ts @@ -0,0 +1,30 @@ +import { Field, ID, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class UserEnvironment { + @Field(() => ID, { + description: 'ID of the User Environment', + }) + id: string; + + @Field(() => ID, { + description: 'ID of the user this environment belongs to', + }) + userUid: string; + + @Field(() => String, { + nullable: true, + description: 'Name of the environment', + }) + name: string | null | undefined; + + @Field({ + description: 'All variables present in the environment', + }) + variables: string; // JSON string of the variables object (format:[{ key: "bla", value: "bla_val" }, ...] ) which will be received from the client + + @Field({ + description: 'isGlobal flag to indicate the environment is global or not', + }) + isGlobal: boolean; +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts new file mode 100644 index 000000000..04f391682 --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PubSubModule } from '../pubsub/pubsub.module'; +import { UserModule } from '../user/user.module'; +import { UserEnvsUserResolver } from './user.resolver'; +import { UserEnvironmentsResolver } from './user-environments.resolver'; +import { UserEnvironmentsService } from './user-environments.service'; + +@Module({ + imports: [PrismaModule, PubSubModule, UserModule], + providers: [ + UserEnvironmentsResolver, + UserEnvironmentsService, + UserEnvsUserResolver, + ], + exports: [UserEnvironmentsService], +}) +export class UserEnvironmentsModule {} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts new file mode 100644 index 000000000..bf1383f7d --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.resolver.ts @@ -0,0 +1,187 @@ +import { Args, ID, Mutation, Resolver, Subscription } from '@nestjs/graphql'; +import { PubSubService } from '../pubsub/pubsub.service'; +import { UserEnvironment } from './user-environments.model'; +import { UseGuards } from '@nestjs/common'; +import { GqlAuthGuard } from '../guards/gql-auth.guard'; +import { GqlUser } from '../decorators/gql-user.decorator'; +import { User } from '../user/user.model'; +import { UserEnvironmentsService } from './user-environments.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; + +@Resolver() +export class UserEnvironmentsResolver { + constructor( + private readonly userEnvironmentsService: UserEnvironmentsService, + private readonly pubsub: PubSubService, + ) {} + + /* Mutations */ + + @Mutation(() => UserEnvironment, { + description: + 'Create a new personal or global user environment for given user uid', + }) + @UseGuards(GqlAuthGuard) + async createUserEnvironment( + @GqlUser() user: User, + @Args({ + name: 'name', + description: + 'Name of the User Environment, if global send an empty string', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + @Args({ + name: 'isGlobal', + description: 'isGlobal flag to indicate personal or global environment', + }) + isGlobal: boolean, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.createUserEnvironment( + user.uid, + name, + variables, + isGlobal, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => UserEnvironment, { + description: + 'Update a users personal or global environment based on environment id', + }) + @UseGuards(GqlAuthGuard) + async updateUserEnvironment( + @Args({ + name: 'id', + description: 'ID of the user environment', + type: () => ID, + }) + id: string, + @Args({ + name: 'name', + description: + 'Name of the User Environment, if global send an empty string', + }) + name: string, + @Args({ + name: 'variables', + description: 'JSON string of the variables object', + }) + variables: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.updateUserEnvironment( + id, + name, + variables, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => UserEnvironment, { + description: 'Deletes a users personal environment based on environment id', + }) + @UseGuards(GqlAuthGuard) + async deleteUserEnvironment( + @Args({ + name: 'id', + description: 'ID of the user environment', + type: () => ID, + }) + id: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.deleteUserEnvironment(id); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + @Mutation(() => Number, { + description: 'Deletes users all personal environments', + }) + @UseGuards(GqlAuthGuard) + async deleteUserEnvironments(@GqlUser() user: User): Promise { + return await this.userEnvironmentsService.deleteUserEnvironments(user.uid); + } + + @Mutation(() => UserEnvironment, { + description: 'Deletes all variables inside a users global environment', + }) + @UseGuards(GqlAuthGuard) + async deleteAllVariablesFromUsersGlobalEnvironment( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the users global environment', + type: () => ID, + }) + id: string, + ): Promise { + const userEnvironment = + await this.userEnvironmentsService.deleteAllVariablesFromUsersGlobalEnvironment( + user.uid, + id, + ); + if (E.isLeft(userEnvironment)) throwErr(userEnvironment.left); + return userEnvironment.right; + } + + /* Subscriptions */ + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment Creation', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentCreated( + @Args({ + name: 'userUid', + description: 'users uid', + type: () => ID, + }) + userUid: string, + ) { + return this.pubsub.asyncIterator(`user_environment/${userUid}/created`); + } + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment updates', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentUpdated( + @Args({ + name: 'id', + description: 'environment id', + type: () => ID, + }) + id: string, + ) { + return this.pubsub.asyncIterator(`user_environment/${id}/updated`); + } + + @Subscription(() => UserEnvironment, { + description: 'Listen for User Environment updates', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userEnvironmentDeleted( + @Args({ + name: 'id', + description: 'environment id', + type: () => ID, + }) + id: string, + ) { + return this.pubsub.asyncIterator(`user_environment/${id}/deleted`); + } +} diff --git a/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts b/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts new file mode 100644 index 000000000..fa322602e --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user-environments.service.ts @@ -0,0 +1,264 @@ +import { Injectable } from '@nestjs/common'; + +import { UserEnvironment } from './user-environments.model'; +import { PrismaService } from '../prisma/prisma.service'; +import { PubSubService } from '../pubsub/pubsub.service'; +import * as E from 'fp-ts/Either'; + +enum SubscriptionType { + Created = 'created', + Updated = 'updated', + Deleted = 'deleted', +} + +@Injectable() +export class UserEnvironmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + /** + * Fetch personal and global user environments based on `isGlobal` flag + * @param uid Users uid + * @returns array of users personal and global environments + */ + async fetchUserEnvironments(uid: string) { + const environments = await this.prisma.userEnvironment.findMany({ + where: { + userUid: uid, + isGlobal: false, + }, + }); + + const userEnvironments: UserEnvironment[] = []; + environments.forEach((environment) => { + userEnvironments.push({ + userUid: environment.userUid, + id: environment.id, + name: environment.name, + variables: JSON.stringify(environment.variables), + isGlobal: environment.isGlobal, + }); + }); + return userEnvironments; + } + + /** + * Create a personal or global user environment + * @param uid Users uid + * @param name environments name + * @param variables environment variables + * @param isGlobal flag to indicate type of environment to create + * @returns an `UserEnvironment` object + */ + async createUserEnvironment( + uid: string, + name: string, + variables: string, + isGlobal: boolean, + ) { + if (isGlobal) { + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (E.isRight(globalEnvExists)) return E.left('global env exits'); + } + + const createdEnvironment = await this.prisma.userEnvironment.create({ + data: { + userUid: uid, + name: name, + variables: JSON.parse(variables), + isGlobal: isGlobal, + }, + }); + + const userEnvironment: UserEnvironment = { + userUid: createdEnvironment.userUid, + id: createdEnvironment.id, + name: createdEnvironment.name, + variables: JSON.stringify(createdEnvironment.variables), + isGlobal: createdEnvironment.isGlobal, + }; + // Publish subscription for environment creation + await this.publishUserEnvironmentCreatedSubscription( + userEnvironment, + SubscriptionType.Created, + ); + + return E.right(userEnvironment); + } + + /** + * Update an existing personal or global user environment + * @param id environment id + * @param name environments name + * @param variables environment variables + * @returns an Either of `UserEnvironment` or error + */ + async updateUserEnvironment(id: string, name: string, variables: string) { + try { + const updatedEnvironment = await this.prisma.userEnvironment.update({ + where: { id: id }, + data: { + name: name, + variables: JSON.parse(variables), + }, + }); + + const updatedUserEnvironment: UserEnvironment = { + userUid: updatedEnvironment.userUid, + id: updatedEnvironment.id, + name: updatedEnvironment.name, + variables: JSON.stringify(updatedEnvironment.variables), + isGlobal: updatedEnvironment.isGlobal, + }; + // Publish subscription for environment creation + await this.publishUserEnvironmentCreatedSubscription( + updatedUserEnvironment, + SubscriptionType.Updated, + ); + return E.right(updatedUserEnvironment); + } catch (e) { + return E.left('user_env not found'); + } + } + + /** + * Delete an existing personal user environment based on environment id + * @param id environment id + * @returns an Either of deleted `UserEnvironment` or error + */ + async deleteUserEnvironment(id: string) { + try { + const deletedEnvironment = await this.prisma.userEnvironment.delete({ + where: { + id: id, + }, + }); + + const deletedUserEnvironment: UserEnvironment = { + userUid: deletedEnvironment.userUid, + id: deletedEnvironment.id, + name: deletedEnvironment.name, + variables: JSON.stringify(deletedEnvironment.variables), + isGlobal: deletedEnvironment.isGlobal, + }; + // Publish subscription for environment creation + await this.publishUserEnvironmentCreatedSubscription( + deletedUserEnvironment, + SubscriptionType.Deleted, + ); + return E.right(deletedUserEnvironment); + } catch (e) { + return E.left('user_env not found'); + } + } + + /** + * Deletes all existing personal user environments + * @param id environment id + * @param isGlobal flag to indicate type of environment to delete + * @returns a count of environments deleted + */ + async deleteUserEnvironments(uid: string) { + const deletedEnvironments = await this.prisma.userEnvironment.deleteMany({ + where: { + userUid: uid, + isGlobal: false, + }, + }); + return deletedEnvironments.count; + } + + async deleteAllVariablesFromUsersGlobalEnvironment(uid: string, id: string) { + const globalEnvExists = await this.checkForExistingGlobalEnv(uid); + if (E.isRight(globalEnvExists) && !E.isLeft(globalEnvExists)) { + const env = globalEnvExists.right; + if (env.id === id) { + try { + const updatedEnvironment = await this.prisma.userEnvironment.update({ + where: { id: id }, + data: { + variables: [], + }, + }); + const updatedUserEnvironment: UserEnvironment = { + userUid: updatedEnvironment.userUid, + id: updatedEnvironment.id, + name: updatedEnvironment.name, + variables: JSON.stringify(updatedEnvironment.variables), + isGlobal: updatedEnvironment.isGlobal, + }; + // Publish subscription for environment creation + await this.publishUserEnvironmentCreatedSubscription( + updatedUserEnvironment, + SubscriptionType.Updated, + ); + return E.right(updatedUserEnvironment); + } catch (e) { + return E.left('user_env not found'); + } + } + } + return E.left('mismatch'); + } + + // Method to publish subscriptions based on the subscription type of the environment + async publishUserEnvironmentCreatedSubscription( + userEnv: UserEnvironment, + subscriptionType: SubscriptionType, + ) { + switch (subscriptionType) { + case SubscriptionType.Created: + await this.pubsub.publish( + `user_environment/${userEnv.userUid}/created`, + userEnv, + ); + break; + case SubscriptionType.Updated: + await this.pubsub.publish( + `user_environment/${userEnv.id}/updated`, + userEnv, + ); + break; + case SubscriptionType.Deleted: + await this.pubsub.publish( + `user_environment/${userEnv.id}/deleted`, + userEnv, + ); + break; + default: + break; + } + } + + private async checkForExistingGlobalEnv(uid: string) { + const globalEnv = await this.prisma.userEnvironment.findFirst({ + where: { + userUid: uid, + isGlobal: true, + }, + }); + if (globalEnv === null) return E.left('global env not exist'); + + return E.right(globalEnv); + } + + async fetchUserGlobalEnvironments(uid: string) { + const globalEnvironment = await this.prisma.userEnvironment.findFirst({ + where: { + userUid: uid, + isGlobal: true, + }, + rejectOnNotFound: true, + }); + + return { + userUid: globalEnvironment.userUid, + id: globalEnvironment.id, + name: globalEnvironment.name, + variables: JSON.stringify(globalEnvironment.variables), + isGlobal: globalEnvironment.isGlobal, + }; + } +} diff --git a/packages/hoppscotch-backend/src/user-environment/user.resolver.ts b/packages/hoppscotch-backend/src/user-environment/user.resolver.ts new file mode 100644 index 000000000..97acdac2c --- /dev/null +++ b/packages/hoppscotch-backend/src/user-environment/user.resolver.ts @@ -0,0 +1,27 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { User } from 'src/user/user.model'; +import { UserEnvironment } from './user-environments.model'; +import { UserEnvironmentsService } from './user-environments.service'; + +@Resolver(() => User) +export class UserEnvsUserResolver { + constructor(private userEnvironmentsService: UserEnvironmentsService) {} + @ResolveField(() => [UserEnvironment], { + description: 'Returns a list of users personal environments', + }) + async environments(@Parent() user: User): Promise { + return await this.userEnvironmentsService.fetchUserEnvironments(user.uid); + } + + @ResolveField(() => UserEnvironment, { + description: + 'Returns a list of user variables inside a global environments', + }) + async globalEnvironments( + @Parent() user: User, + ): Promise { + return await this.userEnvironmentsService.fetchUserGlobalEnvironments( + user.uid, + ); + } +}