diff --git a/apps/api/src/app/groups/dto/create-union-group.dto.ts b/apps/api/src/app/groups/dto/create-union-group.dto.ts new file mode 100644 index 00000000..2fcc2919 --- /dev/null +++ b/apps/api/src/app/groups/dto/create-union-group.dto.ts @@ -0,0 +1,40 @@ +import { + ArrayNotEmpty, + IsArray, + IsNumber, + IsString, + Length, + Max, + Min, + MinLength, + NotContains +} from "class-validator" +import { ApiProperty } from "@nestjs/swagger" + +export class CreateUnionGroupDto { + @IsString() + @Length(1, 50) + @NotContains("admin-groups") + @ApiProperty() + readonly name: string + + @IsString() + @MinLength(10) + @ApiProperty() + readonly description: string + + @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." }) + @ApiProperty() + readonly treeDepth: number + + @IsNumber() + @Min(0) + @ApiProperty() + readonly fingerprintDuration: number + @IsArray() + @ArrayNotEmpty() + @ApiProperty() + readonly groupIds: string[] +} diff --git a/apps/api/src/app/groups/groups.controller.ts b/apps/api/src/app/groups/groups.controller.ts index bf1bc24e..0343571c 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 { CreateUnionGroupDto } from "./dto/create-union-group.dto" @ApiTags("groups") @Controller("groups") @@ -108,6 +109,40 @@ export class GroupsController { return groupsToResponseDTO } + @Post("union") + @ApiBody({ type: CreateUnionGroupDto }) + @ApiHeader({ name: "x-api-key", required: true }) + @ApiCreatedResponse({ type: Group }) + @ApiOperation({ + description: "Create a union group using an API Key or a valid session." + }) + async createUnionGroup( + @Body() dto: CreateUnionGroupDto, + @Headers() headers: Headers, + @Req() req: Request + ) { + let group: any + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + group = await this.groupsService.createUnionGroupWithApiKey( + dto, + apiKey + ) + } else if (req.session.adminId) { + group = await this.groupsService.createUnionGroupManually( + dto, + req.session.adminId + ) + } else { + throw new NotImplementedException() + } + + const fingerprint = await this.groupsService.getFingerprint(group.id) + + return mapGroupToResponseDTO(group, fingerprint) + } + @Delete() @ApiBody({ type: RemoveGroupsDto }) @ApiHeader({ name: "x-api-key", required: true }) diff --git a/apps/api/src/app/groups/groups.service.test.ts b/apps/api/src/app/groups/groups.service.test.ts index 95792863..3f037bfc 100644 --- a/apps/api/src/app/groups/groups.service.test.ts +++ b/apps/api/src/app/groups/groups.service.test.ts @@ -13,6 +13,7 @@ import { AdminsModule } from "../admins/admins.module" import { Admin } from "../admins/entities/admin.entity" import { CreateGroupDto } from "./dto/create-group.dto" import { UpdateGroupDto } from "./dto/update-group.dto" +import { CreateUnionGroupDto } from "./dto/create-union-group.dto" jest.mock("@bandada/utils", () => { const originalModule = jest.requireActual("@bandada/utils") @@ -796,6 +797,174 @@ describe("GroupsService", () => { }) }) + describe("# Create union group via API", () => { + const unionGroupDto: CreateUnionGroupDto = { + name: "Union Group API 1", + description: "This is a new union group", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds: [] + } + let admin: Admin + let apiKey: string + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + + apiKey = await adminsService.updateApiKey( + admin.id, + ApiKeyActions.Generate + ) + + admin = await adminsService.findOne({ id: admin.id }) + }) + + it("Should create a union group via API", async () => { + const groupsDto: Array = [ + { + name: "Ref Group API 1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600 + }, + { + name: "Ref Group API 2", + description: "This is a new group2", + treeDepth: 16, + fingerprintDuration: 3600 + } + ] + + const groupIds = [] + + const groupDto: CreateUnionGroupDto = { + name: "Union Group API 1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds + } + + const groups = await groupsService.createGroupsWithAPIKey( + groupsDto, + apiKey + ) + + for await (const [i, group] of groups.entries()) { + await groupsService.addMemberManually( + group.id, + `${i}`, + admin.id + ) + + groupIds.push(group.id) + } + + const group = await groupsService.createUnionGroupWithApiKey( + groupDto, + apiKey + ) + + 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(2) + expect(group.credentials).toBeNull() + }) + + it("Should not add duplicate members to the union group", async () => { + const groupsDto: Array = [ + { + name: "Ref Group API 3", + description: "This is a new group3", + treeDepth: 16, + fingerprintDuration: 3600 + }, + { + name: "Ref Group API 4", + description: "This is a new group4", + treeDepth: 16, + fingerprintDuration: 3600 + } + ] + + const groupIds = [] + + const groupDto: CreateUnionGroupDto = { + name: "Union Group API 2", + description: "This is a new group2", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds + } + + const groups = await groupsService.createGroupsWithAPIKey( + groupsDto, + apiKey + ) + + for await (const group of groups) { + await groupsService.addMemberManually(group.id, "1", admin.id) + + groupIds.push(group.id) + } + + await groupsService.createUnionGroupWithApiKey(groupDto, apiKey) + + const group = await groupsService.createUnionGroupWithApiKey( + groupDto, + apiKey + ) + + 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(1) + }) + + it("Should not create a union group if the admin does not exist", async () => { + const fun = groupsService.createUnionGroupWithApiKey( + unionGroupDto, + "wrong" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not create a union group if the API key is invalid", async () => { + const fun = groupsService.createUnionGroupWithApiKey( + unionGroupDto, + "apiKey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the groups` + ) + }) + + it("Should not create a union group if the API key is disabled for the admin", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + const fun = groupsService.createUnionGroupWithApiKey( + unionGroupDto, + apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + }) + describe("# Update group via API", () => { const groupDto: CreateGroupDto = { id: "1", @@ -1582,6 +1751,140 @@ describe("GroupsService", () => { }) }) + describe("# createUnionGroupManually", () => { + let admin: Admin + + beforeAll(async () => { + admin = await adminsService.create({ + id: "admin", + address: "0x" + }) + }) + + it("Should create a union group manually", async () => { + const groupsDto: Array = [ + { + name: "Ref Group 1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600 + }, + { + name: "Ref Group 2", + description: "This is a new group2", + treeDepth: 16, + fingerprintDuration: 7200 + } + ] + + const groupIds = [] + + const groupDto: CreateUnionGroupDto = { + name: "Union Group 1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds + } + + const groups = await groupsService.createGroupsManually( + groupsDto, + admin.id + ) + + for await (const [i, group] of groups.entries()) { + await groupsService.addMemberManually( + group.id, + `${i}`, + admin.id + ) + + groupIds.push(group.id) + } + + const group = await groupsService.createUnionGroupManually( + groupDto, + admin.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(2) + }) + + it("Should not create a union group manually if the admin doesn't exist", async () => { + const groupIds = [] + + const groupDto: CreateUnionGroupDto = { + name: "Union Group 1", + description: "This is a new group1", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds + } + + const fun = groupsService.createUnionGroupManually( + groupDto, + "wrong" + ) + + await expect(fun).rejects.toThrow(`You are not an admin`) + }) + + it("Should not add duplicate members to the union group", async () => { + const groupsDto: Array = [ + { + name: "Ref Group 3", + description: "This is a new group3", + treeDepth: 16, + fingerprintDuration: 3600 + }, + { + name: "Ref Group 4", + description: "This is a new group4", + treeDepth: 16, + fingerprintDuration: 3600 + } + ] + + const groupIds = [] + + const groupDto: CreateUnionGroupDto = { + name: "Union Group 2", + description: "This is a new group 2", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds + } + + const groups = await groupsService.createGroupsManually( + groupsDto, + admin.id + ) + + for await (const group of groups) { + await groupsService.addMemberManually(group.id, "1", admin.id) + + groupIds.push(group.id) + } + + const group = await groupsService.createUnionGroupManually( + groupDto, + admin.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(1) + }) + }) + describe("# removeGroupManually", () => { const groupDto: CreateGroupDto = { id: "1", diff --git a/apps/api/src/app/groups/groups.service.ts b/apps/api/src/app/groups/groups.service.ts index c0c7eae2..518094db 100644 --- a/apps/api/src/app/groups/groups.service.ts +++ b/apps/api/src/app/groups/groups.service.ts @@ -20,6 +20,7 @@ import { Group } from "./entities/group.entity" import { Member } from "./entities/member.entity" import { MerkleProof } from "./types" import { getAndCheckAdmin } from "../utils" +import { CreateUnionGroupDto } from "./dto/create-union-group.dto" @Injectable() export class GroupsService { @@ -138,6 +139,68 @@ export class GroupsService { return newGroups } + /** + * Creates a group that are union of existing groups using API Key. + * @param dto External parameters used to create a new group. + * @param groupIds Existing group ids. + * @param apiKey The API Key. + * @returns Created group. + */ + async createUnionGroupWithApiKey( + dto: CreateUnionGroupDto, + apiKey: string + ): Promise { + let group = await this.createGroupWithAPIKey(dto, apiKey) + + const members = [] + + for await (const groupId of dto.groupIds) { + const refGroup = await this.getGroup(groupId) + + if (refGroup) { + for (const member of refGroup.members) { + if (!members.includes(member.id)) { + members.push(member.id) + } + } + } + } + + group = await this.addMembers(group.id, members) + + return group + } + + /** + * Creates a group that are union of existing groups manually without using API Key. + * @param dto External parameters used to create a new group. + * @param groupIds Existing group ids. + * @param adminId Admin id. + * @returns Created group. + */ + async createUnionGroupManually( + dto: CreateUnionGroupDto, + adminId: string + ): Promise { + let group = await this.createGroupManually(dto, adminId) + + const members = [] + + for await (const groupId of dto.groupIds) { + const refGroup = await this.getGroup(groupId) + + if (refGroup) { + for (const member of refGroup.members) { + members.push(member.id) + } + } + } + + group = await this.addMembers(group.id, members) + + return group + } + /** * Creates a new group. * @param dto External parameters used to create a new group. diff --git a/apps/docs/docs/api-sdk.md b/apps/docs/docs/api-sdk.md index 9bd13bdf..accaa29c 100644 --- a/apps/docs/docs/api-sdk.md +++ b/apps/docs/docs/api-sdk.md @@ -202,6 +202,31 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" const groups = await apiSdk.createGroups(groupsCreateDetails, apiKey) ``` +## Create union group + +\# **createUnionGroup**(): _Promise\_ + +Create a union group from multiple groups. + +```ts +const unionGroupCreationDetails = { + name: "Group 1", + description: "This is Group 1", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds: [ + "10402173435763029700781503965100", + "20402173435763029700781503965200" + ] +} +const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" + +const unionGroup = await apiSdk.createUnionGroup( + unionGroupCreationDetails, + apiKey +) +``` + ## Remove group \# **removeGroup**(): _Promise\_ diff --git a/libs/api-sdk/README.md b/libs/api-sdk/README.md index 21e01288..8eb8d5ca 100644 --- a/libs/api-sdk/README.md +++ b/libs/api-sdk/README.md @@ -225,6 +225,31 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" const groups = await apiSdk.createGroups(groupsCreateDetails, apiKey) ``` +## Create union group + +\# **createUnionGroup**(): _Promise\_ + +Create a union group from multiple groups. + +```ts +const unionGroupCreationDetails = { + name: "Group 1", + description: "This is Group 1", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds: [ + "10402173435763029700781503965100", + "20402173435763029700781503965200" + ] +} +const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" + +const unionGroup = await apiSdk.createUnionGroup( + unionGroupCreationDetails, + apiKey +) +``` + ## Remove group \# **removeGroup**(): _Promise\_ diff --git a/libs/api-sdk/src/apiSdk.ts b/libs/api-sdk/src/apiSdk.ts index 822564dc..e5d62ede 100644 --- a/libs/api-sdk/src/apiSdk.ts +++ b/libs/api-sdk/src/apiSdk.ts @@ -4,7 +4,8 @@ import { Invite, GroupCreationDetails, GroupUpdateDetails, - DashboardUrl + DashboardUrl, + UnionGroupCreationDetails } from "./types" import checkParameter from "./checkParameter" import { @@ -25,7 +26,8 @@ import { getGroupsByAdminId, getGroupsByMemberId, getCredentialGroupJoinUrl, - getMultipleCredentialsGroupJoinUrl + getMultipleCredentialsGroupJoinUrl, + createUnionGroup } from "./groups" import { createInvite, getInvite, redeemInvite } from "./invites" @@ -138,6 +140,32 @@ export default class ApiSdk { return groups[0] } + /** + * Creates a union group using the API key. + * @param unionGroupCreationDetails Union group creation details. + * @param apiKey The API key of the admin of the group. + * @returns The created union group. + */ + async createUnionGroup( + unionGroupCreationDetails: UnionGroupCreationDetails, + apiKey: string + ): Promise { + if ( + unionGroupCreationDetails.treeDepth < 16 || + unionGroupCreationDetails.treeDepth > 32 + ) { + throw new Error("The tree depth must be between 16 and 32") + } + + const group = await createUnionGroup( + this._config, + unionGroupCreationDetails, + apiKey + ) + + return group + } + /** * Creates one or more groups using the API key. * @param groupsCreationDetails Data to create the groups. diff --git a/libs/api-sdk/src/groups.ts b/libs/api-sdk/src/groups.ts index 4cc9481f..073ec27a 100644 --- a/libs/api-sdk/src/groups.ts +++ b/libs/api-sdk/src/groups.ts @@ -3,7 +3,8 @@ import type { GroupCreationDetails, Group, GroupUpdateDetails, - DashboardUrl + DashboardUrl, + UnionGroupCreationDetails } from "./types" const url = "/groups" @@ -132,6 +133,32 @@ export async function createGroups( return req } +/** + * Creates a union group with the provided details. + * @param unionGroupCreationDetails Data to create the union group. + * @param apiKey API Key of the admin. + * @returns Created union group. + */ +export async function createUnionGroup( + config: object, + unionGroupCreationDetails: UnionGroupCreationDetails, + apiKey: string +): Promise { + const requestUrl = `${url}/union` + + const newConfig: any = { + method: "post", + data: unionGroupCreationDetails, + ...config + } + + newConfig.headers["x-api-key"] = apiKey + + const req = await request(requestUrl, newConfig) + + return req +} + /** * Removes the group. * @param groupId The group id. diff --git a/libs/api-sdk/src/index.test.ts b/libs/api-sdk/src/index.test.ts index 6d3203d8..0628bc75 100644 --- a/libs/api-sdk/src/index.test.ts +++ b/libs/api-sdk/src/index.test.ts @@ -6,7 +6,8 @@ import { GroupUpdateDetails, Invite, SupportedUrl, - DashboardUrl + DashboardUrl, + UnionGroupCreationDetails } from "./types" import checkParameter from "./checkParameter" @@ -323,6 +324,69 @@ describe("Bandada API SDK", () => { ) }) }) + describe("#createUnionGroup", () => { + it("Should create a union group", async () => { + const groups = [ + { + id: "10402173435763029700781503965100", + name: "Union Group 1", + description: "This is a new group", + admin: "0xdf558148e66850ac48dbe2c8119b0eefa7d08bfd19c997c90a142eb97916b847", + treeDepth: 16, + fingerprintDuration: 3600, + members: ["1"] + }, + { + id: "20402173435763029700781503965200", + name: "Union Group 2", + description: "This is a new group", + admin: "0xdf558148e66850ac48dbe2c8119b0eefa7d08bfd19c997c90a142eb97916b847", + treeDepth: 32, + fingerprintDuration: 7200, + members: ["2"] + } + ] + + const expectedGroup: UnionGroupCreationDetails = { + name: "Union Group 3", + description: "This is a new group", + treeDepth: 16, + fingerprintDuration: 3600, + groupIds: groups.map((group) => group.id) + } + + const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" + + requestMocked.mockImplementationOnce(() => + Promise.resolve({ + id: "30402173435763029700781503965300", + name: "Union Group 3", + description: "This is a new group", + admin: "0xdf558148e66850ac48dbe2c8119b0eefa7d08bfd19c997c90a142eb97916b847", + treeDepth: 16, + fingerprintDuration: 3600, + createdAt: "2023-07-15T08:21:05.000Z", + members: ["1", "2"], + credentials: null + }) + ) + + const apiSdk: ApiSdk = new ApiSdk(SupportedUrl.DEV) + const group: Group = await apiSdk.createUnionGroup( + expectedGroup, + apiKey + ) + + expect(group.description).toBe(expectedGroup.description) + expect(group.name).toBe(expectedGroup.name) + expect(group.treeDepth).toBe(expectedGroup.treeDepth) + expect(group.fingerprintDuration).toBe( + expectedGroup.fingerprintDuration + ) + expect(group.members).toHaveLength(2) + }) + }) + describe("#removeGroup", () => { it("Should create a group", async () => { const groupId = "10402173435763029700781503965100" diff --git a/libs/api-sdk/src/types/index.ts b/libs/api-sdk/src/types/index.ts index b78166c8..951b9bf2 100644 --- a/libs/api-sdk/src/types/index.ts +++ b/libs/api-sdk/src/types/index.ts @@ -21,6 +21,14 @@ export type GroupCreationDetails = { credentials?: Credential } +export type UnionGroupCreationDetails = { + name: string + description: string + treeDepth: number + fingerprintDuration: number + groupIds: string[] +} + export type GroupUpdateDetails = { description?: string treeDepth?: number