From 0a39c4b1c32de30af583e829778b531e748e2af5 Mon Sep 17 00:00:00 2001 From: waddaboo <56923450+waddaboo@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:36:26 +0800 Subject: [PATCH 1/6] feat(api): add redeemInvite --- .../src/app/invites/dto/redeem-invite.dto.ts | 15 +++ .../api/src/app/invites/invites.controller.ts | 35 ++++++ .../src/app/invites/invites.service.test.ts | 112 ++++++++++++++++++ apps/api/src/app/invites/invites.service.ts | 36 ++++++ 4 files changed, 198 insertions(+) create mode 100644 apps/api/src/app/invites/dto/redeem-invite.dto.ts diff --git a/apps/api/src/app/invites/dto/redeem-invite.dto.ts b/apps/api/src/app/invites/dto/redeem-invite.dto.ts new file mode 100644 index 00000000..eebe6ba0 --- /dev/null +++ b/apps/api/src/app/invites/dto/redeem-invite.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger" +import { IsNumberString, IsString, Length } from "class-validator" + +export class RedeemInviteDto { + @IsString() + @Length(8) + @ApiProperty() + readonly inviteCode: string + + @IsString() + @Length(32) + @IsNumberString() + @ApiProperty() + readonly groupId: string +} diff --git a/apps/api/src/app/invites/invites.controller.ts b/apps/api/src/app/invites/invites.controller.ts index 65b883e0..41b0ed05 100644 --- a/apps/api/src/app/invites/invites.controller.ts +++ b/apps/api/src/app/invites/invites.controller.ts @@ -5,6 +5,7 @@ import { Headers, NotImplementedException, Param, + Patch, Post, Req } from "@nestjs/common" @@ -22,6 +23,7 @@ import { CreateInviteDto } from "./dto/create-invite.dto" import { Invite } from "./entities/invite.entity" import { InvitesService } from "./invites.service" import { mapEntity } from "../utils" +import { RedeemInviteDto } from "./dto/redeem-invite.dto" @ApiTags("invites") @Controller("invites") @@ -72,4 +74,37 @@ export class InvitesController { return mapEntity(invite) } + + @Patch("redeem") + @ApiBody({ type: RedeemInviteDto }) + @ApiHeader({ name: "x-api-key", required: true }) + @ApiOperation({ description: "Redeems a specific invite." }) + @ApiCreatedResponse({ type: InviteResponse }) + async redeemInvite( + @Headers() headers: Headers, + @Req() req: Request, + @Body() { inviteCode, groupId }: RedeemInviteDto + ): Promise { + let invite: Invite + + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + invite = await this.invitesService.redeemInviteWithApiKey( + inviteCode, + groupId, + apiKey + ) + } else if (req.session.adminId) { + invite = await this.invitesService.redeemInviteKeyManually( + inviteCode, + groupId, + req.session.adminId + ) + } else { + throw new NotImplementedException() + } + + return mapEntity(invite) + } } diff --git a/apps/api/src/app/invites/invites.service.test.ts b/apps/api/src/app/invites/invites.service.test.ts index b9513c82..848acaf2 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -396,6 +396,118 @@ describe("InvitesService", () => { }) }) + describe("# redeemInviteWithApiKey", () => { + it("Should redeem an invite with api key", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) + + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const { isRedeemed: redeemed } = + await invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + admin.apiKey + ) + + expect(redeemed).toBeTruthy() + }) + + it("Should not reddem an invite if the given api key is invalid", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const fun = invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + "wrong-apikey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the group '${groupId}'` + ) + }) + + it("Should not redeem an invite if the given api key does not belong to an admin", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const oldApiKey = admin.apiKey + + await adminsService.updateApiKey(admin.id, ApiKeyActions.Generate) + + const fun = invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + oldApiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the group '${groupId}'` + ) + }) + + it("Should not redeem an invite if the given api key is disabled", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + admin = await adminsService.findOne({ id: admin.id }) + + const fun = invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + admin.apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + }) + + describe("# redeemInviteKeyManually", () => { + it("Should redeem an invite manually", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const { isRedeemed: redeemed } = + await invitesService.redeemInviteKeyManually( + invite.code, + groupId, + admin.id + ) + + expect(redeemed).toBeTruthy() + }) + + it("Should not redeem an invite if the given identifier does not belong to an admin", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const fun = invitesService.redeemInviteKeyManually( + invite.code, + groupId, + "wrong-admin" + ) + + await expect(fun).rejects.toThrow("You are not an admin") + }) + }) + describe("# generateCode", () => { it("Should generate a random code with 8 characters", async () => { const result = (invitesService as any).generateCode() diff --git a/apps/api/src/app/invites/invites.service.ts b/apps/api/src/app/invites/invites.service.ts index 8f54ce86..6c8fe190 100644 --- a/apps/api/src/app/invites/invites.service.ts +++ b/apps/api/src/app/invites/invites.service.ts @@ -149,6 +149,42 @@ export class InvitesService { return this.inviteRepository.save(invite) } + /** + * Redeems an invite by using API Key. + * @param inviteCode Invite code to be redeemed. + * @param groupId Group id. + * @param apiKey The API Key. + * @returns The updated invite. + */ + async redeemInviteWithApiKey( + inviteCode: string, + groupId: string, + apiKey: string + ) { + await getAndCheckAdmin(this.adminsService, apiKey, groupId) + + return this.redeemInvite(inviteCode, groupId) + } + + /** + * Redeems an invite manually without using API Key. + * @param inviteCode Invite code to be redeemed. + * @param groupId Group id. + * @param adminId Group admin id. + * @returns The updated invite. + */ + async redeemInviteKeyManually( + inviteCode: string, + groupId: string, + adminId: string + ) { + const admin = await this.adminsService.findOne({ id: adminId }) + + if (!admin) throw new BadRequestException(`You are not an admin`) + + return this.redeemInvite(inviteCode, groupId) + } + /** * Generates a random code with a given number of characters. * The list of available characters have been chosen to be human readable. From bddd42fcabc3724999bc347bc35affe7ed255b44 Mon Sep 17 00:00:00 2001 From: waddaboo <56923450+waddaboo@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:37:27 +0800 Subject: [PATCH 2/6] feat(api-sdk): add redeemInvite --- libs/api-sdk/README.md | 13 ++++++++++ libs/api-sdk/src/apiSdk.ts | 24 ++++++++++++++++++- libs/api-sdk/src/index.test.ts | 44 ++++++++++++++++++++++++++++++++++ libs/api-sdk/src/invites.ts | 31 ++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/libs/api-sdk/README.md b/libs/api-sdk/README.md index 8a1d027f..76f54bb0 100644 --- a/libs/api-sdk/README.md +++ b/libs/api-sdk/README.md @@ -467,6 +467,19 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" const invite = await apiSdk.getInvite(inviteCode) ``` +## Redeem invite + +\# **redeemInvite**(): _Promise\_ + +Redeems a specific invite. + +```ts +const groupId = "10402173435763029700781503965100" +const inviteCode = "C5VAG4HD" + +const invite = await apiSdk.redeemInvite(inviteCode, groupId) +``` + ## Get credential group join URL \# **getCredentialGroupJoinUrl**(): _string_ diff --git a/libs/api-sdk/src/apiSdk.ts b/libs/api-sdk/src/apiSdk.ts index 4404d17a..822564dc 100644 --- a/libs/api-sdk/src/apiSdk.ts +++ b/libs/api-sdk/src/apiSdk.ts @@ -27,7 +27,7 @@ import { getCredentialGroupJoinUrl, getMultipleCredentialsGroupJoinUrl } from "./groups" -import { createInvite, getInvite } from "./invites" +import { createInvite, getInvite, redeemInvite } from "./invites" export default class ApiSdk { private _url: string @@ -367,6 +367,28 @@ export default class ApiSdk { return invite } + /** + * Redeems a specific invite. + * @param inviteCode Invite code. + * @param groupId Group id. + * @param apiKey The api key. + * @returns The updated invite. + */ + async redeemInvite( + inviteCode: string, + groupId: string, + apiKey: string + ): Promise { + const invite = await redeemInvite( + this._config, + inviteCode, + groupId, + apiKey + ) + + return invite + } + /** * Generate a custom url for joining a credential group. * @param dashboardUrl Dashboard base url. diff --git a/libs/api-sdk/src/index.test.ts b/libs/api-sdk/src/index.test.ts index d21ee2aa..6d3203d8 100644 --- a/libs/api-sdk/src/index.test.ts +++ b/libs/api-sdk/src/index.test.ts @@ -957,6 +957,50 @@ describe("Bandada API SDK", () => { expect(invite.group).toStrictEqual(group) }) }) + + describe("# redeemInvite", () => { + it("Should redeem an invite", async () => { + const groupId = "95633257675970239314311768035433" + const groupName = "Group 1" + const group = { + id: groupId, + name: groupName, + description: "This is Group 1", + type: "off-chain", + adminId: + "0x63229164c457584616006e31d1e171e6cdd4163695bc9c4bf0227095998ffa4c", + treeDepth: 16, + fingerprintDuration: 3600, + credentials: null, + apiEnabled: false, + apiKey: null, + createdAt: "2023-08-09T18:09:53.000Z", + updatedAt: "2023-08-09T18:09:53.000Z" + } + const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" + const inviteCode = "C5VAG4HD" + const inviteCreatedAt = "2023-08-09T18:10:02.000Z" + + requestMocked.mockImplementationOnce(() => + Promise.resolve({ + code: inviteCode, + isRedeemed: true, + createdAt: inviteCreatedAt, + group + }) + ) + + const apiSdk: ApiSdk = new ApiSdk(SupportedUrl.DEV) + const invite = await apiSdk.redeemInvite( + inviteCode, + groupId, + apiKey + ) + + expect(invite.code).toBe(inviteCode) + expect(invite.isRedeemed).toBe(true) + }) + }) }) describe("Check Parameter", () => { describe("Should not throw an error if the parameter has the expected type", () => { diff --git a/libs/api-sdk/src/invites.ts b/libs/api-sdk/src/invites.ts index df4af2b7..bc17e574 100644 --- a/libs/api-sdk/src/invites.ts +++ b/libs/api-sdk/src/invites.ts @@ -46,3 +46,34 @@ export async function createInvite( return req } + +/** + * Redeems a specific invite. + * @param inviteCode Invite code to be redeemed. + * @param groupId Group id. + * @param apiKey API Key of the admin. + * @returns The updated redeemed invite. + */ +export async function redeemInvite( + config: object, + inviteCode: string, + groupId: string, + apiKey: string +): Promise { + const requestUrl = `${url}/redeem` + + const newConfig: any = { + method: "patch", + data: { + inviteCode, + groupId + }, + ...config + } + + newConfig.headers["x-api-key"] = apiKey + + const req = await request(requestUrl, newConfig) + + return req +} From f44e856cc1c660498671c3b0d402535651ba0f8a Mon Sep 17 00:00:00 2001 From: waddaboo <56923450+waddaboo@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:40:02 +0800 Subject: [PATCH 3/6] docs: add redeemInvite docs --- apps/docs/docs/api-sdk.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/docs/docs/api-sdk.md b/apps/docs/docs/api-sdk.md index bd337f9a..652b0aba 100644 --- a/apps/docs/docs/api-sdk.md +++ b/apps/docs/docs/api-sdk.md @@ -444,6 +444,19 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" const invite = await apiSdk.getInvite(inviteCode) ``` +## Redeem invite + +\# **redeemInvite**(): _Promise\_ + +Redeems a specific invite. + +```ts +const groupId = "10402173435763029700781503965100" +const inviteCode = "C5VAG4HD" + +const invite = await apiSdk.redeemInvite(inviteCode, groupId) +``` + ## Get credential group join URL \# **getCredentialGroupJoinUrl**(): _string_ From b97c23da84d73aad398c27be57ecc50e3755a0ea Mon Sep 17 00:00:00 2001 From: Han <56923450+waddaboo@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:35:57 +0800 Subject: [PATCH 4/6] docs: update example to include apiKey Co-authored-by: Vivian Plasencia --- apps/docs/docs/api-sdk.md | 4 ++-- libs/api-sdk/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/docs/docs/api-sdk.md b/apps/docs/docs/api-sdk.md index 652b0aba..c95baa08 100644 --- a/apps/docs/docs/api-sdk.md +++ b/apps/docs/docs/api-sdk.md @@ -453,9 +453,9 @@ Redeems a specific invite. ```ts const groupId = "10402173435763029700781503965100" const inviteCode = "C5VAG4HD" +const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" -const invite = await apiSdk.redeemInvite(inviteCode, groupId) -``` +const invite = await apiSdk.redeemInvite(inviteCode, groupId, apiKey) ## Get credential group join URL diff --git a/libs/api-sdk/README.md b/libs/api-sdk/README.md index 76f54bb0..005e0fb2 100644 --- a/libs/api-sdk/README.md +++ b/libs/api-sdk/README.md @@ -476,9 +476,9 @@ Redeems a specific invite. ```ts const groupId = "10402173435763029700781503965100" const inviteCode = "C5VAG4HD" +const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" -const invite = await apiSdk.redeemInvite(inviteCode, groupId) -``` +const invite = await apiSdk.redeemInvite(inviteCode, groupId, apiKey) ## Get credential group join URL From a9281cd64ff2453322f1a5f5edf788d160f22c08 Mon Sep 17 00:00:00 2001 From: Han <56923450+waddaboo@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:37:08 +0800 Subject: [PATCH 5/6] feat(api): add log when redeem successful Co-authored-by: Vivian Plasencia --- apps/api/src/app/invites/invites.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/invites/invites.service.ts b/apps/api/src/app/invites/invites.service.ts index 6c8fe190..3a20f866 100644 --- a/apps/api/src/app/invites/invites.service.ts +++ b/apps/api/src/app/invites/invites.service.ts @@ -146,7 +146,11 @@ export class InvitesService { invite.isRedeemed = true - return this.inviteRepository.save(invite) + await this.inviteRepository.save(invite) + + Logger.log(`InvitesService: invite '${invite.code}' has been redeemed`) + + return invite } /** From 7d02593f1fbd73247875bddc42cf801fab44b0f7 Mon Sep 17 00:00:00 2001 From: Han <56923450+waddaboo@users.noreply.github.com> Date: Thu, 5 Dec 2024 22:50:56 +0800 Subject: [PATCH 6/6] fix: remove additional ` --- libs/api-sdk/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/api-sdk/README.md b/libs/api-sdk/README.md index 005e0fb2..f08f01da 100644 --- a/libs/api-sdk/README.md +++ b/libs/api-sdk/README.md @@ -486,7 +486,6 @@ const invite = await apiSdk.redeemInvite(inviteCode, groupId, apiKey) Returns a custom URL string for joining a credential group. -```ts import { DashboardUrl } from "@bandada/api-sdk" const dashboardUrl = DashboardUrl.DEV