diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index f899da36..28e11e01 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -79,14 +79,15 @@ model TeamEnvironment { } model User { - uid String @id @default(cuid()) - displayName String? - email String? - photoURL String? - settings UserSettings? - - UserHistory UserHistory[] - UserEnvironments UserEnvironment[] + uid String @id @default(cuid()) + displayName String? + email String? + photoURL String? + currentRESTSession Json? + currentGQLSession Json? + settings UserSettings? + UserHistory UserHistory[] + UserEnvironments UserEnvironment[] } model UserSettings { @@ -105,7 +106,7 @@ model UserHistory { request Json responseMetadata Json isStarred Boolean - executedOn DateTime @default(now()) @db.Timestamp(3) + executedOn DateTime @default(now()) @db.Timestamp(3) } enum ReqType { diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 117e6d0d..c78df062 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -26,6 +26,12 @@ export const USER_FB_DOCUMENT_DELETION_FAILED = */ export const USER_NOT_FOUND = 'user/not_found' as const; +/** + * User update failure + * (UserService) + */ +export const USER_UPDATE_FAILED = 'user/update_failed' as const; + /** * User deletion failure * (UserService) diff --git a/packages/hoppscotch-backend/src/prisma/prisma.service.ts b/packages/hoppscotch-backend/src/prisma/prisma.service.ts index 8febf1b5..be48954e 100644 --- a/packages/hoppscotch-backend/src/prisma/prisma.service.ts +++ b/packages/hoppscotch-backend/src/prisma/prisma.service.ts @@ -16,4 +16,4 @@ export class PrismaService async onModuleDestroy() { await this.$disconnect(); } -} +} \ No newline at end of file diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index 8254b184..cee33860 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -1,3 +1,4 @@ +import { User } from 'src/user/user.model'; import { UserSettings } from 'src/user-settings/user-settings.model'; import { UserEnvironment } from '../user-environment/user-environments.model'; import { UserHistory } from '../user-history/user-history.model'; @@ -5,10 +6,11 @@ import { UserHistory } from '../user-history/user-history.model'; // A custom message type that defines the topic and the corresponding payload. // For every module that publishes a subscription add its type def and the possible subscription type. export type TopicDef = { + [topic: `user/${string}/${'updated'}`]: User; + [topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings; [ topic: `user_environment/${string}/${'created' | 'updated' | 'deleted'}` ]: UserEnvironment; - [topic: `user_settings/${string}/${'created' | 'updated'}`]: UserSettings; [topic: `user_environment/${string}/deleted_many`]: number; [ topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}` diff --git a/packages/hoppscotch-backend/src/user/user.model.ts b/packages/hoppscotch-backend/src/user/user.model.ts index 779dbc4d..d0631935 100644 --- a/packages/hoppscotch-backend/src/user/user.model.ts +++ b/packages/hoppscotch-backend/src/user/user.model.ts @@ -1,27 +1,54 @@ -import { ObjectType, ID, Field } from '@nestjs/graphql'; +import { + ObjectType, + ID, + Field, + InputType, + registerEnumType, +} from '@nestjs/graphql'; @ObjectType() export class User { @Field(() => ID, { - description: 'Firebase UID of the user', + description: 'UID of the user', }) uid: string; @Field({ nullable: true, - description: 'Displayed name of the user (if given)', + description: 'Displayed name of the user', }) displayName?: string; @Field({ nullable: true, - description: 'Email of the user (if given)', + description: 'Email of the user', }) email?: string; @Field({ nullable: true, - description: 'URL to the profile photo of the user (if given)', + description: 'URL to the profile photo of the user', }) photoURL?: string; + + @Field({ + nullable: true, + description: 'Stringified current REST session for logged-in User', + }) + currentRESTSession?: string; + + @Field({ + nullable: true, + description: 'Stringified current GraphQL session for logged-in User', + }) + currentGQLSession?: string; } + +export enum SessionType { + REST = 'REST', + GQL = 'GQL', +} + +registerEnumType(SessionType, { + name: 'SessionType', +}); diff --git a/packages/hoppscotch-backend/src/user/user.module.ts b/packages/hoppscotch-backend/src/user/user.module.ts index 3681faad..a5f9eda1 100644 --- a/packages/hoppscotch-backend/src/user/user.module.ts +++ b/packages/hoppscotch-backend/src/user/user.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { UserResolver } from './user.resolver'; import { PubSubModule } from 'src/pubsub/pubsub.module'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { UserService } from './user.service'; @Module({ - imports: [PubSubModule], - providers: [UserResolver], + imports: [PubSubModule, PrismaModule], + providers: [UserResolver, UserService], exports: [], }) export class UserModule {} diff --git a/packages/hoppscotch-backend/src/user/user.resolver.ts b/packages/hoppscotch-backend/src/user/user.resolver.ts index d913be01..a3c88fe2 100644 --- a/packages/hoppscotch-backend/src/user/user.resolver.ts +++ b/packages/hoppscotch-backend/src/user/user.resolver.ts @@ -1,14 +1,19 @@ -import { Resolver, Query } from '@nestjs/graphql'; -import { User } from './user.model'; +import { Resolver, Query, Mutation, Args, Subscription } from '@nestjs/graphql'; +import { SessionType, User } from './user.model'; import { UseGuards } from '@nestjs/common'; import { GqlAuthGuard } from '../guards/gql-auth.guard'; import { GqlUser } from '../decorators/gql-user.decorator'; +import { UserService } from './user.service'; +import { throwErr } from 'src/utils'; +import * as E from 'fp-ts/lib/Either'; +import { PubSubService } from 'src/pubsub/pubsub.service'; @Resolver(() => User) export class UserResolver { - // TODO: remove the eslint-disable line below once dependencies are added to user.service file - // eslint-disable-next-line @typescript-eslint/no-empty-function - constructor() {} + constructor( + private readonly userService: UserService, + private readonly pubsub: PubSubService, + ) {} @Query(() => User, { description: @@ -27,4 +32,43 @@ export class UserResolver { me2(@GqlUser() user: User): User { return user; } + + /* Mutations */ + + @Mutation(() => User, { + description: 'Update user sessions', + }) + @UseGuards(GqlAuthGuard) + async updateUserSessions( + @GqlUser() user: User, + @Args({ + name: 'currentSession', + description: 'JSON string of the saved REST/GQL session', + }) + currentSession: string, + @Args({ + name: 'sessionType', + description: 'Type of the session', + }) + sessionType: SessionType, + ): Promise { + const updatedUser = await this.userService.updateUserSessions( + user, + currentSession, + sessionType, + ); + if (E.isLeft(updatedUser)) throwErr(updatedUser.left); + return updatedUser.right; + } + + /* Subscriptions */ + + @Subscription(() => User, { + description: 'Listen for user updates', + resolve: (value) => value, + }) + @UseGuards(GqlAuthGuard) + userUpdated(@GqlUser() user: User) { + return this.pubsub.asyncIterator(`user/${user.uid}/updated`); + } } diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts new file mode 100644 index 00000000..4561a6f6 --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -0,0 +1,108 @@ +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { JSON_INVALID } from 'src/errors'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { UserService } from './user.service'; + +const mockPrisma = mockDeep(); +const mockPubSub = mockDeep(); + +// @ts-ignore +const userService = new UserService(mockPrisma, mockPubSub as any); + +const user = { + uid: '123', + displayName: 'John Doe', + email: 'test@hoppscotch.io', + photoURL: 'https://example.com/avatar.png', + currentRESTSession: JSON.stringify({}), + currentGQLSession: JSON.stringify({}), +}; + +beforeEach(() => { + mockReset(mockPrisma); + mockPubSub.publish.mockClear(); +}); + +describe('UserService', () => { + describe('updateUserSessions', () => { + test('Should resolve right and update users GQL session', async () => { + const sessionData = user.currentGQLSession; + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: JSON.parse(sessionData), + currentRESTSession: null, + }); + + const result = await userService.updateUserSessions( + user, + sessionData, + 'GQL', + ); + + expect(result).toEqualRight({ + ...user, + currentGQLSession: sessionData, + currentRESTSession: null, + }); + }); + test('Should resolve right and update users REST session', async () => { + const sessionData = user.currentGQLSession; + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: null, + currentRESTSession: JSON.parse(sessionData), + }); + + const result = await userService.updateUserSessions( + user, + sessionData, + 'REST', + ); + + expect(result).toEqualRight({ + ...user, + currentGQLSession: null, + currentRESTSession: sessionData, + }); + }); + test('Should reject left and update user for invalid GQL session', async () => { + const sessionData = 'invalid json'; + + const result = await userService.updateUserSessions( + user, + sessionData, + 'GQL', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + test('Should reject left and update user for invalid REST session', async () => { + const sessionData = 'invalid json'; + + const result = await userService.updateUserSessions( + user, + sessionData, + 'REST', + ); + + expect(result).toEqualLeft(JSON_INVALID); + }); + + test('Should publish pubsub message on user update sessions', async () => { + mockPrisma.user.update.mockResolvedValue({ + ...user, + currentGQLSession: JSON.parse(user.currentGQLSession), + currentRESTSession: JSON.parse(user.currentRESTSession), + }); + + await userService.updateUserSessions(user, user.currentGQLSession, 'GQL'); + + expect(mockPubSub.publish).toHaveBeenCalledTimes(1); + expect(mockPubSub.publish).toHaveBeenCalledWith( + `user/${user.uid}/updated`, + user, + ); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts new file mode 100644 index 00000000..9b0d6be1 --- /dev/null +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { SessionType, User } from './user.model'; +import * as E from 'fp-ts/lib/Either'; +import { USER_UPDATE_FAILED } from 'src/errors'; +import { PubSubService } from 'src/pubsub/pubsub.service'; +import { stringToJson } from 'src/utils'; + +@Injectable() +export class UserService { + constructor( + private readonly prisma: PrismaService, + private readonly pubsub: PubSubService, + ) {} + + /** + * Update a user's sessions + * @param user User object + * @param currentRESTSession user's current REST session + * @param currentGQLSession user's current GQL session + * @returns a Either of User or error + */ + async updateUserSessions( + user: User, + currentSession: string, + sessionType: string, + ): Promise | E.Left> { + const validatedSession = await this.validateSession(currentSession); + if (E.isLeft(validatedSession)) return E.left(validatedSession.left); + + try { + const sessionObj = {}; + switch (sessionType) { + case SessionType.GQL: + sessionObj['currentGQLSession'] = validatedSession.right; + break; + case SessionType.REST: + sessionObj['currentRESTSession'] = validatedSession.right; + break; + default: + return E.left(USER_UPDATE_FAILED); + } + + const dbUpdatedUser = await this.prisma.user.update({ + where: { uid: user.uid }, + data: sessionObj, + }); + + const updatedUser: User = { + ...dbUpdatedUser, + currentGQLSession: dbUpdatedUser.currentGQLSession + ? JSON.stringify(dbUpdatedUser.currentGQLSession) + : null, + currentRESTSession: dbUpdatedUser.currentRESTSession + ? JSON.stringify(dbUpdatedUser.currentRESTSession) + : null, + }; + + // Publish subscription for user updates + await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser); + + return E.right(updatedUser); + } catch (e) { + return E.left(USER_UPDATE_FAILED); + } + } + + /** + * Validate and parse currentRESTSession and currentGQLSession + * @param sessionData string of the session + * @returns a Either of JSON object or error + */ + async validateSession(sessionData: string) { + const jsonSession = stringToJson(sessionData); + if (E.isLeft(jsonSession)) return E.left(jsonSession.left); + + return E.right(jsonSession.right); + } +}