Skip to content

Commit

Permalink
Merge pull request #616 from bandada-infra/feat/redeem-invite
Browse files Browse the repository at this point in the history
feat: redeem invite manually or with api key
  • Loading branch information
vplasencia authored Dec 5, 2024
2 parents 48c539a + 7d02593 commit 68df316
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 3 deletions.
15 changes: 15 additions & 0 deletions apps/api/src/app/invites/dto/redeem-invite.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 35 additions & 0 deletions apps/api/src/app/invites/invites.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Headers,
NotImplementedException,
Param,
Patch,
Post,
Req
} from "@nestjs/common"
Expand All @@ -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")
Expand Down Expand Up @@ -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<InviteResponse> {
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)
}
}
112 changes: 112 additions & 0 deletions apps/api/src/app/invites/invites.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
42 changes: 41 additions & 1 deletion apps/api/src/app/invites/invites.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,47 @@ 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
}

/**
* 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)
}

/**
Expand Down
13 changes: 13 additions & 0 deletions apps/docs/docs/api-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,19 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc"
const invite = await apiSdk.getInvite(inviteCode)
```

## Redeem invite

\# **redeemInvite**(): _Promise\<Invite>_

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, apiKey)

## Get credential group join URL

\# **getCredentialGroupJoinUrl**(): _string_
Expand Down
14 changes: 13 additions & 1 deletion libs/api-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,13 +467,25 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc"
const invite = await apiSdk.getInvite(inviteCode)
```

## Redeem invite

\# **redeemInvite**(): _Promise\<Invite>_

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, apiKey)

## Get credential group join URL

\# **getCredentialGroupJoinUrl**(): _string_

Returns a custom URL string for joining a credential group.

```ts
import { DashboardUrl } from "@bandada/api-sdk"

const dashboardUrl = DashboardUrl.DEV
Expand Down
24 changes: 23 additions & 1 deletion libs/api-sdk/src/apiSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Invite> {
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.
Expand Down
44 changes: 44 additions & 0 deletions libs/api-sdk/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading

0 comments on commit 68df316

Please sign in to comment.