diff --git a/packages/hoppscotch-backend/prisma/migrations/20231106120154_embeds_addition/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20231106120154_embeds_addition/migration.sql new file mode 100644 index 00000000..d545bd4e --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20231106120154_embeds_addition/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB, +ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id"); + +-- AddForeignKey +ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 67feb1ff..68dfed99 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -68,11 +68,13 @@ model TeamRequest { } model Shortcode { - id String @id - request Json - creatorUid String? - createdOn DateTime @default(now()) - + id String @id @unique + request Json + embedProperties Json? + creatorUid String? + User User? @relation(fields: [creatorUid], references: [uid]) + createdOn DateTime @default(now()) + updatedOn DateTime @updatedAt @default(now()) @@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique") } @@ -102,6 +104,7 @@ model User { currentGQLSession Json? createdOn DateTime @default(now()) @db.Timestamp(3) invitedUsers InvitedUsers[] + shortcodes Shortcode[] } model Account { diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 6fb4a4a1..2a98aef3 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -318,18 +318,6 @@ export const TEAM_INVITATION_NOT_FOUND = */ export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const; -/** - * Invalid ShortCode format - * (ShortcodeService) - */ -export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const; - -/** - * ShortCode already exists in DB - * (ShortcodeService) - */ -export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const; - /** * Invalid or non-existent TEAM ENVIRONMENT ID * (TeamEnvironmentsService) @@ -621,3 +609,24 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const; */ export const MAILER_FROM_ADDRESS_UNDEFINED = 'mailer/from_address_undefined' as const; + +/** + * SharedRequest invalid request JSON format + * (ShortcodeService) + */ +export const SHORTCODE_INVALID_REQUEST_JSON = + 'shortcode/request_invalid_format' as const; + +/** + * SharedRequest invalid properties JSON format + * (ShortcodeService) + */ +export const SHORTCODE_INVALID_PROPERTIES_JSON = + 'shortcode/properties_invalid_format' as const; + +/** + * SharedRequest invalid properties not found + * (ShortcodeService) + */ +export const SHORTCODE_PROPERTIES_NOT_FOUND = + 'shortcode/properties_not_found' as const; diff --git a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts index c4f31639..b0cc8a85 100644 --- a/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts +++ b/packages/hoppscotch-backend/src/pubsub/topicsDefs.ts @@ -69,5 +69,7 @@ export type TopicDef = { [topic: `team_req/${string}/req_deleted`]: string; [topic: `team/${string}/invite_added`]: TeamInvitation; [topic: `team/${string}/invite_removed`]: string; - [topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode; + [ + topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}` + ]: Shortcode; }; diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts index 79ab0132..47828ff7 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts @@ -3,7 +3,7 @@ import { Field, ID, ObjectType } from '@nestjs/graphql'; @ObjectType() export class Shortcode { @Field(() => ID, { - description: 'The shortcode. 12 digit alphanumeric.', + description: 'The 12 digit alphanumeric code', }) id: string; @@ -12,6 +12,12 @@ export class Shortcode { }) request: string; + @Field({ + description: 'JSON string representing the properties for an embed', + nullable: true, + }) + properties: string; + @Field({ description: 'Timestamp of when the Shortcode was created', }) diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts index eb696a23..7febeadf 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; import { PrismaModule } from 'src/prisma/prisma.module'; import { PubSubModule } from 'src/pubsub/pubsub.module'; import { UserModule } from 'src/user/user.module'; @@ -7,14 +6,7 @@ import { ShortcodeResolver } from './shortcode.resolver'; import { ShortcodeService } from './shortcode.service'; @Module({ - imports: [ - PrismaModule, - UserModule, - PubSubModule, - JwtModule.register({ - secret: process.env.JWT_SECRET, - }), - ], + imports: [PrismaModule, UserModule, PubSubModule], providers: [ShortcodeService, ShortcodeResolver], exports: [ShortcodeService], }) diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts index e357a907..df0158e1 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.resolver.ts @@ -1,6 +1,5 @@ import { Args, - Context, ID, Mutation, Query, @@ -11,14 +10,12 @@ import * as E from 'fp-ts/Either'; import { UseGuards } from '@nestjs/common'; import { Shortcode } from './shortcode.model'; import { ShortcodeService } from './shortcode.service'; -import { UserService } from 'src/user/user.service'; import { throwErr } from 'src/utils'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; import { User } from 'src/user/user.model'; import { PubSubService } from 'src/pubsub/pubsub.service'; import { AuthUser } from '../types/AuthUser'; -import { JwtService } from '@nestjs/jwt'; import { PaginationArgs } from 'src/types/input-types.args'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { SkipThrottle } from '@nestjs/throttler'; @@ -28,9 +25,7 @@ import { SkipThrottle } from '@nestjs/throttler'; export class ShortcodeResolver { constructor( private readonly shortcodeService: ShortcodeService, - private readonly userService: UserService, private readonly pubsub: PubSubService, - private jwtService: JwtService, ) {} /* Queries */ @@ -64,20 +59,53 @@ export class ShortcodeResolver { @Mutation(() => Shortcode, { description: 'Create a shortcode for the given request.', }) + @UseGuards(GqlAuthGuard) async createShortcode( + @GqlUser() user: AuthUser, @Args({ name: 'request', description: 'JSON string of the request object', }) request: string, - @Context() ctx: any, + @Args({ + name: 'properties', + description: 'JSON string of the properties of the embed', + nullable: true, + }) + properties: string, ) { - const decodedAccessToken = this.jwtService.verify( - ctx.req.cookies['access_token'], - ); const result = await this.shortcodeService.createShortcode( request, - decodedAccessToken?.sub, + properties, + user, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } + + @Mutation(() => Shortcode, { + description: 'Update a user generated Shortcode', + }) + @UseGuards(GqlAuthGuard) + async updateEmbedProperties( + @GqlUser() user: AuthUser, + @Args({ + name: 'code', + type: () => ID, + description: 'The Shortcode to update', + }) + code: string, + @Args({ + name: 'properties', + description: 'JSON string of the properties of the embed', + }) + properties: string, + ) { + const result = await this.shortcodeService.updateEmbedProperties( + code, + user.uid, + properties, ); if (E.isLeft(result)) throwErr(result.left); @@ -114,6 +142,16 @@ export class ShortcodeResolver { return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`); } + @Subscription(() => Shortcode, { + description: 'Listen for Shortcode updates', + resolve: (value) => value, + }) + @SkipThrottle() + @UseGuards(GqlAuthGuard) + myShortcodesUpdated(@GqlUser() user: AuthUser) { + return this.pubsub.asyncIterator(`shortcode/${user.uid}/updated`); + } + @Subscription(() => Shortcode, { description: 'Listen for shortcode deletion', resolve: (value) => value, diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts index b1dac382..27ab9f5f 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts @@ -1,13 +1,15 @@ import { mockDeep, mockReset } from 'jest-mock-extended'; import { PrismaService } from '../prisma/prisma.service'; import { - SHORTCODE_ALREADY_EXISTS, - SHORTCODE_INVALID_JSON, + SHORTCODE_INVALID_PROPERTIES_JSON, + SHORTCODE_INVALID_REQUEST_JSON, SHORTCODE_NOT_FOUND, + SHORTCODE_PROPERTIES_NOT_FOUND, } from 'src/errors'; import { Shortcode } from './shortcode.model'; import { ShortcodeService } from './shortcode.service'; import { UserService } from 'src/user/user.service'; +import { AuthUser } from 'src/types/AuthUser'; const mockPrisma = mockDeep(); @@ -22,7 +24,7 @@ const mockFB = { doc: mockDocFunc, }, }; -const mockUserService = new UserService(mockFB as any, mockPubSub as any); +const mockUserService = new UserService(mockPrisma as any, mockPubSub as any); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -38,18 +40,34 @@ beforeEach(() => { }); const createdOn = new Date(); -const shortCodeWithOutUser = { - id: '123', - request: '{}', +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: createdOn, - creatorUid: null, + currentGQLSession: {}, + currentRESTSession: {}, }; -const shortCodeWithUser = { +const mockEmbed = { id: '123', request: '{}', + embedProperties: '{}', createdOn: createdOn, - creatorUid: 'user_uid_1', + creatorUid: user.uid, + updatedOn: createdOn, +}; + +const mockShortcode = { + id: '123', + request: '{}', + embedProperties: null, + createdOn: createdOn, + creatorUid: user.uid, + updatedOn: createdOn, }; const shortcodes = [ @@ -58,33 +76,38 @@ const shortcodes = [ request: { hello: 'there', }, - creatorUid: 'testuser', + embedProperties: { + foo: 'bar', + }, + creatorUid: user.uid, createdOn: new Date(), + updatedOn: createdOn, }, { id: 'blablabla1', request: { hello: 'there', }, - creatorUid: 'testuser', + embedProperties: { + foo: 'bar', + }, + creatorUid: user.uid, createdOn: new Date(), + updatedOn: createdOn, }, ]; describe('ShortcodeService', () => { describe('getShortCode', () => { - test('should return a valid shortcode with valid shortcode ID', async () => { - mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce( - shortCodeWithOutUser, - ); + test('should return a valid Shortcode with valid Shortcode ID', async () => { + mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(mockEmbed); - const result = await shortcodeService.getShortCode( - shortCodeWithOutUser.id, - ); + const result = await shortcodeService.getShortCode(mockEmbed.id); expect(result).toEqualRight({ - id: shortCodeWithOutUser.id, - createdOn: shortCodeWithOutUser.createdOn, - request: JSON.stringify(shortCodeWithOutUser.request), + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), }); }); @@ -99,10 +122,10 @@ describe('ShortcodeService', () => { }); describe('fetchUserShortCodes', () => { - test('should return list of shortcodes with valid inputs and no cursor', async () => { + test('should return list of Shortcode with valid inputs and no cursor', async () => { mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes); - const result = await shortcodeService.fetchUserShortCodes('testuser', { + const result = await shortcodeService.fetchUserShortCodes(user.uid, { cursor: null, take: 10, }); @@ -110,20 +133,22 @@ describe('ShortcodeService', () => { { id: shortcodes[0].id, request: JSON.stringify(shortcodes[0].request), + properties: JSON.stringify(shortcodes[0].embedProperties), createdOn: shortcodes[0].createdOn, }, { id: shortcodes[1].id, request: JSON.stringify(shortcodes[1].request), + properties: JSON.stringify(shortcodes[1].embedProperties), createdOn: shortcodes[1].createdOn, }, ]); }); - test('should return list of shortcodes with valid inputs and cursor', async () => { + test('should return list of Shortcode with valid inputs and cursor', async () => { mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]); - const result = await shortcodeService.fetchUserShortCodes('testuser', { + const result = await shortcodeService.fetchUserShortCodes(user.uid, { cursor: 'blablabla', take: 10, }); @@ -131,6 +156,7 @@ describe('ShortcodeService', () => { { id: shortcodes[1].id, request: JSON.stringify(shortcodes[1].request), + properties: JSON.stringify(shortcodes[1].embedProperties), createdOn: shortcodes[1].createdOn, }, ]); @@ -139,7 +165,7 @@ describe('ShortcodeService', () => { test('should return an empty array for an invalid cursor', async () => { mockPrisma.shortcode.findMany.mockResolvedValue([]); - const result = await shortcodeService.fetchUserShortCodes('testuser', { + const result = await shortcodeService.fetchUserShortCodes(user.uid, { cursor: 'invalidcursor', take: 10, }); @@ -171,77 +197,111 @@ describe('ShortcodeService', () => { }); describe('createShortcode', () => { - test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => { + test('should throw SHORTCODE_INVALID_REQUEST_JSON error if incoming request data is invalid', async () => { const result = await shortcodeService.createShortcode( 'invalidRequest', - 'user_uid_1', + null, + user, ); - expect(result).toEqualLeft(SHORTCODE_INVALID_JSON); + expect(result).toEqualLeft(SHORTCODE_INVALID_REQUEST_JSON); }); - test('should successfully create a new shortcode with valid user uid', async () => { - // generateUniqueShortCodeID --> getShortCode + test('should throw SHORTCODE_INVALID_PROPERTIES_JSON error if incoming properties data is invalid', async () => { + const result = await shortcodeService.createShortcode( + '{}', + 'invalid_data', + user, + ); + expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON); + }); + + test('should successfully create a new Embed with valid user uid', async () => { + // generateUniqueShortCodeID --> getShortcode mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( 'NotFoundError', ); - mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed); - const result = await shortcodeService.createShortcode('{}', 'user_uid_1'); - expect(result).toEqualRight({ - id: shortCodeWithUser.id, - createdOn: shortCodeWithUser.createdOn, - request: JSON.stringify(shortCodeWithUser.request), + const result = await shortcodeService.createShortcode('{}', '{}', user); + expect(result).toEqualRight({ + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), }); }); - test('should successfully create a new shortcode with null user uid', async () => { - // generateUniqueShortCodeID --> getShortCode + test('should successfully create a new ShortCode with valid user uid', async () => { + // generateUniqueShortCodeID --> getShortcode mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( 'NotFoundError', ); - mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode); - const result = await shortcodeService.createShortcode('{}', null); - expect(result).toEqualRight({ - id: shortCodeWithUser.id, - createdOn: shortCodeWithUser.createdOn, - request: JSON.stringify(shortCodeWithOutUser.request), + const result = await shortcodeService.createShortcode('{}', null, user); + expect(result).toEqualRight({ + id: mockShortcode.id, + createdOn: mockShortcode.createdOn, + request: JSON.stringify(mockShortcode.request), + properties: mockShortcode.embedProperties, }); }); - test('should send pubsub message to `shortcode/{uid}/created` on successful creation of shortcode', async () => { - // generateUniqueShortCodeID --> getShortCode + test('should send pubsub message to `shortcode/{uid}/created` on successful creation of a Shortcode', async () => { + // generateUniqueShortCodeID --> getShortcode mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( 'NotFoundError', ); - mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode); + + const result = await shortcodeService.createShortcode('{}', null, user); - const result = await shortcodeService.createShortcode('{}', 'user_uid_1'); expect(mockPubSub.publish).toHaveBeenCalledWith( - `shortcode/${shortCodeWithUser.creatorUid}/created`, - { - id: shortCodeWithUser.id, - createdOn: shortCodeWithUser.createdOn, - request: JSON.stringify(shortCodeWithUser.request), + `shortcode/${mockShortcode.creatorUid}/created`, + { + id: mockShortcode.id, + createdOn: mockShortcode.createdOn, + request: JSON.stringify(mockShortcode.request), + properties: mockShortcode.embedProperties, + }, + ); + }); + + test('should send pubsub message to `shortcode/{uid}/created` on successful creation of an Embed', async () => { + // generateUniqueShortCodeID --> getShortcode + mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( + 'NotFoundError', + ); + mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed); + + const result = await shortcodeService.createShortcode('{}', '{}', user); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${mockEmbed.creatorUid}/created`, + { + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), }, ); }); }); describe('revokeShortCode', () => { - test('should return true on successful deletion of shortcode with valid inputs', async () => { - mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser); + test('should return true on successful deletion of Shortcode with valid inputs', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); const result = await shortcodeService.revokeShortCode( - shortCodeWithUser.id, - shortCodeWithUser.creatorUid, + mockEmbed.id, + mockEmbed.creatorUid, ); expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({ where: { creator_uid_shortcode_unique: { - creatorUid: shortCodeWithUser.creatorUid, - id: shortCodeWithUser.id, + creatorUid: mockEmbed.creatorUid, + id: mockEmbed.id, }, }, }); @@ -249,52 +309,53 @@ describe('ShortcodeService', () => { expect(result).toEqualRight(true); }); - test('should return SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => { + test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid and user uid is valid', async () => { mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); expect( shortcodeService.revokeShortCode('invalid', 'testuser'), ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); }); - test('should return SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => { + test('should return SHORTCODE_NOT_FOUND error when Shortcode is valid and user uid is invalid', async () => { mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); expect( shortcodeService.revokeShortCode('blablablabla', 'invalidUser'), ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); }); - test('should return SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => { + test('should return SHORTCODE_NOT_FOUND error when both Shortcode and user uid are invalid', async () => { mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); expect( shortcodeService.revokeShortCode('invalid', 'invalid'), ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); }); - test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of shortcode', async () => { - mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser); + test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of Shortcode', async () => { + mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed); const result = await shortcodeService.revokeShortCode( - shortCodeWithUser.id, - shortCodeWithUser.creatorUid, + mockEmbed.id, + mockEmbed.creatorUid, ); expect(mockPubSub.publish).toHaveBeenCalledWith( - `shortcode/${shortCodeWithUser.creatorUid}/revoked`, + `shortcode/${mockEmbed.creatorUid}/revoked`, { - id: shortCodeWithUser.id, - createdOn: shortCodeWithUser.createdOn, - request: JSON.stringify(shortCodeWithUser.request), + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify(mockEmbed.embedProperties), }, ); }); }); describe('deleteUserShortCodes', () => { - test('should successfully delete all users shortcodes with valid user uid', async () => { + test('should successfully delete all users Shortcodes with valid user uid', async () => { mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 }); const result = await shortcodeService.deleteUserShortCodes( - shortCodeWithUser.creatorUid, + mockEmbed.creatorUid, ); expect(result).toEqual(1); }); @@ -303,9 +364,81 @@ describe('ShortcodeService', () => { mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 }); const result = await shortcodeService.deleteUserShortCodes( - shortCodeWithUser.creatorUid, + mockEmbed.creatorUid, ); expect(result).toEqual(0); }); }); + + describe('updateShortcode', () => { + test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid', async () => { + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '', + ); + expect(result).toEqualLeft(SHORTCODE_PROPERTIES_NOT_FOUND); + }); + + test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid JSON format', async () => { + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '{kk', + ); + expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON); + }); + + test('should return SHORTCODE_NOT_FOUND error when Shortcode ID is invalid', async () => { + mockPrisma.shortcode.update.mockRejectedValue('RecordNotFound'); + const result = await shortcodeService.updateEmbedProperties( + 'invalidID', + user.uid, + '{}', + ); + expect(result).toEqualLeft(SHORTCODE_NOT_FOUND); + }); + + test('should successfully update a Shortcodes with valid inputs', async () => { + mockPrisma.shortcode.update.mockResolvedValueOnce({ + ...mockEmbed, + embedProperties: '{"foo":"bar"}', + }); + + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '{"foo":"bar"}', + ); + expect(result).toEqualRight({ + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify('{"foo":"bar"}'), + }); + }); + + test('should send pubsub message to `shortcode/{uid}/updated` on successful Update of Shortcode', async () => { + mockPrisma.shortcode.update.mockResolvedValueOnce({ + ...mockEmbed, + embedProperties: '{"foo":"bar"}', + }); + + const result = await shortcodeService.updateEmbedProperties( + mockEmbed.id, + user.uid, + '{"foo":"bar"}', + ); + + expect(mockPubSub.publish).toHaveBeenCalledWith( + `shortcode/${mockEmbed.creatorUid}/updated`, + { + id: mockEmbed.id, + createdOn: mockEmbed.createdOn, + request: JSON.stringify(mockEmbed.request), + properties: JSON.stringify('{"foo":"bar"}'), + }, + ); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts index 87fb5405..cb653723 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts @@ -1,10 +1,14 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import * as T from 'fp-ts/Task'; -import * as O from 'fp-ts/Option'; import * as TO from 'fp-ts/TaskOption'; import * as E from 'fp-ts/Either'; import { PrismaService } from 'src/prisma/prisma.service'; -import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND } from 'src/errors'; +import { + SHORTCODE_INVALID_PROPERTIES_JSON, + SHORTCODE_INVALID_REQUEST_JSON, + SHORTCODE_NOT_FOUND, + SHORTCODE_PROPERTIES_NOT_FOUND, +} from 'src/errors'; import { UserDataHandler } from 'src/user/user.data.handler'; import { Shortcode } from './shortcode.model'; import { Shortcode as DBShortCode } from '@prisma/client'; @@ -46,10 +50,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { * @param shortcodeInfo Prisma Shortcode type * @returns GQL Shortcode */ - private returnShortCode(shortcodeInfo: DBShortCode): Shortcode { + private cast(shortcodeInfo: DBShortCode): Shortcode { return { id: shortcodeInfo.id, request: JSON.stringify(shortcodeInfo.request), + properties: + shortcodeInfo.embedProperties != null + ? JSON.stringify(shortcodeInfo.embedProperties) + : null, createdOn: shortcodeInfo.createdOn, }; } @@ -94,7 +102,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({ where: { id: shortcode }, }); - return E.right(this.returnShortCode(shortcodeInfo)); + return E.right(this.cast(shortcodeInfo)); } catch (error) { return E.left(SHORTCODE_NOT_FOUND); } @@ -104,14 +112,22 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { * Create a new ShortCode * * @param request JSON string of request details - * @param userUID user UID, if present + * @param userInfo user UI + * @param properties JSON string of embed properties, if present * @returns Either of ShortCode or error */ - async createShortcode(request: string, userUID: string | null) { - const shortcodeData = stringToJson(request); - if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON); + async createShortcode( + request: string, + properties: string | null = null, + userInfo: AuthUser, + ) { + const requestData = stringToJson(request); + if (E.isLeft(requestData) || !requestData.right) + return E.left(SHORTCODE_INVALID_REQUEST_JSON); - const user = await this.userService.findUserById(userUID); + const parsedProperties = stringToJson(properties); + if (E.isLeft(parsedProperties)) + return E.left(SHORTCODE_INVALID_PROPERTIES_JSON); const generatedShortCode = await this.generateUniqueShortCodeID(); if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left); @@ -119,8 +135,9 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { const createdShortCode = await this.prisma.shortcode.create({ data: { id: generatedShortCode.right, - request: shortcodeData.right, - creatorUid: O.isNone(user) ? null : user.value.uid, + request: requestData.right, + embedProperties: parsedProperties.right ?? undefined, + creatorUid: userInfo.uid, }, }); @@ -128,11 +145,11 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { if (createdShortCode.creatorUid) { this.pubsub.publish( `shortcode/${createdShortCode.creatorUid}/created`, - this.returnShortCode(createdShortCode), + this.cast(createdShortCode), ); } - return E.right(this.returnShortCode(createdShortCode)); + return E.right(this.cast(createdShortCode)); } /** @@ -156,7 +173,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { }); const fetchedShortCodes: Shortcode[] = shortCodes.map((code) => - this.returnShortCode(code), + this.cast(code), ); return fetchedShortCodes; @@ -182,7 +199,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { this.pubsub.publish( `shortcode/${deletedShortCodes.creatorUid}/revoked`, - this.returnShortCode(deletedShortCodes), + this.cast(deletedShortCodes), ); return E.right(true); @@ -205,4 +222,45 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { return deletedShortCodes.count; } + + /** + * Update a created Shortcode + * @param shortcodeID Shortcode ID + * @param uid User Uid + * @returns Updated Shortcode + */ + async updateEmbedProperties( + shortcodeID: string, + uid: string, + updatedProps: string, + ) { + if (!updatedProps) return E.left(SHORTCODE_PROPERTIES_NOT_FOUND); + + const parsedProperties = stringToJson(updatedProps); + if (E.isLeft(parsedProperties) || !parsedProperties.right) + return E.left(SHORTCODE_INVALID_PROPERTIES_JSON); + + try { + const updatedShortcode = await this.prisma.shortcode.update({ + where: { + creator_uid_shortcode_unique: { + creatorUid: uid, + id: shortcodeID, + }, + }, + data: { + embedProperties: parsedProperties.right, + }, + }); + + this.pubsub.publish( + `shortcode/${updatedShortcode.creatorUid}/updated`, + this.cast(updatedShortcode), + ); + + return E.right(this.cast(updatedShortcode)); + } catch (error) { + return E.left(SHORTCODE_NOT_FOUND); + } + } }