diff --git a/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts b/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts index 130455b84c..a3a299eb44 100644 --- a/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts +++ b/apps/api/v2/src/modules/destination-calendars/services/destination-calendars.service.ts @@ -16,8 +16,6 @@ export class DestinationCalendarsService { userId: number, delegationCredentialId?: string ) { - // note(Lauris): todo remove the log but leaving this now to confirm delegationCredentialId is received - console.log("debug: delegationCredentialId", delegationCredentialId); const userCalendars = await this.calendarsService.getCalendars(userId); const allCalendars: Calendar[] = userCalendars.connectedCalendars .map((cal: ConnectedCalendar) => cal.calendars ?? []) diff --git a/apps/api/v2/src/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input.ts b/apps/api/v2/src/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input.ts new file mode 100644 index 0000000000..71215c12d9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input.ts @@ -0,0 +1,49 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsOptional, IsArray, ArrayMinSize, IsString, IsNumber } from "class-validator"; + +export class GetAssignedAttributeOptions { + @ApiPropertyOptional({ type: Number, description: "Number of responses to skip" }) + @Transform(({ value }) => value && parseInt(value)) + @IsOptional() + skip?: number; + + @ApiPropertyOptional({ type: Number, description: "Number of responses to take" }) + @Transform(({ value }) => value && parseInt(value)) + @IsOptional() + take?: number; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((optId: string) => optId); + } + return value; + }) + @ApiPropertyOptional({ + type: [String], + description: "Filter by assigned attribute option ids. ids must be separated by a comma.", + example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + }) + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" }) + assignedOptionIds?: string[]; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((teamId: string) => parseInt(teamId)); + } + return value; + }) + @ApiPropertyOptional({ + type: [Number], + description: "Filter by teamIds. Team ids must be separated by a comma.", + example: "?teamIds=100,200", + }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" }) + teamIds?: number[]; +} diff --git a/apps/api/v2/src/modules/organizations/attributes/options/organization-attribute-options.repository.ts b/apps/api/v2/src/modules/organizations/attributes/options/organization-attribute-options.repository.ts index 2c77a0ba53..458eedd269 100644 --- a/apps/api/v2/src/modules/organizations/attributes/options/organization-attribute-options.repository.ts +++ b/apps/api/v2/src/modules/organizations/attributes/options/organization-attribute-options.repository.ts @@ -7,6 +7,8 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { slugify } from "@calcom/platform-libraries"; +import { GetOrganizationAttributeAssignedOptionsProp } from "./services/organization-attributes-option.service"; + @Injectable() export class OrganizationAttributeOptionRepository { private readonly logger = new Logger("OrganizationAttributeOptionRepository"); @@ -90,6 +92,82 @@ export class OrganizationAttributeOptionRepository { return options; } + async getOrganizationAttributeAssignedOptions({ + attributeId, + attributeSlug, + filters, + organizationId, + skip, + take, + }: GetOrganizationAttributeAssignedOptionsProp) { + const options = await this.dbRead.prisma.attributeOption.findMany({ + where: { + attribute: { + ...(attributeId && { id: attributeId }), + ...(attributeSlug && { slug: attributeSlug }), + teamId: organizationId, + }, + assignedUsers: { + some: {}, // empty {} statement checks if option is assigned to at least one user + ...(filters?.teamIds && { + some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } }, + }), + }, + }, + include: { assignedUsers: { include: { member: true } } }, + skip, + take, + }); + + // only return options that are assigned to users alongside assignedOptionIds filter + if (filters?.assignedOptionIds) { + const filteredAssignedOptions = await this.dbRead.prisma.attributeOption.findMany({ + where: { + attribute: { teamId: organizationId }, + assignedUsers: { + every: { + attributeOptionId: { in: filters?.assignedOptionIds }, + }, + ...(filters?.teamIds && { + some: { member: { user: { teams: { some: { teamId: { in: filters.teamIds } } } } } }, + }), + }, + }, + include: { assignedUsers: { include: { member: true } } }, + }); + + if (!filteredAssignedOptions?.length) { + throw new NotFoundException( + "Options provided in assignedOptionIds are not assigned to anyone, or the users are not part of the teams specified in teamIds filter." + ); + } + + const matchingUserIds = filteredAssignedOptions.flatMap((opt) => + opt.assignedUsers.flatMap((assignedUser) => assignedUser.member.userId) + ); + // reduce remove options that are not assigned alongside assignedOptionIds filter + return options.reduce((acc, opt) => { + if (opt.assignedUsers.some((assignedUser) => matchingUserIds.includes(assignedUser.member.userId))) { + return [ + ...acc, + { + ...opt, + assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId), + }, + ]; + } + return acc; + }, [] as typeof options); + } + + return options.map((opt) => { + return { + ...opt, + assignedUserIds: opt.assignedUsers.map((attributeToUser) => attributeToUser.member.userId), + }; + }); + } + async assignOrganizationAttributeOptionToUser({ organizationId, membershipId, diff --git a/apps/api/v2/src/modules/organizations/attributes/options/organization-attributes-options.e2e-spec.ts b/apps/api/v2/src/modules/organizations/attributes/options/organization-attributes-options.e2e-spec.ts index 7fc76901e6..8bf509d619 100644 --- a/apps/api/v2/src/modules/organizations/attributes/options/organization-attributes-options.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/attributes/options/organization-attributes-options.e2e-spec.ts @@ -3,6 +3,8 @@ import { AppModule } from "@/app.module"; import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input"; import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input"; import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts"; +import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output"; +import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; @@ -13,12 +15,13 @@ import * as request from "supertest"; import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { User, Team, Membership } from "@calcom/prisma/client"; +import { User, Team, Membership, Attribute, AttributeOption } from "@calcom/prisma/client"; describe("Organizations Attributes Options Endpoints", () => { describe("User lacks required role", () => { @@ -95,19 +98,37 @@ describe("Organizations Attributes Options Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let teamsRepositoryFixtures: TeamRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; let attributeRepositoryFixture: AttributeRepositoryFixture; const userEmail = `organization-attributes-options-admin-${randomString()}@api.com`; + const userEmail2 = `organization-attributes-options-member-${randomString()}@api.com`; + let user: User; + let user2: User; let org: Team; + let team: Team; let membership: Membership; + let membership2: Membership; + let attributeId: string; + let attributeSlug: string; + let createdOption: any; + let createdOption2: any; + let attribute2: Attribute; + let attribute2Option: AttributeOption; const createOptionInput: CreateOrganizationAttributeOptionInput = { value: "option1", - slug: "option1", + slug: `option1-${randomString()}`, + }; + + const createOption2Input: CreateOrganizationAttributeOptionInput = { + value: "option2", + slug: `option2${randomString()}`, }; beforeAll(async () => { @@ -120,6 +141,7 @@ describe("Organizations Attributes Options Endpoints", () => { userRepositoryFixture = new UserRepositoryFixture(moduleRef); organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef); membershipFixtures = new MembershipRepositoryFixture(moduleRef); attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef); @@ -128,23 +150,50 @@ describe("Organizations Attributes Options Endpoints", () => { isOrganization: true, }); + team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } }); + user = await userRepositoryFixture.create({ email: userEmail, username: userEmail, organization: { connect: { id: org.id } }, }); + user2 = await userRepositoryFixture.create({ + email: userEmail2, + username: userEmail2, + organization: { connect: { id: org.id } }, + }); + membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true); + await membershipFixtures.create({ + role: "MEMBER", + accepted: true, + team: { connect: { id: team.id } }, + user: { connect: { id: user.id } }, + }); // Create an attribute for testing const attribute = await attributeRepositoryFixture.create({ name: "Test Attribute", team: { connect: { id: org.id } }, type: "TEXT", - slug: "test-attribute", + slug: `test-attribute-${randomString()}`, + }); + + attribute2 = await attributeRepositoryFixture.create({ + name: "Test Attribute 2", + team: { connect: { id: org.id } }, + type: "TEXT", + slug: `test-attribute-2-${randomString()}`, }); attributeId = attribute.id; - + attributeSlug = attribute.slug; + attribute2Option = await attributeRepositoryFixture.createOption({ + slug: `optionA-${randomString()}`, + value: "optionA", + attribute: { connect: { id: attribute2.id } }, + }); app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -164,16 +213,31 @@ describe("Organizations Attributes Options Endpoints", () => { }); }); + it("should create attribute option", async () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`) + .send(createOption2Input) + .expect(201) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + createdOption2 = response.body.data; + expect(createdOption2.value).toEqual(createOption2Input.value); + expect(createdOption2.slug).toEqual(createOption2Input.slug); + }); + }); + it("should get attribute options", async () => { return request(app.getHttpServer()) .get(`/v2/organizations/${org.id}/attributes/${attributeId}/options`) .expect(200) .then((response) => { expect(response.body.status).toEqual(SUCCESS_STATUS); - const options = response.body.data; - expect(options.length).toEqual(1); - expect(options[0].value).toEqual(createOptionInput.value); - expect(options[0].slug).toEqual(createOptionInput.slug); + const options = response.body.data as OptionOutput[]; + expect(options.length).toEqual(2); + expect(options.find((opt) => opt.value === createOptionInput.value)).toBeDefined(); + expect(options.find((opt) => opt.slug === createOptionInput.slug)).toBeDefined(); + expect(options.find((opt) => opt.value === createOption2Input.value)).toBeDefined(); + expect(options.find((opt) => opt.slug === createOption2Input.slug)).toBeDefined(); }); }); @@ -209,6 +273,38 @@ describe("Organizations Attributes Options Endpoints", () => { }); }); + it("should assign attribute option to user2", async () => { + const assignInput: AssignOrganizationAttributeOptionToUserInput = { + attributeId: attributeId, + attributeOptionId: createdOption2.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`) + .send(assignInput) + .expect(201) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + expect(response.body.data).toBeTruthy(); + }); + }); + + it("should assign attribute option to user2", async () => { + const assignInput: AssignOrganizationAttributeOptionToUserInput = { + attributeId: attribute2.id, + attributeOptionId: attribute2Option.id, + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/attributes/options/${user2.id}`) + .send(assignInput) + .expect(201) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + expect(response.body.data).toBeTruthy(); + }); + }); + it("should get attribute options for user", async () => { return request(app.getHttpServer()) .get(`/v2/organizations/${org.id}/attributes/options/${user.id}`) @@ -221,6 +317,133 @@ describe("Organizations Attributes Options Endpoints", () => { }); }); + it("should get attribute options for user2", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/options/${user2.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const userOptions = response.body.data as AttributeOption[]; + expect(userOptions.length).toEqual(2); + expect(userOptions.find((opt) => opt.id === createdOption2.id)).toBeDefined(); + expect(userOptions.find((opt) => opt.id === attribute2Option.id)).toBeDefined(); + }); + }); + + it("should get attribute all assigned options", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const assignedOptions = response.body.data as AssignedOptionOutput[]; + expect(assignedOptions?.length).toEqual(2); + + expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption.id === opt.id) + ?.assignedUserIds.find((id) => id === user.id) + ).toBeDefined(); + + expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption2.id === opt.id) + ?.assignedUserIds.find((id) => id === user2.id) + ).toBeDefined(); + }); + }); + + it("should get attribute all assigned options filtered by team id", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?teamIds=${team.id}`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const assignedOptions = response.body.data as AssignedOptionOutput[]; + expect(assignedOptions?.length).toEqual(1); + + expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption.id === opt.id) + ?.assignedUserIds.find((id) => id === user.id) + ).toBeDefined(); + }); + }); + + it("should get attribute all assigned options by attribute slug", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/attributes/slugs/${attributeSlug}/options/assigned`) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const assignedOptions = response.body.data as AssignedOptionOutput[]; + expect(assignedOptions?.length).toEqual(2); + + expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption.id === opt.id) + ?.assignedUserIds.find((id) => id === user.id) + ).toBeDefined(); + + expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption2.id === opt.id) + ?.assignedUserIds.find((id) => id === user2.id) + ).toBeDefined(); + }); + }); + + it("should get attribute all assigned options filtered by other assigned options", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}` + ) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const assignedOptions = response.body.data as AssignedOptionOutput[]; + expect(assignedOptions?.length).toEqual(1); + expect(assignedOptions.find((opt) => createdOption2.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption2.id === opt.id) + ?.assignedUserIds.find((id) => id === user2.id) + ).toBeDefined(); + }); + }); + + it("should not get attribute all assigned options filtered by other assigned options and teamIds in which the user is not part of", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${attribute2Option.id}&teamIds=${team.id}` + ) + .expect(404); + }); + + it("should get attribute all assigned options filtered by other assigned options and teamIds", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${org.id}/attributes/${attributeId}/options/assigned?assignedOptionIds=${createdOption.id}&teamIds=${team.id}` + ) + .expect(200) + .then((response) => { + expect(response.body.status).toEqual(SUCCESS_STATUS); + const assignedOptions = response.body.data as AssignedOptionOutput[]; + expect(assignedOptions?.length).toEqual(1); + expect(assignedOptions.find((opt) => createdOption.id === opt.id)).toBeDefined(); + expect( + assignedOptions + .find((opt) => createdOption.id === opt.id) + ?.assignedUserIds.find((id) => id === user.id) + ).toBeDefined(); + }); + }); + it("should unassign attribute option from user", async () => { return request(app.getHttpServer()) .delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`) @@ -243,7 +466,9 @@ describe("Organizations Attributes Options Endpoints", () => { afterAll(async () => { await membershipFixtures.delete(membership.id); + await membershipFixtures.delete(membership2.id); await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(user2.email); await organizationsRepositoryFixture.delete(org.id); await app.close(); }); diff --git a/apps/api/v2/src/modules/organizations/attributes/options/organizations-attributes-options.controller.ts b/apps/api/v2/src/modules/organizations/attributes/options/organizations-attributes-options.controller.ts index fd3a496ee3..f15b520002 100644 --- a/apps/api/v2/src/modules/organizations/attributes/options/organizations-attributes-options.controller.ts +++ b/apps/api/v2/src/modules/organizations/attributes/options/organizations-attributes-options.controller.ts @@ -8,19 +8,32 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/create-organization-attribute-option.input"; +import { GetAssignedAttributeOptions } from "@/modules/organizations/attributes/options/inputs/get-assigned-attribute-options.input"; import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input"; import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts"; import { AssignOptionUserOutput, UnassignOptionUserOutput, } from "@/modules/organizations/attributes/options/outputs/assign-option-user.output"; +import { GetAllAttributeAssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output"; import { CreateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/create-option.output"; import { DeleteAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/delete-option.output"; import { GetOptionUserOutput } from "@/modules/organizations/attributes/options/outputs/get-option-user.output"; import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/get-option.output"; import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output"; import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service"; -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseGuards, +} from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; @@ -118,6 +131,54 @@ export class OrganizationsAttributesOptionsController { }; } + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Get("/attributes/:attributeId/options/assigned") + @ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" }) + async getOrganizationAttributeAssignedOptions( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeId") attributeId: string, + @Query() queryParams: GetAssignedAttributeOptions + ): Promise { + const { skip, take, ...rest } = queryParams; + const attributeOptions = + await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({ + organizationId: orgId, + attributeId, + skip: skip ?? 0, + take: take ?? 250, + filters: rest, + }); + return { + status: SUCCESS_STATUS, + data: attributeOptions, + }; + } + + @Roles("ORG_MEMBER") + @PlatformPlan("ESSENTIALS") + @Get("/attributes/slugs/:attributeSlug/options/assigned") + @ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" }) + async getOrganizationAttributeAssignedOptionsBySlug( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("attributeSlug") attributeSlug: string, + @Query() queryParams: GetAssignedAttributeOptions + ): Promise { + const { skip, take, ...rest } = queryParams; + const attributeOptions = + await this.organizationsAttributesOptionsService.getOrganizationAttributeAssignedOptions({ + organizationId: orgId, + attributeSlug, + skip: skip ?? 0, + take: take ?? 250, + filters: rest, + }); + return { + status: SUCCESS_STATUS, + data: attributeOptions, + }; + } + @Roles("ORG_ADMIN") @PlatformPlan("ESSENTIALS") @Post("/attributes/options/:userId") diff --git a/apps/api/v2/src/modules/organizations/attributes/options/outputs/assigned-options.output.ts b/apps/api/v2/src/modules/organizations/attributes/options/outputs/assigned-options.output.ts new file mode 100644 index 0000000000..8da3918bbf --- /dev/null +++ b/apps/api/v2/src/modules/organizations/attributes/options/outputs/assigned-options.output.ts @@ -0,0 +1,24 @@ +import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output"; +import { OptionOutput } from "@/modules/organizations/attributes/options/outputs/option.output"; +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsArray, ValidateNested } from "class-validator"; + +export class AssignedOptionOutput extends OptionOutput { + @Expose() + @IsArray() + @ApiProperty({ + type: Array, + required: true, + description: "Ids of the users assigned to the attribute option.", + example: [124, 224], + }) + assignedUserIds!: number[]; +} + +export class GetAllAttributeAssignedOptionOutput extends BaseOutputDTO { + @Expose() + @ValidateNested() + @Type(() => OptionOutput) + data!: AssignedOptionOutput[]; +} diff --git a/apps/api/v2/src/modules/organizations/attributes/options/outputs/option.output.ts b/apps/api/v2/src/modules/organizations/attributes/options/outputs/option.output.ts index f879806900..08408a39d6 100644 --- a/apps/api/v2/src/modules/organizations/attributes/options/outputs/option.output.ts +++ b/apps/api/v2/src/modules/organizations/attributes/options/outputs/option.output.ts @@ -1,8 +1,9 @@ -import { BaseOutputDTO } from "@/modules/organizations/attributes/index/outputs/base.output"; import { ApiProperty } from "@nestjs/swagger"; -import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator"; +import { Expose } from "class-transformer"; +import { IsString } from "class-validator"; export class OptionOutput { + @Expose() @IsString() @ApiProperty({ type: String, @@ -12,10 +13,12 @@ export class OptionOutput { }) id!: string; + @Expose() @IsString() @ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" }) attributeId!: string; + @Expose() @IsString() @ApiProperty({ type: String, @@ -25,6 +28,7 @@ export class OptionOutput { }) value!: string; + @Expose() @IsString() @ApiProperty({ type: String, diff --git a/apps/api/v2/src/modules/organizations/attributes/options/services/organization-attributes-option.service.ts b/apps/api/v2/src/modules/organizations/attributes/options/services/organization-attributes-option.service.ts index 000a14c62b..07b5c8937d 100644 --- a/apps/api/v2/src/modules/organizations/attributes/options/services/organization-attributes-option.service.ts +++ b/apps/api/v2/src/modules/organizations/attributes/options/services/organization-attributes-option.service.ts @@ -3,8 +3,10 @@ import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/ import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/attributes/options/inputs/organizations-attributes-options-assign.input"; import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/attributes/options/inputs/update-organizaiton-attribute-option.input.ts"; import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository"; +import { AssignedOptionOutput } from "@/modules/organizations/attributes/options/outputs/assigned-options.output"; import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]); @@ -111,4 +113,45 @@ export class OrganizationAttributeOptionService { userId ); } + + async getOrganizationAttributeAssignedOptions({ + organizationId, + attributeId, + attributeSlug, + skip, + take, + filters, + }: GetOrganizationAttributeAssignedOptionsProp) { + const options = await this.organizationAttributeOptionRepository.getOrganizationAttributeAssignedOptions({ + organizationId, + ...(attributeId ? { attributeId } : { attributeSlug }), + skip, + take, + filters, + }); + return options.map((opt) => plainToClass(AssignedOptionOutput, opt, { strategy: "excludeAll" })); + } } + +// Discriminative Union on attributeSlug / attributeId +export type GetOrganizationAttributeAssignedOptionsProp = + | GetOrganizationAttributeAssignedOptionsPropById + | GetOrganizationAttributeAssignedOptionsPropBySlug; + +type GetOrganizationAttributeAssignedOptionsPropById = { + organizationId: number; + attributeId: string; + attributeSlug?: undefined; + skip: number; + take: number; + filters?: { assignedOptionIds?: string[]; teamIds?: number[] }; +}; + +type GetOrganizationAttributeAssignedOptionsPropBySlug = { + organizationId: number; + attributeId?: undefined; + attributeSlug?: string; + skip: number; + take: number; + filters?: { assignedOptionIds?: string[]; teamIds?: number[] }; +}; diff --git a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts index 78f63ed2ce..cd24c897a2 100644 --- a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts +++ b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts @@ -66,9 +66,11 @@ export class OrganizationsUsersController { @Param("orgId", ParseIntPipe) orgId: number, @Query() query: GetOrganizationsUsersInput ): Promise { + const { emails, assignedOptionIds, attributeQueryOperator, teamIds } = query ?? {}; const users = await this.organizationsUsersService.getUsers( orgId, - query.emails, + emails, + { assignedOptionIds, attributeQueryOperator, teamIds }, query.skip ?? 0, query.take ?? 250 ); diff --git a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts index 1ea598049b..7d148c66a7 100644 --- a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts @@ -1,6 +1,7 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { EmailService } from "@/modules/email/email.service"; +import { GetOrganizationsUsersInput } from "@/modules/organizations/users/index/inputs/get-organization-users.input"; import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; @@ -10,6 +11,7 @@ import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import * as request from "supertest"; +import { AttributeRepositoryFixture } from "test/fixtures/repository/attributes.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; @@ -20,7 +22,7 @@ import { randomString } from "test/utils/randomString"; import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { User, Team, EventType } from "@calcom/prisma/client"; +import { User, Team, EventType, AttributeOption } from "@calcom/prisma/client"; describe("Organizations Users Endpoints", () => { const bio = "I am a bio"; @@ -114,8 +116,6 @@ describe("Organizations Users Endpoints", () => { await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]); await organizationsRepositoryFixture.delete(org.id); await app.close(); - - await app.close(); }); }); describe("Admin role", () => { @@ -249,11 +249,6 @@ describe("Organizations Users Endpoints", () => { expect(body.status).toBe(SUCCESS_STATUS); expect(userData.length).toBe(4); - console.log( - "profiles", - { userData }, - userData.map((u) => u.profile) - ); // Find and verify each member's data const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username); const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username); @@ -543,7 +538,250 @@ describe("Organizations Users Endpoints", () => { await userRepositoryFixture.deleteByEmail(createdUser.email); await organizationsRepositoryFixture.delete(org.id); await app.close(); + }); + }); + describe("Org Members with assigned attributes", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamsRepositoryFixtures: TeamRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + let attributeRepositoryFixture: AttributeRepositoryFixture; + + const authEmail = `organizations-users-auth-${randomString()}@api.com`; + const user2Email = `organizations-users2-auth-${randomString()}@api.com`; + + let user: User; + let user2: User; + + let org: Team; + let team: Team; + let assignedOption1: AttributeOption; + let assignedOption2: AttributeOption; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + authEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + teamsRepositoryFixtures = new TeamRepositoryFixture(moduleRef); + + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + + org = await organizationsRepositoryFixture.create({ + name: `organizations-users-organization-${randomString()}`, + isOrganization: true, + }); + + team = await teamsRepositoryFixtures.create({ name: "org team", parent: { connect: { id: org.id } } }); + + user = await userRepositoryFixture.create({ + email: authEmail, + username: authEmail, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user.id}`, + username: authEmail, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user.id, + }, + }, + }); + + const membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + + await membershipFixtures.create({ + role: "MEMBER", + accepted: true, + team: { connect: { id: team.id } }, + user: { connect: { id: user.id } }, + }); + + user2 = await userRepositoryFixture.create({ + email: user2Email, + username: user2Email, + organization: { connect: { id: org.id } }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${user2.id}`, + username: user2Email, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: user2.id, + }, + }, + }); + + const membership2 = await membershipFixtures.addUserToOrg(user2, org, "ADMIN", true); + + attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef); + const attribute = await attributeRepositoryFixture.create({ + name: "Test Attribute", + team: { connect: { id: org.id } }, + type: "TEXT", + slug: `test-attribute-${randomString()}`, + }); + + const attribute2 = await attributeRepositoryFixture.create({ + name: "Test Attribute 2", + team: { connect: { id: org.id } }, + type: "TEXT", + slug: `test-attribute-2-${randomString()}`, + }); + const attributeId = attribute.id; + + assignedOption1 = await attributeRepositoryFixture.createOption({ + slug: "option1", + value: "option1", + attribute: { connect: { id: attribute.id } }, + assignedUsers: { + create: { + memberId: membership.id, + }, + }, + }); + + assignedOption2 = await attributeRepositoryFixture.createOption({ + slug: "optionA", + value: "optionA", + attribute: { connect: { id: attribute2.id } }, + assignedUsers: { createMany: { data: [{ memberId: membership.id }, { memberId: membership2.id }] } }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should get users with all specified assigned attribute options", async () => { + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + assignedOptionIds: [assignedOption1.id], + attributeQueryOperator: "AND", + } as GetOrganizationsUsersInput) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(1); + + const userWithAssignedOptions = userData.find( + (u: GetOrgUsersWithProfileOutput) => u.email === user.email + ); + expect(userWithAssignedOptions).toBeDefined(); + expect(userWithAssignedOptions?.email).toBe(user.email); + expect(userWithAssignedOptions?.profile.username).toBe(user.username); + }); + + it("should get users with at least one of the specified assigned attribute options", async () => { + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + assignedOptionIds: [assignedOption1.id, assignedOption2.id], + attributeQueryOperator: "OR", + } as GetOrganizationsUsersInput) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(2); + + const userWithAssignedOptions = userData.find( + (u: GetOrgUsersWithProfileOutput) => u.email === user.email + ); + expect(userWithAssignedOptions).toBeDefined(); + expect(userWithAssignedOptions?.email).toBe(user.email); + expect(userWithAssignedOptions?.profile.username).toBe(user.username); + + const userWithAssignedOptions2 = userData.find( + (u: GetOrgUsersWithProfileOutput) => u.email === user2.email + ); + expect(userWithAssignedOptions2).toBeDefined(); + expect(userWithAssignedOptions2?.email).toBe(user2.email); + expect(userWithAssignedOptions2?.profile.username).toBe(user2.username); + }); + + it("should get users with at least one of the specified assigned attribute options filtered by teams", async () => { + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + assignedOptionIds: [assignedOption1.id, assignedOption2.id], + attributeQueryOperator: "OR", + teamIds: [team.id], + } as GetOrganizationsUsersInput) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(1); + + const userWithAssignedOptions = userData.find( + (u: GetOrgUsersWithProfileOutput) => u.email === user.email + ); + expect(userWithAssignedOptions).toBeDefined(); + expect(userWithAssignedOptions?.email).toBe(user.email); + expect(userWithAssignedOptions?.profile.username).toBe(user.username); + }); + + it("should get users with none of the specified assigned attribute options", async () => { + const { body } = await request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/users`) + .query({ + assignedOptionIds: [assignedOption1.id, assignedOption2.id], + attributeQueryOperator: "NONE", + } as GetOrganizationsUsersInput) + .set("Content-Type", "application/json") + .set("Accept", "application/json"); + + const userData = body.data; + + expect(body.status).toBe(SUCCESS_STATUS); + expect(userData.length).toBe(0); + }); + + afterAll(async () => { + // await membershipFixtures.delete(membership.id); + await userRepositoryFixture.deleteByEmail(user.email); + await userRepositoryFixture.deleteByEmail(user2.email); + await organizationsRepositoryFixture.delete(org.id); await app.close(); }); }); diff --git a/apps/api/v2/src/modules/organizations/users/index/inputs/get-organization-users.input.ts b/apps/api/v2/src/modules/organizations/users/index/inputs/get-organization-users.input.ts index 6dfc79f57e..a999a123a5 100644 --- a/apps/api/v2/src/modules/organizations/users/index/inputs/get-organization-users.input.ts +++ b/apps/api/v2/src/modules/organizations/users/index/inputs/get-organization-users.input.ts @@ -1,3 +1,52 @@ import { GetUsersInput } from "@/modules/users/inputs/get-users.input"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { Expose } from "class-transformer"; +import { IsOptional, IsArray, ArrayMinSize, IsString, IsIn, IsNumber } from "class-validator"; -export class GetOrganizationsUsersInput extends GetUsersInput {} +export class GetOrganizationsUsersInput extends GetUsersInput { + @Expose() + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((optId: string) => optId); + } + return value; + }) + @ApiPropertyOptional({ + type: [String], + description: "Filter by assigned attribute option ids. ids must be separated by a comma.", + example: "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + }) + @IsArray() + @IsString({ each: true }) + @ArrayMinSize(1, { message: "assignedOptionIds must contain at least 1 attribute option id" }) + assignedOptionIds?: string[]; + + @ApiPropertyOptional({ + type: String, + description: "Query operator used to filter assigned options, AND by default.", + example: "NONE", + }) + @IsOptional() + @IsString() + @IsIn(["OR", "AND", "NONE"]) + attributeQueryOperator: "AND" | "OR" | "NONE" = "AND"; // Default value + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((teamId: string) => parseInt(teamId)); + } + return value; + }) + @ApiPropertyOptional({ + type: [Number], + description: "Filter by teamIds. Team ids must be separated by a comma.", + example: "?teamIds=100,200", + }) + @IsArray() + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" }) + teamIds?: number[]; +} diff --git a/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts b/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts index ff6800c1b5..10b825c863 100644 --- a/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts +++ b/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts @@ -17,11 +17,64 @@ export class OrganizationsUsersRepository { }; } - async getOrganizationUsersByEmails(orgId: number, emailArray?: string[], skip?: number, take?: number) { + async getOrganizationUsersByEmailsAndAttributeFilters( + orgId: number, + filters: { + teamIds?: number[]; + assignedOptionIds: string[]; + attributeQueryOperator?: "AND" | "OR" | "NONE"; + }, + emailArray?: string[], + skip?: number, + take?: number + ) { + const { teamIds, assignedOptionIds, attributeQueryOperator } = filters ?? {}; + const attributeToUsersWithProfile = await this.dbRead.prisma.attributeToUser.findMany({ + include: { + member: { include: { user: { include: { profiles: { where: { organizationId: orgId } } } } } }, + }, + distinct: ["memberId"], + where: { + member: { + teamId: orgId, + ...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }), + // Filter to only get users which have ALL of the assigned attribue options + ...(attributeQueryOperator === "AND" && { + AND: assignedOptionIds.map((optionId) => ({ + AttributeToUser: { some: { attributeOptionId: optionId } }, + })), + }), + }, + ...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}), + // Filter to get users which have AT LEAST ONE of the assigned attribue options + ...(attributeQueryOperator === "OR" && { + attributeOption: { id: { in: assignedOptionIds } }, + }), + // Filter to get users that have NONE the assigned attribue options + ...(attributeQueryOperator === "NONE" && { + NOT: { + attributeOption: { id: { in: assignedOptionIds } }, + }, + }), + }, + skip, + take, + }); + return attributeToUsersWithProfile.map((attributeToUser) => attributeToUser.member.user); + } + + async getOrganizationUsersByEmails( + orgId: number, + emailArray?: string[], + teamIds?: number[], + skip?: number, + take?: number + ) { return await this.dbRead.prisma.user.findMany({ where: { ...this.filterOnOrgMembership(orgId), ...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}), + ...(teamIds && { teams: { some: { teamId: { in: teamIds } } } }), }, include: { profiles: { diff --git a/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts b/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts index 1684b2239c..28b60e086d 100644 --- a/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts +++ b/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts @@ -16,12 +16,37 @@ export class OrganizationsUsersService { private readonly emailService: EmailService ) {} - async getUsers(orgId: number, emailInput?: string[], skip?: number, take?: number) { + async getUsers( + orgId: number, + emailInput?: string[], + filters?: { + teamIds?: number[]; + assignedOptionIds?: string[]; + attributeQueryOperator?: "AND" | "OR" | "NONE"; + }, + skip?: number, + take?: number + ) { const emailArray = !emailInput ? [] : emailInput; + if (filters?.assignedOptionIds && filters?.assignedOptionIds?.length) { + return await this.organizationsUsersRepository.getOrganizationUsersByEmailsAndAttributeFilters( + orgId, + { + assignedOptionIds: filters.assignedOptionIds, + attributeQueryOperator: filters?.attributeQueryOperator ?? "AND", + teamIds: filters?.teamIds, + }, + emailArray, + skip, + take + ); + } + const users = await this.organizationsUsersRepository.getOrganizationUsersByEmails( orgId, emailArray, + filters?.teamIds, skip, take ); diff --git a/apps/api/v2/src/modules/organizations/users/ooo/organizations-users-ooo.repository.ts b/apps/api/v2/src/modules/organizations/users/ooo/organizations-users-ooo.repository.ts index c00ecd27f8..9c4664caa9 100644 --- a/apps/api/v2/src/modules/organizations/users/ooo/organizations-users-ooo.repository.ts +++ b/apps/api/v2/src/modules/organizations/users/ooo/organizations-users-ooo.repository.ts @@ -11,7 +11,6 @@ export class OrgUsersOOORepository { sort?: { sortStart?: "asc" | "desc"; sortEnd?: "asc" | "desc" }, filters?: { email?: string } ) { - console.log({ sort, filters }); return this.dbRead.prisma.outOfOfficeEntry.findMany({ where: { user: { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 125944f5aa..d5308b71b2 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1110,6 +1110,190 @@ ] } }, + "/v2/organizations/{orgId}/attributes/{attributeId}/options/assigned": { + "get": { + "operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptions", + "summary": "Get by attribute id all of the attribute options that are assigned to users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of responses to skip", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Number of responses to take", + "schema": { + "type": "number" + } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "example": "?teamIds=100,200", + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllAttributeAssignedOptionOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + } + }, + "/v2/organizations/{orgId}/attributes/slugs/{attributeSlug}/options/assigned": { + "get": { + "operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptionsBySlug", + "summary": "Get by attribute slug all of the attribute options that are assigned to users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeSlug", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of responses to skip", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Number of responses to take", + "schema": { + "type": "number" + } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "example": "?teamIds=100,200", + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllAttributeAssignedOptionOutput" + } + } + } + } + }, + "tags": [ + "Orgs / Attributes / Options" + ] + } + }, "/v2/organizations/{orgId}/attributes/options/{userId}": { "post": { "operationId": "OrganizationsAttributesOptionsController_assignOrganizationAttributeOptionToUser", @@ -4777,6 +4961,48 @@ "type": "string" } } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "attributeQueryOperator", + "required": false, + "in": "query", + "description": "Query operator used to filter assigned options, AND by default.", + "example": "NONE", + "schema": { + "default": "AND", + "enum": [ + "OR", + "AND", + "NONE" + ], + "type": "string" + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "example": "?teamIds=100,200", + "schema": { + "type": "array", + "items": { + "type": "number" + } + } } ], "responses": { @@ -18416,6 +18642,72 @@ "data" ] }, + "AssignedOptionOutput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the option", + "example": "attr_option_id" + }, + "attributeId": { + "type": "string", + "description": "The ID of the attribute", + "example": "attr_id" + }, + "value": { + "type": "string", + "description": "The value of the option", + "example": "option_value" + }, + "slug": { + "type": "string", + "description": "The slug of the option", + "example": "option-slug" + }, + "assignedUserIds": { + "description": "Ids of the users assigned to the attribute option.", + "example": [ + 124, + 224 + ], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "attributeId", + "value", + "slug", + "assignedUserIds" + ] + }, + "GetAllAttributeAssignedOptionOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignedOptionOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, "AssignOrganizationAttributeOptionToUserInput": { "type": "object", "properties": { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index ad43c1c3e5..36965f7af0 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -1066,6 +1066,186 @@ "tags": ["Orgs / Attributes / Options"] } }, + "/v2/organizations/{orgId}/attributes/{attributeId}/options/assigned": { + "get": { + "operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptions", + "summary": "Get by attribute id all of the attribute options that are assigned to users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of responses to skip", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Number of responses to take", + "schema": { + "type": "number" + } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "example": "?teamIds=100,200", + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllAttributeAssignedOptionOutput" + } + } + } + } + }, + "tags": ["Orgs / Attributes / Options"] + } + }, + "/v2/organizations/{orgId}/attributes/slugs/{attributeSlug}/options/assigned": { + "get": { + "operationId": "OrganizationsAttributesOptionsController_getOrganizationAttributeAssignedOptionsBySlug", + "summary": "Get by attribute slug all of the attribute options that are assigned to users", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "attributeSlug", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of responses to skip", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Number of responses to take", + "schema": { + "type": "number" + } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "example": "?teamIds=100,200", + "schema": { + "type": "array", + "items": { + "type": "number" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetAllAttributeAssignedOptionOutput" + } + } + } + } + }, + "tags": ["Orgs / Attributes / Options"] + } + }, "/v2/organizations/{orgId}/attributes/options/{userId}": { "post": { "operationId": "OrganizationsAttributesOptionsController_assignOrganizationAttributeOptionToUser", @@ -4569,6 +4749,44 @@ "type": "string" } } + }, + { + "name": "assignedOptionIds", + "required": false, + "in": "query", + "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "attributeQueryOperator", + "required": false, + "in": "query", + "description": "Query operator used to filter assigned options, AND by default.", + "example": "NONE", + "schema": { + "default": "AND", + "enum": ["OR", "AND", "NONE"], + "type": "string" + } + }, + { + "name": "teamIds", + "required": false, + "in": "query", + "description": "Filter by teamIds. Team ids must be separated by a comma.", + "example": "?teamIds=100,200", + "schema": { + "type": "array", + "items": { + "type": "number" + } + } } ], "responses": { @@ -16927,6 +17145,57 @@ }, "required": ["status", "data"] }, + "AssignedOptionOutput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The ID of the option", + "example": "attr_option_id" + }, + "attributeId": { + "type": "string", + "description": "The ID of the attribute", + "example": "attr_id" + }, + "value": { + "type": "string", + "description": "The value of the option", + "example": "option_value" + }, + "slug": { + "type": "string", + "description": "The slug of the option", + "example": "option-slug" + }, + "assignedUserIds": { + "description": "Ids of the users assigned to the attribute option.", + "example": [124, 224], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "attributeId", "value", "slug", "assignedUserIds"] + }, + "GetAllAttributeAssignedOptionOutput": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssignedOptionOutput" + } + } + }, + "required": ["status", "data"] + }, "AssignOrganizationAttributeOptionToUserInput": { "type": "object", "properties": {