Skip to content

Commit

Permalink
Merge pull request #595 from bandada-infra/feat/api-sdk/redeem-invite
Browse files Browse the repository at this point in the history
feat: redeemInvite API SDK
  • Loading branch information
vplasencia authored Nov 1, 2024
2 parents 3a2f330 + 6cd256f commit 0f3a763
Show file tree
Hide file tree
Showing 10 changed files with 334 additions and 2 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)
}
}
114 changes: 113 additions & 1 deletion apps/api/src/app/invites/invites.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe("InvitesService", () => {
})

describe("# createInviteWithApiKey", () => {
it("Should create an invite manually", async () => {
it("Should create an invite with api key", async () => {
const {
group,
code,
Expand Down Expand Up @@ -402,6 +402,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
37 changes: 37 additions & 0 deletions apps/api/src/app/invites/invites.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export class InvitesService {
* Redeems an invite by consuming its code. Every invite
* can be used only once.
* @param inviteCode Invite code to be redeemed.
* @param groupId Group id.
* @returns The updated invite.
*/
async redeemInvite(inviteCode: string, groupId: string): Promise<Invite> {
Expand Down Expand Up @@ -149,6 +150,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.
Expand Down
26 changes: 26 additions & 0 deletions apps/client/src/api/bandadaAPI.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ApiSdk, Group, Invite } from "@bandada/api-sdk"
import { request } from "@bandada/utils"

const api = new ApiSdk(import.meta.env.VITE_API_URL)
const API_URL = import.meta.env.VITE_API_URL

export async function getInvite(inviteCode: string): Promise<Invite | null> {
try {
Expand Down Expand Up @@ -72,3 +74,27 @@ export async function addMemberByInviteCode(
return null
}
}

export async function redeemInvite(
inviteCode: string,
groupId: string
): Promise<Invite | null> {
try {
return await request(
`${API_URL}/invites/redeem/${inviteCode}/group/${groupId}`,
{
method: "post"
}
)
} catch (error: any) {
console.error(error)

if (error.response) {
alert(error.response.statusText)
} else {
alert("Some error occurred!")
}

return null
}
}
4 changes: 4 additions & 0 deletions apps/client/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ export default function HomePage(): JSX.Element {
)

await semaphore.addMember(group.id, identityCommitment)
await bandadaAPI.redeemInvite(
inviteCode,
invite.group.id
)
} catch (error) {
alert(
"Some error occurred! Check if you're on Sepolia network and the transaction is signed and completed"
Expand Down
13 changes: 13 additions & 0 deletions libs/api-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,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 invite = await apiSdk.redeemInvite(inviteCode, groupId)
```

## Get credential group join URL

\# **getCredentialGroupJoinUrl**(): _string_
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 @@ -31,7 +31,7 @@ import {
getGroupsByType,
createAssociatedGroup
} from "./groups"
import { createInvite, getInvite } from "./invites"
import { createInvite, getInvite, redeemInvite } from "./invites"

export default class ApiSdk {
private _url: string
Expand Down Expand Up @@ -419,6 +419,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
Loading

0 comments on commit 0f3a763

Please sign in to comment.