diff --git a/apps/api/src/app/credentials/credentials.service.test.ts b/apps/api/src/app/credentials/credentials.service.test.ts index ae8d7775..d221aa82 100644 --- a/apps/api/src/app/credentials/credentials.service.test.ts +++ b/apps/api/src/app/credentials/credentials.service.test.ts @@ -83,6 +83,7 @@ describe("CredentialsService", () => { { name: "Group1", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ @@ -104,6 +105,7 @@ describe("CredentialsService", () => { { name: "Group2", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -184,8 +186,9 @@ describe("CredentialsService", () => { it("Should throw an error if the credential group blockchain network is not supported", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "Group2", + name: "Group3", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ @@ -230,8 +233,9 @@ describe("CredentialsService", () => { it("Should add the same credential with different identities in different groups", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "Group2", + name: "Group4", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ @@ -335,8 +339,9 @@ describe("CredentialsService", () => { it("Should add a member to a credential group using the number of transactions", async () => { const { id } = await groupsService.createGroup( { - name: "Group2", + name: "Group5", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ @@ -385,8 +390,9 @@ describe("CredentialsService", () => { it("Should add a member to a group with many credentials", async () => { const { id } = await groupsService.createGroup( { - name: "Group3", + name: "Group6", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ @@ -437,8 +443,9 @@ describe("CredentialsService", () => { it("Should not add a member to a group with many credentials and return undefined if the OAuth state does not match the credential provider", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "Group4", + name: "Group7", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ @@ -490,8 +497,9 @@ describe("CredentialsService", () => { it("Should throw an error if the group with many credentials contains unsupported network", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "Group5", + name: "Group8", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: JSON.stringify({ diff --git a/apps/api/src/app/groups/docSchemas/group.ts b/apps/api/src/app/groups/docSchemas/group.ts index bfca9425..1175c097 100644 --- a/apps/api/src/app/groups/docSchemas/group.ts +++ b/apps/api/src/app/groups/docSchemas/group.ts @@ -8,6 +8,8 @@ export class Group { @ApiProperty() description: string @ApiProperty() + type: string + @ApiProperty() admin: string @ApiProperty() treeDepth: number diff --git a/apps/api/src/app/groups/docSchemas/groupResponse.ts b/apps/api/src/app/groups/docSchemas/groupResponse.ts index 7f890bcd..62b48760 100644 --- a/apps/api/src/app/groups/docSchemas/groupResponse.ts +++ b/apps/api/src/app/groups/docSchemas/groupResponse.ts @@ -8,6 +8,8 @@ export class GroupResponse { @ApiProperty() description: string @ApiProperty() + type: string + @ApiProperty() adminId: string @ApiProperty() treeDepth: number diff --git a/apps/api/src/app/groups/dto/create-group.dto.ts b/apps/api/src/app/groups/dto/create-group.dto.ts index 2dc49185..1051a957 100644 --- a/apps/api/src/app/groups/dto/create-group.dto.ts +++ b/apps/api/src/app/groups/dto/create-group.dto.ts @@ -8,9 +8,11 @@ import { MinLength, NotContains, IsNumberString, - IsJSON + IsJSON, + IsEnum } from "class-validator" import { ApiProperty } from "@nestjs/swagger" +import { GroupType } from "../types" export class CreateGroupDto { @IsString() @@ -30,6 +32,12 @@ export class CreateGroupDto { @ApiProperty() readonly description: string + @IsEnum(["on-chain", "off-chain"]) + @ApiProperty({ + enum: ["on-chain", "off-chain"] + }) + readonly type: GroupType + @IsNumber() @Min(16, { message: "The tree depth must be between 16 and 32." }) @Max(32, { message: "The tree depth must be between 16 and 32." }) diff --git a/apps/api/src/app/groups/entities/group.entity.ts b/apps/api/src/app/groups/entities/group.entity.ts index 05cc20c9..1c56afdd 100644 --- a/apps/api/src/app/groups/entities/group.entity.ts +++ b/apps/api/src/app/groups/entities/group.entity.ts @@ -12,6 +12,7 @@ import { import { OAuthAccount } from "../../credentials/entities/credentials-account.entity" import { Member } from "./member.entity" import { Invite } from "../../invites/entities/invite.entity" +import { GroupType } from "../types" @Entity("groups") export class Group { @@ -25,6 +26,13 @@ export class Group { @Column() description: string + @Column({ + type: "simple-enum", + enum: ["on-chain", "off-chain"], + nullable: true + }) + type: GroupType + @Column({ name: "admin_id" }) adminId: string diff --git a/apps/api/src/app/groups/groups.controller.ts b/apps/api/src/app/groups/groups.controller.ts index bf1bc24e..214d46f4 100644 --- a/apps/api/src/app/groups/groups.controller.ts +++ b/apps/api/src/app/groups/groups.controller.ts @@ -31,6 +31,7 @@ import { UpdateGroupsDto } from "./dto/update-groups.dto" import { GroupsService } from "./groups.service" import { mapGroupToResponseDTO } from "./groups.utils" import { RemoveGroupsDto } from "./dto/remove-groups.dto" +import { GroupType } from "./types" @ApiTags("groups") @Controller("groups") @@ -40,13 +41,22 @@ export class GroupsController { @Get() @ApiQuery({ name: "adminId", required: false, type: String }) @ApiQuery({ name: "memberId", required: false, type: String }) + @ApiQuery({ name: "type", required: false, type: String }) + @ApiQuery({ name: "name", required: false, type: String }) @ApiOperation({ description: "Returns the list of groups." }) @ApiCreatedResponse({ type: Group, isArray: true }) async getGroups( @Query("adminId") adminId: string, - @Query("memberId") memberId: string + @Query("memberId") memberId: string, + @Query("type") type: GroupType, + @Query("name") name: string ) { - const groups = await this.groupsService.getGroups({ adminId, memberId }) + const groups = await this.groupsService.getGroups({ + adminId, + memberId, + type, + name + }) const groupIds = groups.map((group) => group.id) const fingerprints = await this.groupsService.getFingerprints(groupIds) diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index 95792863..d54ef84f 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -66,6 +66,7 @@ describe("GroupsService", () => { { name: "Group1", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -77,16 +78,19 @@ describe("GroupsService", () => { describe("# createGroup", () => { it("Should create a group", async () => { - const { treeDepth, members } = await groupsService.createGroup( - { - name: "Group2", - description: "This is a description", - treeDepth: 16, - fingerprintDuration: 3600 - }, - "admin" - ) + const { type, treeDepth, members } = + await groupsService.createGroup( + { + name: "Group2", + description: "This is a description", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, + "admin" + ) + expect(type).toBe("off-chain") expect(treeDepth).toBe(16) expect(members).toHaveLength(0) }) @@ -96,6 +100,7 @@ describe("GroupsService", () => { { name: "Group3", description: "This is a description", + type: "off-chain", treeDepth: 15, fingerprintDuration: 3600 }, @@ -106,6 +111,39 @@ describe("GroupsService", () => { "The tree depth must be between 16 and 32." ) }) + + it("Should not create a group if the group name already exist", async () => { + const fun = groupsService.createGroup( + { + name: "Group1", + description: "This is a description", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, + "admin" + ) + + await expect(fun).rejects.toThrow("already exists") + }) + + it("Should create a group with same name but different admin", async () => { + const { name, treeDepth, members } = + await groupsService.createGroup( + { + name: "Group1", + description: "This is a description", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, + "different-admin" + ) + + expect(name).toBe("Group1") + expect(treeDepth).toBe(16) + expect(members).toHaveLength(0) + }) }) describe("# removeGroup", () => { @@ -114,6 +152,7 @@ describe("GroupsService", () => { { name: "Group3", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -142,6 +181,7 @@ describe("GroupsService", () => { { name: "Group4", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -208,7 +248,7 @@ describe("GroupsService", () => { it("Should return a list of groups", async () => { const result = await groupsService.getGroups() - expect(result).toHaveLength(3) + expect(result).toHaveLength(4) }) it("Should return a list of groups by admin", async () => { @@ -216,6 +256,7 @@ describe("GroupsService", () => { { name: "Group01", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -227,6 +268,7 @@ describe("GroupsService", () => { { name: "Group02", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -243,8 +285,9 @@ describe("GroupsService", () => { it("Should return a list of groups by member", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "MemberGroup", + name: "GetGroupsByMember", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -266,6 +309,33 @@ describe("GroupsService", () => { expect(result).toHaveLength(1) }) + + it("Should return a list of groups by group type", async () => { + await groupsService.createGroup( + { + name: "OnChainGroup", + description: "This is a description", + type: "on-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, + "admin02" + ) + + const result = await groupsService.getGroups({ + type: "on-chain" + }) + + expect(result).toHaveLength(1) + }) + + it("Should return a list of groups by group name", async () => { + const result = await groupsService.getGroups({ + name: "OnChainGroup" + }) + + expect(result).toHaveLength(1) + }) }) describe("# getGroup", () => { @@ -335,6 +405,7 @@ describe("GroupsService", () => { { name: "MemberGroup", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -396,8 +467,9 @@ describe("GroupsService", () => { it("Should remove a member if they exist in the group", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "Group2", + name: "RemoveMemberManuallyGroup2", description: "This is a new group", + type: "off-chain", treeDepth: 21, fingerprintDuration: 3600 }, @@ -440,8 +512,9 @@ describe("GroupsService", () => { it("Should throw error if member is removed by a non-admin", async () => { const { id: _groupId } = await groupsService.createGroup( { - name: "Group2", + name: "RemoveMemberGroup2", description: "This is a new group", + type: "off-chain", treeDepth: 21, fingerprintDuration: 3600 }, @@ -471,6 +544,7 @@ describe("GroupsService", () => { const groupDto: CreateGroupDto = { name: "Group", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 } @@ -493,13 +567,19 @@ describe("GroupsService", () => { it("Should create a group via API", async () => { const group = await groupsService.createGroupWithAPIKey( - groupDto, + { + name: "CreateApiGroup1", + description: "This is a new group", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, apiKey ) expect(group.adminId).toBe(admin.id) expect(group.description).toBe(groupDto.description) - expect(group.name).toBe(groupDto.name) + expect(group.name).toBe("CreateApiGroup1") expect(group.treeDepth).toBe(groupDto.treeDepth) expect(group.fingerprintDuration).toBe(groupDto.fingerprintDuration) expect(group.members).toHaveLength(0) @@ -508,7 +588,13 @@ describe("GroupsService", () => { it("Should remove a group via API", async () => { const group = await groupsService.createGroupWithAPIKey( - groupDto, + { + name: "CreateApiGroup2", + description: "This is a new group", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, apiKey ) @@ -531,7 +617,13 @@ describe("GroupsService", () => { it("Should not remove a group if the admin does not exist", async () => { const group = await groupsService.createGroupWithAPIKey( - groupDto, + { + name: "CreateApiGroup3", + description: "This is a new group", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, apiKey ) @@ -552,7 +644,13 @@ describe("GroupsService", () => { it("Should not remove a group if the API key is invalid", async () => { const group = await groupsService.createGroupWithAPIKey( - groupDto, + { + name: "CreateApiGroup4", + description: "This is a new group", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, apiKey ) @@ -577,7 +675,13 @@ describe("GroupsService", () => { await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) const group = await groupsService.createGroupWithAPIKey( - groupDto, + { + name: "CreateApiGroup5", + description: "This is a new group", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, apiKey ) @@ -594,7 +698,13 @@ describe("GroupsService", () => { await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) const group = await groupsService.createGroupWithAPIKey( - groupDto, + { + name: "CreateApiGroup6", + description: "This is a new group", + type: "off-chain", + treeDepth: 16, + fingerprintDuration: 3600 + }, apiKey ) @@ -625,22 +735,25 @@ describe("GroupsService", () => { const groupsDtos: Array = [ { id: "1", - name: "Group1", + name: "RemoveGroup1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, { id: "2", - name: "Group2", + name: "RemoveGroup2", description: "This is a new group2", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, { id: "3", - name: "Group3", + name: "RemoveGroup3", description: "This is a new group3", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 } @@ -688,7 +801,7 @@ describe("GroupsService", () => { adminId: admin.id }) - expect(groups).toHaveLength(4) + expect(groups).toHaveLength(8) await groupsService.removeGroupsWithAPIKey([ids[0], ids[1]], apiKey) @@ -696,14 +809,11 @@ describe("GroupsService", () => { adminId: admin.id }) - expect(groups).toHaveLength(2) + expect(groups).toHaveLength(6) const group = groups.at(1) const groupDto = groupsDtos.at(2) - expect(group.id).toBe(groupDto.id) expect(group.adminId).toBe(admin.id) - expect(group.description).toBe(groupDto.description) - expect(group.name).toBe(groupDto.name) expect(group.treeDepth).toBe(groupDto.treeDepth) expect(group.fingerprintDuration).toBe(groupDto.fingerprintDuration) expect(group.members).toHaveLength(0) @@ -799,8 +909,9 @@ describe("GroupsService", () => { describe("# Update group via API", () => { const groupDto: CreateGroupDto = { id: "1", - name: "Group1", + name: "UpdateGroupApi1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 } @@ -902,15 +1013,17 @@ describe("GroupsService", () => { const groupsDtos: Array = [ { id: "1", - name: "Group1", + name: "UpdateGroup1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, { id: "2", - name: "Group2", + name: "UpdateGroup2", description: "This is a new group2", + type: "off-chain", treeDepth: 32, fingerprintDuration: 7200 } @@ -1042,8 +1155,9 @@ describe("GroupsService", () => { group = await groupsService.createGroup( { - name: "Group2", + name: "AddRemoveGroup2", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -1192,8 +1306,9 @@ describe("GroupsService", () => { group = await groupsService.createGroup( { - name: "Group2", + name: "AddRemoveApiGroup2", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -1218,6 +1333,7 @@ describe("GroupsService", () => { { name: "Credential Group", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -1364,8 +1480,9 @@ describe("GroupsService", () => { beforeAll(async () => { group = await groupsService.createGroup( { - name: "Group2", + name: "AddMemberManuallyGroup2", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -1412,8 +1529,9 @@ describe("GroupsService", () => { beforeAll(async () => { group = await groupsService.createGroup( { - name: "Group2", + name: "AddMembersManuallyGroup2", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -1441,6 +1559,7 @@ describe("GroupsService", () => { { name: "Credential Group", description: "This is a new group", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -1490,8 +1609,9 @@ describe("GroupsService", () => { describe("# createGroupManually", () => { const groupDto: CreateGroupDto = { id: "1", - name: "Group1", + name: "CreateGroupManually1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 } @@ -1532,15 +1652,17 @@ describe("GroupsService", () => { const groupsDtos: Array = [ { id: "1", - name: "Group1", + name: "CreateGroup1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, { id: "2", - name: "Group2", + name: "CreateGroup2", description: "This is a new group2", + type: "off-chain", treeDepth: 32, fingerprintDuration: 7200 } @@ -1585,8 +1707,9 @@ describe("GroupsService", () => { describe("# removeGroupManually", () => { const groupDto: CreateGroupDto = { id: "1", - name: "Group1", + name: "RemoveGroup1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 } @@ -1637,15 +1760,17 @@ describe("GroupsService", () => { const groupsDtos: Array = [ { id: "1", - name: "Group1", + name: "RemoveGroupManually1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, { id: "2", - name: "Group2", + name: "RemoveGroupManually2", description: "This is a new group2", + type: "off-chain", treeDepth: 32, fingerprintDuration: 7200 } @@ -1720,8 +1845,9 @@ describe("GroupsService", () => { describe("# updateGroupManually", () => { const groupDto: CreateGroupDto = { id: "1", - name: "Group1", + name: "UpdateGroupManually1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 } @@ -1792,15 +1918,17 @@ describe("GroupsService", () => { const groupsDtos: Array = [ { id: "1", - name: "Group1", + name: "UpdateGroupsManually1", description: "This is a new group1", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, { id: "2", - name: "Group2", + name: "UpdateGroupsManually2", description: "This is a new group2", + type: "off-chain", treeDepth: 32, fingerprintDuration: 7200 } @@ -1895,6 +2023,7 @@ describe("GroupsService", () => { { name: "Fingerprint Group", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index c0c7eae2..f7e8dd45 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -18,7 +18,7 @@ import { CreateGroupDto } from "./dto/create-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto" import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" -import { MerkleProof } from "./types" +import { GroupType, MerkleProof } from "./types" import { getAndCheckAdmin } from "../utils" @Injectable() @@ -149,6 +149,7 @@ export class GroupsService { id: groupId, name, description, + type, treeDepth, fingerprintDuration, credentials @@ -169,10 +170,19 @@ export class GroupsService { ) } + const uniqueName = await this.groupRepository.findOne({ + where: { name, adminId } + }) + + if (uniqueName) { + throw new BadRequestException(`Group name '${name}' already exists`) + } + const group = this.groupRepository.create({ id: _groupId, name, description, + type, treeDepth, fingerprintDuration, credentials, @@ -820,6 +830,8 @@ export class GroupsService { async getGroups(filters?: { adminId?: string memberId?: string + type?: GroupType + name?: string }): Promise { let where = {} @@ -838,6 +850,20 @@ export class GroupsService { } } + if (filters?.type) { + where = { + type: filters.type, + ...where + } + } + + if (filters?.name) { + where = { + name: filters.name, + ...where + } + } + return this.groupRepository.find({ relations: { members: true }, where, diff --git a/apps/api/src/app/groups/groups.utils.ts b/apps/api/src/app/groups/groups.utils.ts index 5c56e519..a6f1728c 100644 --- a/apps/api/src/app/groups/groups.utils.ts +++ b/apps/api/src/app/groups/groups.utils.ts @@ -5,6 +5,7 @@ export function mapGroupToResponseDTO(group: Group, fingerprint: string = "") { id: group.id, name: group.name, description: group.description, + type: group.type, admin: group.adminId, treeDepth: group.treeDepth, fingerprint, diff --git a/apps/api/src/app/groups/types/index.ts b/apps/api/src/app/groups/types/index.ts index 478ae130..8846571e 100644 --- a/apps/api/src/app/groups/types/index.ts +++ b/apps/api/src/app/groups/types/index.ts @@ -4,3 +4,5 @@ export type MerkleProof = { siblings: any[] pathIndices: number[] } + +export type GroupType = "on-chain" | "off-chain" 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..fc63c39d 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,47 @@ export class InvitesController { return mapEntity(invite) } + + @Get("check/:code/group/:groupId") + @ApiOperation({ description: "Checks if a specific invite is valid." }) + @ApiCreatedResponse({ type: Boolean }) + async checkInvite( + @Param("code") inviteCode: string, + @Param("groupId") groupId: string + ): Promise { + return this.invitesService.checkInvite(inviteCode, groupId) + } + + @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..41ade654 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -71,6 +71,7 @@ describe("InvitesService", () => { { name: "Group1", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600 }, @@ -104,6 +105,7 @@ describe("InvitesService", () => { { name: "Group2", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -157,8 +159,9 @@ describe("InvitesService", () => { await groupsService.createGroup( { - name: "Group2", + name: "Group3", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -187,8 +190,9 @@ describe("InvitesService", () => { const group = await groupsService.createGroup( { - name: "Group3", + name: "Group4", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -213,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, @@ -281,8 +285,9 @@ describe("InvitesService", () => { await groupsService.createGroup( { - name: "Group2", + name: "Group5", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -315,8 +320,9 @@ describe("InvitesService", () => { const group = await groupsService.createGroup( { - name: "Group3", + name: "Group6", description: "This is a description", + type: "off-chain", treeDepth: 16, fingerprintDuration: 3600, credentials: { @@ -361,6 +367,25 @@ describe("InvitesService", () => { }) }) + describe("# checkInvite", () => { + it("Should return true if invite is valid", async () => { + const { code } = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const invite = await invitesService.checkInvite(code, groupId) + + expect(invite).toBeTruthy() + }) + + it("Should return false if invite is invalid", async () => { + const invite = await invitesService.checkInvite("12345", groupId) + + expect(invite).toBeFalsy() + }) + }) + describe("# redeemInvite", () => { let invite: Invite @@ -396,6 +421,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..52516b1e 100644 --- a/apps/api/src/app/invites/invites.service.ts +++ b/apps/api/src/app/invites/invites.service.ts @@ -117,10 +117,29 @@ export class InvitesService { return invite } + /** + * Returns boolean value if the invite code is valid. + * @param inviteCode Invite code. + * @param groupId Group id. + * @returns Boolean. + */ + async checkInvite(inviteCode: string, groupId: string): Promise { + const invite = await this.inviteRepository.findOne({ + where: { + code: inviteCode, + group: { id: groupId } + }, + relations: ["group"] + }) + + return !!invite + } + /** * 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 { @@ -149,6 +168,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. diff --git a/apps/client/.env.local b/apps/client/.env.local index e0a83db6..f850925f 100644 --- a/apps/client/.env.local +++ b/apps/client/.env.local @@ -2,3 +2,4 @@ VITE_API_URL=http://localhost:3000 VITE_DASHBOARD_URL=http://localhost:3001 +VITE_ETHEREUM_NETWORK=sepolia diff --git a/apps/client/.env.production b/apps/client/.env.production index b5d93270..cd787a15 100644 --- a/apps/client/.env.production +++ b/apps/client/.env.production @@ -2,3 +2,4 @@ VITE_API_URL=https://api.bandada.pse.dev VITE_DASHBOARD_URL=https://app.bandada.pse.dev +VITE_ETHEREUM_NETWORK=sepolia diff --git a/apps/client/.env.staging b/apps/client/.env.staging index 4a85c4db..eea50bec 100644 --- a/apps/client/.env.staging +++ b/apps/client/.env.staging @@ -2,3 +2,4 @@ VITE_API_URL=https://api-staging.bandada.pse.dev VITE_DASHBOARD_URL=https://app-staging.bandada.pse.dev +VITE_ETHEREUM_NETWORK=sepolia diff --git a/apps/client/package.json b/apps/client/package.json index 883ce052..fadbe8b8 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,7 @@ "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@fontsource-variable/unbounded": "^5.0.5", + "@semaphore-protocol/data": "4.0.3", "@semaphore-protocol/identity": "3.10.1", "@web3-react/core": "^6.1.9", "@web3-react/injected-connector": "^6.0.7", diff --git a/apps/client/src/utils/api.ts b/apps/client/src/api/bandadaAPI.ts similarity index 61% rename from apps/client/src/utils/api.ts rename to apps/client/src/api/bandadaAPI.ts index 49aba4d2..c0d5153b 100644 --- a/apps/client/src/utils/api.ts +++ b/apps/client/src/api/bandadaAPI.ts @@ -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 { try { @@ -72,3 +74,48 @@ export async function addMemberByInviteCode( return null } } + +export async function checkInvite( + inviteCode: string, + groupId: string +): Promise { + try { + return await request( + `${API_URL}/invites/check/${inviteCode}/group/${groupId}` + ) + } catch (error: any) { + console.error(error) + + if (error.response) { + alert(error.response.statusText) + } else { + alert("Some error occurred!") + } + + return false + } +} + +export async function redeemInvite( + inviteCode: string, + groupId: string +): Promise { + 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 + } +} diff --git a/apps/client/src/api/semaphoreAPI.ts b/apps/client/src/api/semaphoreAPI.ts new file mode 100644 index 00000000..460f7298 --- /dev/null +++ b/apps/client/src/api/semaphoreAPI.ts @@ -0,0 +1,32 @@ +import { SemaphoreSubgraph } from "@semaphore-protocol/data" + +const ETHEREUM_NETWORK = import.meta.env.VITE_ETHEREUM_NETWORK + +const subgraph = new SemaphoreSubgraph(ETHEREUM_NETWORK) + +/** + * It returns details of a specific on-chain group. + * @param groupId The group id. + * @returns The group details. + */ +export async function getGroup(groupId: string) { + try { + const group = await subgraph.getGroup(groupId, { + members: true + }) + + return { + id: group.id, + name: group.id, + treeDepth: group.merkleTree.depth, + fingerprintDuration: 3600, + members: group.members as string[], + admin: group.admin as string, + type: "on-chain" + } + } catch (error) { + console.error(error) + + return null + } +} diff --git a/apps/client/src/pages/home.tsx b/apps/client/src/pages/home.tsx index 2dadf86b..654af2f4 100644 --- a/apps/client/src/pages/home.tsx +++ b/apps/client/src/pages/home.tsx @@ -17,13 +17,10 @@ import { providers } from "ethers" import { useCallback, useEffect, useState } from "react" import { FiGithub } from "react-icons/fi" import { useSearchParams } from "react-router-dom" +import { getSemaphoreContract } from "@bandada/utils" import icon1Image from "../assets/icon1.svg" -import { - addMemberByInviteCode, - getGroup, - getInvite, - isGroupMember -} from "../utils/api" +import * as bandadaAPI from "../api/bandadaAPI" +import * as semaphoreAPI from "../api/semaphoreAPI" const injectedConnector = new InjectedConnector({}) @@ -59,44 +56,89 @@ export default function HomePage(): JSX.Element { if (account && library) { setLoading(true) - const invite = await getInvite(inviteCode) + const invite = await bandadaAPI.getInvite(inviteCode) if (invite === null) { setLoading(false) return } + const isValid = await bandadaAPI.checkInvite( + inviteCode, + invite.group.id + ) + + if (!isValid) { + setLoading(false) + alert("Invalid invite code") + return + } + const signer = library.getSigner(account) const message = `Sign this message to generate your Semaphore identity.` const identity = new Identity(await signer.signMessage(message)) const identityCommitment = identity.getCommitment().toString() - const hasJoined = await isGroupMember( - invite.group.id, - identityCommitment - ) + if (invite.group.type === "off-chain") { + const hasJoined = await bandadaAPI.isGroupMember( + invite.group.id, + identityCommitment + ) - if (hasJoined === null) { - setLoading(false) - return - } + if (hasJoined === null) { + setLoading(false) + return + } - if (hasJoined) { - setLoading(false) - alert("You have already joined this group") - return - } + if (hasJoined) { + setLoading(false) + alert("You have already joined this group") + return + } - const response = await addMemberByInviteCode( - invite.group.id, - identityCommitment, - inviteCode - ) + const response = await bandadaAPI.addMemberByInviteCode( + invite.group.id, + identityCommitment, + inviteCode + ) - if (response === null) { - setLoading(false) - return + if (response === null) { + setLoading(false) + return + } + } else { + const group = await semaphoreAPI.getGroup(invite.group.name) + + if (group === null) { + setLoading(false) + alert("Invalid group") + return + } + + if (group.members.includes(identityCommitment)) { + setLoading(false) + alert("You have already joined this group") + return + } + + try { + const semaphore = getSemaphoreContract( + "sepolia", + signer + ) + + 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" + ) + return + } } setInviteCode("") @@ -112,7 +154,7 @@ export default function HomePage(): JSX.Element { if (account && library) { setLoading(true) - const group = await getGroup(groupId) + const group = await bandadaAPI.getGroup(groupId) if (group === null || group.credentials === null) { setLoading(false) diff --git a/apps/dashboard/src/api/bandadaAPI.ts b/apps/dashboard/src/api/bandadaAPI.ts index 8d477445..22ab216a 100644 --- a/apps/dashboard/src/api/bandadaAPI.ts +++ b/apps/dashboard/src/api/bandadaAPI.ts @@ -1,6 +1,6 @@ import { ApiKeyActions, request } from "@bandada/utils" import { SiweMessage } from "siwe" -import { Admin, Group } from "../types" +import { Admin, Group, GroupType } from "../types" import createAlert from "../utils/createAlert" const API_URL = import.meta.env.VITE_API_URL @@ -40,17 +40,23 @@ export async function generateMagicLink( * @param adminId The admin id. * @returns The list of groups or null. */ -export async function getGroups(adminId?: string): Promise { +export async function getGroups( + adminId?: string, + type?: GroupType +): Promise { try { - const url = adminId - ? `${API_URL}/groups/?adminId=${adminId}` - : `${API_URL}/groups/` + const groupType = type || "off-chain" + let url = `${API_URL}/groups/?type=${groupType}` + + if (adminId) { + url += `&?adminId=${adminId}` + } const groups = await request(url) return groups.map((group: Group) => ({ ...group, - type: "off-chain" + type: groupType })) } catch (error: any) { console.error(error) @@ -59,6 +65,28 @@ export async function getGroups(adminId?: string): Promise { } } +/** + * It returns the list of groups by group name. + * @param name + * @returns The group details. + */ +export async function getGroupByName( + name: string, + type?: GroupType +): Promise { + try { + const groupType = type || "off-chain" + + return await request( + `${API_URL}/groups/?name=${name}&?type=${groupType}` + ) + } catch (error: any) { + console.error(error) + createAlert(error.response.data.message) + return null + } +} + /** * It returns details of a specific group. * @param groupId The group id. @@ -80,12 +108,16 @@ export async function getGroup(groupId: string): Promise { * It creates a new group. * @param name The group name. * @param description The group description. + * @param type The group type ("on-chain" | "off-chain"). * @param treeDepth The Merkle tree depth. + * @param fingerprintDuration The fingerprint duration. + * @param credentials The group credentials. * @returns The group details. */ export async function createGroup( name: string, description: string, + type: GroupType, treeDepth: number, fingerprintDuration: number, credentials?: any @@ -97,6 +129,7 @@ export async function createGroup( { name, description, + type, treeDepth, fingerprintDuration, credentials: JSON.stringify(credentials) @@ -104,7 +137,7 @@ export async function createGroup( ] }) - return { ...groups.at(0), type: "off-chain" } + return { ...groups.at(0) } } catch (error: any) { console.error(error) createAlert(error.response.data.message) diff --git a/apps/dashboard/src/components/add-member-modal.tsx b/apps/dashboard/src/components/add-member-modal.tsx index 6fd5ed9a..dbdb4fdd 100644 --- a/apps/dashboard/src/components/add-member-modal.tsx +++ b/apps/dashboard/src/components/add-member-modal.tsx @@ -147,7 +147,37 @@ ${memberIds.join("\n")} }, [onClose, _memberIds, group, signer]) const generateInviteLink = useCallback(async () => { - const inviteLink = await bandadaAPI.generateMagicLink(group.id) + let inviteLink = null + + if (group.type === "off-chain") { + inviteLink = await bandadaAPI.generateMagicLink(group.id) + } else { + const associatedGroup = await bandadaAPI.getGroupByName( + group.name, + "on-chain" + ) + + if (associatedGroup && associatedGroup.length > 0) { + inviteLink = await bandadaAPI.generateMagicLink( + associatedGroup[0].id + ) + } else { + const description = `This group is associated to the on-chain group ${group.name}` + const newAssociatedGroup = await bandadaAPI.createGroup( + group.name, + description, + "on-chain", + 16, + 3600 + ) + + if (newAssociatedGroup) { + inviteLink = await bandadaAPI.generateMagicLink( + newAssociatedGroup.id + ) + } + } + } if (inviteLink === null) { return @@ -212,63 +242,59 @@ ${memberIds.join("\n")} )} - {group.type === "off-chain" && ( - - - {!group.credentials - ? "Share invite link" - : "Share access link"} - - - - - - - - e.preventDefault() - } - icon={ - - } - /> - - - - - {!group.credentials && ( - - )} - - )} + e.preventDefault()} + icon={ + + } + /> + + + + + {!group.credentials && ( + + )} +