feat: filter users by assigned attribute options (#20659)

* feat: assigned attributes options base

* feat: assigned attributes options base e2e

* feat: filter assigned options by other assigned options

* feat: filter assigned options by other assigned options

* fixup! feat: filter assigned options by other assigned options

* feat: get org users by assigned attributes

* adding missing input file

* push generated docs

* feat: add filter org users by teamIds

* feat: add filter assigned attribute options by teamIds

* chore; update doc

* chore: remove console log

* chore: minor e2e fix

* chore: minor e2e fix

* chore: improve docs

* fix: assigned options by teamIds + optionIds
This commit is contained in:
Morgan authored and GitHub committed 2025-04-17 14:05:06 +02:00
1 parent a8fe3a6466
commit 5c7f1aebb2
16 files changed
+1435 -26

No files matched your search

@@ -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 ?? [])
@@ -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[];
}
@@ -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,
@@ -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();
});
@@ -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<GetAllAttributeAssignedOptionOutput> {
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<GetAllAttributeAssignedOptionOutput> {
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")
@@ -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[];
}
@@ -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,
@@ -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[] };
};
@@ -66,9 +66,11 @@ export class OrganizationsUsersController {
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
): Promise<GetOrganizationUsersResponseDTO> {
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
);
@@ -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();
});
});
@@ -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[];
}
@@ -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: {
@@ -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
);
@@ -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: {
+292
View File
@@ -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 <token>` where `<token>` 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 <token>` where `<token>` 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": {
+269
View File
@@ -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 <token>` where `<token>` 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 <token>` where `<token>` 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": {