feat: attributes v2 api (#16461)

* WIP

* services

* controllers + outputs

* unit tests for repositories

* use hard coded enum type

* use prisma import

* remove redunant input file

* fix packagejson

* rename to spec files

* fix typo in option service file name

* move membership call to org membership service

* move logic to service > repo

* fix membership query

* attribute controller e2e spec

* add options e2e test

* add jest watch for e2e

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
sean-brydon
2024-09-05 10:15:12 +02:00
committed by GitHub
co-authored by Morgan Vernay
parent 36e79bf69c
commit 2de33fe1bf
33 changed files with 2554 additions and 4 deletions
+2
View File
@@ -12,6 +12,7 @@
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
@@ -19,6 +20,7 @@
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
@@ -0,0 +1,250 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
describe("Organizations Attributes Options Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "member@attributes-options-api.com";
let user: User;
let org: Team;
let membership: Membership;
let attributeId: string;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesOptionsCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
// Create an attribute for testing
attributeId = "test-attribute-id";
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should not be able to create attribute option", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.send({
name: "Option 1",
value: "option1",
})
.expect(403);
});
it("should not be able to delete attribute option", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/1`)
.expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let attributeRepositoryFixture: AttributeRepositoryFixture;
const userEmail = "admin@attributes-options-api.com";
let user: User;
let org: Team;
let membership: Membership;
let attributeId: string;
let createdOption: any;
const createOptionInput: CreateOrganizationAttributeOptionInput = {
value: "option1",
slug: "option1",
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
attributeRepositoryFixture = new AttributeRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesOptionsCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
// Create an attribute for testing
const attribute = await attributeRepositoryFixture.create({
name: "Test Attribute",
team: { connect: { id: org.id } },
type: "TEXT",
slug: "test-attribute",
});
attributeId = attribute.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create attribute option", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/${attributeId}/options`)
.send(createOptionInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
createdOption = response.body.data;
expect(createdOption.value).toEqual(createOptionInput.value);
expect(createdOption.slug).toEqual(createOptionInput.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);
});
});
it("should update attribute option", async () => {
const updateOptionInput: UpdateOrganizationAttributeOptionInput = {
value: "updated-option-value",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
.send(updateOptionInput)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const updatedOption = response.body.data;
expect(updatedOption.value).toEqual(updateOptionInput.value);
});
});
it("should assign attribute option to user", async () => {
const assignInput: AssignOrganizationAttributeOptionToUserInput = {
attributeId: attributeId,
attributeOptionId: createdOption.id,
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes/options/${user.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}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const userOptions = response.body.data;
expect(userOptions.length).toEqual(1);
expect(userOptions[0].id).toEqual(createdOption.id);
});
});
it("should unassign attribute option from user", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/options/${user.id}/${createdOption.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
it("should delete attribute option", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/${attributeId}/options/${createdOption.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,213 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
describe("Organizations Attributes Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "member@attributes-api.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should not be able to create attribute for org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes`)
.send({
key: "department",
value: "engineering",
})
.expect(403);
});
it("should not be able to delete attribute for org", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/attributes/1`).expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "admin@attributes-api.com";
let user: User;
let org: Team;
let membership: Membership;
let createdAttribute: any;
const createAttributeInput: CreateOrganizationAttributeInput = {
name: "department",
slug: "department",
type: "TEXT",
options: [],
enabled: true,
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "AttributesCorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should create attribute for org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/attributes`)
.send(createAttributeInput)
.expect(201)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
createdAttribute = response.body.data;
expect(createdAttribute.type).toEqual(createAttributeInput.type);
expect(createdAttribute.slug).toEqual(createAttributeInput.slug);
expect(createdAttribute.enabled).toEqual(createAttributeInput.enabled);
});
});
it("should get org attributes", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const attributes = response.body.data;
expect(attributes.length).toEqual(1);
expect(attributes[0].name).toEqual(createAttributeInput.name);
expect(attributes[0].slug).toEqual(createAttributeInput.slug);
expect(attributes[0].type).toEqual(createAttributeInput.type);
expect(attributes[0].enabled).toEqual(createAttributeInput.enabled);
});
});
it("should get single org attribute", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const attribute = response.body.data;
expect(attribute.name).toEqual(createAttributeInput.name);
expect(attribute.slug).toEqual(createAttributeInput.slug);
expect(attribute.type).toEqual(createAttributeInput.type);
expect(attribute.enabled).toEqual(createAttributeInput.enabled);
});
});
it("should update org attribute", async () => {
const updateAttributeInput: UpdateOrganizationAttributeInput = {
name: "marketing",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
.send(updateAttributeInput)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
const updatedAttribute = response.body.data;
expect(updatedAttribute.name).toEqual(updateAttributeInput.name);
});
});
it("should delete org attribute", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/attributes/${createdAttribute.id}`)
.expect(200)
.then((response) => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeTruthy();
});
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,173 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import {
AssignOptionUserOutput,
UnassignOptionUserOutput,
} from "@/modules/organizations/outputs/attributes/options/assign-option-user.output";
import { CreateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/create-option.output";
import { DeleteAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/delete-option.output";
import { GetOptionUserOutput } from "@/modules/organizations/outputs/attributes/options/get-option-user.output";
import { GetAllAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/get-option.output";
import { UpdateAttributeOptionOutput } from "@/modules/organizations/outputs/attributes/options/update-option.output";
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
export class OrganizationsOptionsAttributesController {
constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {}
// Creates an attribute option for an attribute
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes/:attributeId/options")
async createOrganizationAttributeOption(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Body() bodyAttribute: CreateOrganizationAttributeOptionInput
): Promise<CreateAttributeOptionOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.createOrganizationAttributeOption(
orgId,
attributeId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Deletes an attribute option for an attribute
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/attributes/:attributeId/options/:optionId")
async deleteOrganizationAttributeOption(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Param("optionId") optionId: string
): Promise<DeleteAttributeOptionOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.deleteOrganizationAttributeOption(
orgId,
attributeId,
optionId
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Updates an attribute option for an attribute
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/attributes/:attributeId/options/:optionId")
async updateOrganizationAttributeOption(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Param("optionId") optionId: string,
@Body() bodyAttribute: UpdateOrganizationAttributeOptionInput
): Promise<UpdateAttributeOptionOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.updateOrganizationAttributeOption(
orgId,
attributeId,
optionId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Gets all attribute options for an attribute
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/:attributeId/options")
async getOrganizationAttributeOptions(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string
): Promise<GetAllAttributeOptionOutput> {
const attributeOptions = await this.organizationsAttributesOptionsService.getOrganizationAttributeOptions(
orgId,
attributeId
);
return {
status: SUCCESS_STATUS,
data: attributeOptions,
};
}
// Assign attribute option to user
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes/options/:userId")
async assignOrganizationAttributeOptionToUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@Body() bodyAttribute: AssignOrganizationAttributeOptionToUserInput
): Promise<AssignOptionUserOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.assignOrganizationAttributeOptionToUser(
orgId,
userId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
// Unassign attribute option from user
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Delete("/attributes/options/:userId/:attributeOptionId")
async unassignOrganizationAttributeOptionFromUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@Param("attributeOptionId") attributeOptionId: string
): Promise<UnassignOptionUserOutput> {
const attributeOption =
await this.organizationsAttributesOptionsService.unassignOrganizationAttributeOptionFromUser(
orgId,
userId,
attributeOptionId
);
return {
status: SUCCESS_STATUS,
data: attributeOption,
};
}
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/options/:userId")
async getOrganizationAttributeOptionsForUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number
): Promise<GetOptionUserOutput> {
const attributeOptions =
await this.organizationsAttributesOptionsService.getOrganizationAttributeOptionsForUser(orgId, userId);
return {
status: SUCCESS_STATUS,
data: attributeOptions,
};
}
}
@@ -0,0 +1,129 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { CreateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/create-organization-attributes.output";
import { DeleteOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/delete-organization-attributes.output";
import {
GetOrganizationAttributesOutput,
GetSingleAttributeOutput,
} from "@/modules/organizations/outputs/attributes/get-organization-attributes.output";
import { UpdateOrganizationAttributesOutput } from "@/modules/organizations/outputs/attributes/update-organization-attributes.output";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
export class OrganizationsAttributesController {
constructor(private readonly organizationsAttributesService: OrganizationAttributesService) {}
// Gets all attributes for an organization
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes")
async getOrganizationAttributes(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetOrganizationAttributesOutput> {
const { skip, take } = queryParams;
const attributes = await this.organizationsAttributesService.getOrganizationAttributes(orgId, skip, take);
return {
status: SUCCESS_STATUS,
data: attributes,
};
}
// Gets a single attribute for an organization
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
@Get("/attributes/:attributeId")
async getOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string
): Promise<GetSingleAttributeOutput> {
const attribute = await this.organizationsAttributesService.getOrganizationAttribute(orgId, attributeId);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
// Creates an attribute for an organization
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/attributes")
async createOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() bodyAttribute: CreateOrganizationAttributeInput
): Promise<CreateOrganizationAttributesOutput> {
const attribute = await this.organizationsAttributesService.createOrganizationAttribute(
orgId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
// Updates an attribute for an organization
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Patch("/attributes/:attributeId")
async updateOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string,
@Body() bodyAttribute: UpdateOrganizationAttributeInput
): Promise<UpdateOrganizationAttributesOutput> {
const attribute = await this.organizationsAttributesService.updateOrganizationAttribute(
orgId,
attributeId,
bodyAttribute
);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
// Deletes an attribute for an organization
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@Delete("/attributes/:attributeId")
async deleteOrganizationAttribute(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("attributeId") attributeId: string
): Promise<DeleteOrganizationAttributesOutput> {
const attribute = await this.organizationsAttributesService.deleteOrganizationAttribute(
orgId,
attributeId
);
return {
status: SUCCESS_STATUS,
data: attribute,
};
}
}
@@ -0,0 +1,17 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class AssignOrganizationAttributeOptionToUserInput {
@IsOptional()
@IsString()
@IsNotEmpty()
readonly value?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
readonly attributeOptionId?: string;
@IsNotEmpty()
@IsString()
readonly attributeId!: string;
}
@@ -0,0 +1,35 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { AttributeType } from "@prisma/client";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export class CreateOrganizationAttributeInput {
@IsString()
@IsNotEmpty()
readonly name!: string;
@IsString()
@IsNotEmpty()
readonly slug!: string;
@IsEnum(AttributeType)
@IsNotEmpty()
readonly type!: AttributeType;
@IsArray()
@ValidateNested()
@Type(() => CreateOrganizationAttributeOptionInput)
readonly options!: CreateOrganizationAttributeOptionInput[];
@IsBoolean()
@IsOptional()
readonly enabled?: boolean;
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from "class-validator";
export class CreateOrganizationAttributeOptionInput {
@IsString()
@IsNotEmpty()
readonly value!: string;
@IsString()
@IsNotEmpty()
readonly slug!: string;
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
export class UpdateOrganizationAttributeOptionInput {
@IsString()
@IsOptional()
readonly value?: string;
@IsString()
@IsOptional()
readonly slug?: string;
}
@@ -0,0 +1,20 @@
import { AttributeType } from "@prisma/client";
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
export class UpdateOrganizationAttributeInput {
@IsString()
@IsOptional()
readonly name?: string;
@IsString()
@IsOptional()
readonly slug?: string;
@IsEnum(AttributeType)
@IsOptional()
readonly type?: AttributeType;
@IsBoolean()
@IsOptional()
readonly enabled?: boolean;
}
@@ -3,6 +3,8 @@ import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsOptionsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes-options.controller";
import { OrganizationsAttributesController } from "@/modules/organizations/controllers/attributes/organizations-attributes.controller";
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
@@ -11,6 +13,8 @@ import { OrganizationsTeamsController } from "@/modules/organizations/controller
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
@@ -18,6 +22,8 @@ import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository";
import { OrganizationAttributeOptionService } from "@/modules/organizations/services/attributes/organization-attributes-option.service";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
@@ -65,6 +71,10 @@ import { Module } from "@nestjs/common";
OrganizationsEventTypesRepository,
OrganizationsTeamsMembershipsRepository,
OrganizationsTeamsMembershipsService,
OrganizationAttributesService,
OrganizationAttributeOptionService,
OrganizationAttributeOptionRepository,
OrganizationAttributesRepository,
OrganizationsWebhooksRepository,
OrganizationsWebhooksService,
WebhooksRepository,
@@ -80,6 +90,10 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipService,
OrganizationsTeamsMembershipsRepository,
OrganizationsTeamsMembershipsService,
OrganizationAttributesService,
OrganizationAttributeOptionService,
OrganizationAttributeOptionRepository,
OrganizationAttributesRepository,
OrganizationsWebhooksRepository,
OrganizationsWebhooksService,
WebhooksRepository,
@@ -92,6 +106,8 @@ import { Module } from "@nestjs/common";
OrganizationsMembershipsController,
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
OrganizationsAttributesController,
OrganizationsOptionsAttributesController,
OrganizationsWebhooksController,
],
})
@@ -0,0 +1,71 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
export const AttributeType = {
TEXT: "TEXT",
NUMBER: "NUMBER",
SINGLE_SELECT: "SINGLE_SELECT",
MULTI_SELECT: "MULTI_SELECT",
} as const;
export type AttributeType = (typeof AttributeType)[keyof typeof AttributeType];
export class Attribute {
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_123" })
id!: string;
@IsInt()
@ApiProperty({
type: Number,
required: true,
description: "The team ID associated with the attribute",
example: 1,
})
teamId!: number;
@ApiProperty({
type: String,
required: true,
description: "The type of the attribute",
enum: AttributeType,
})
@IsEnum(AttributeType)
type!: AttributeType;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The name of the attribute",
example: "Attribute Name",
})
name!: string;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The slug of the attribute",
example: "attribute-name",
})
slug!: string;
@IsBoolean()
@ApiProperty({
type: Boolean,
required: true,
description: "Whether the attribute is enabled and displayed on their profile",
example: true,
})
enabled!: boolean;
@IsBoolean()
@ApiProperty({
type: Boolean,
required: false,
description: "Whether users can edit the relation",
example: true,
})
usersCanEditRelation!: boolean;
}
@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
export class BaseOutputDTO {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,11 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class CreateOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute;
}
@@ -0,0 +1,11 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class DeleteOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute;
}
@@ -0,0 +1,22 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsInt, IsString, IsEnum, ValidateNested, IsOptional } from "class-validator";
import { AttributeType } from "@calcom/prisma/client";
export class GetSingleAttributeOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@IsOptional()
@Type(() => Attribute)
data!: Attribute | null;
}
export class GetOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute[];
}
@@ -0,0 +1,33 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
class AssignOptionUserOutputData {
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
id!: string;
@IsString()
@ApiProperty({ type: Number, required: true, description: "The ID form the org membership for the user" })
memberId!: number;
@IsString()
@ApiProperty({ type: String, required: true, description: "The value of the option" })
attributeOptionId!: string;
}
export class AssignOptionUserOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => AssignOptionUserOutputData)
data!: AssignOptionUserOutputData;
}
export class UnassignOptionUserOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => AssignOptionUserOutputData)
data!: AssignOptionUserOutputData;
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class CreateAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput;
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class DeleteAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput;
}
@@ -0,0 +1,29 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
class GetOptionUserOutputData {
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the option assigned to the user" })
id!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute" })
attributeId!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The value of the option" })
value!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The slug of the option" })
slug!: string;
}
export class GetOptionUserOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => GetOptionUserOutputData)
data!: GetOptionUserOutputData[];
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class GetAllAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput[];
}
@@ -0,0 +1,36 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsString, IsInt, IsEnum, IsBoolean } from "class-validator";
export class OptionOutput {
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The ID of the option",
example: "attr_option_id",
})
id!: string;
@IsString()
@ApiProperty({ type: String, required: true, description: "The ID of the attribute", example: "attr_id" })
attributeId!: string;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The value of the option",
example: "option_value",
})
value!: string;
@IsString()
@ApiProperty({
type: String,
required: true,
description: "The slug of the option",
example: "option-slug",
})
slug!: string;
}
@@ -0,0 +1,11 @@
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { OptionOutput } from "@/modules/organizations/outputs/attributes/options/option.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class UpdateAttributeOptionOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => OptionOutput)
data!: OptionOutput;
}
@@ -0,0 +1,11 @@
import { Attribute } from "@/modules/organizations/outputs/attributes/attribute.output";
import { BaseOutputDTO } from "@/modules/organizations/outputs/attributes/base.output";
import { Expose, Type } from "class-transformer";
import { ValidateNested } from "class-validator";
export class UpdateOrganizationAttributesOutput extends BaseOutputDTO {
@Expose()
@ValidateNested()
@Type(() => Attribute)
data!: Attribute;
}
@@ -0,0 +1,160 @@
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationAttributeOptionRepository {
private readonly logger = new Logger("OrganizationAttributeOptionRepository");
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly organizationsMembershipsService: OrganizationsMembershipService
) {}
async createOrganizationAttributeOption(
organizationId: number,
attributeId: string,
data: CreateOrganizationAttributeOptionInput
) {
return this.dbWrite.prisma.attributeOption.create({
data: {
...data,
attributeId,
},
});
}
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
try {
const deletedAttributeOption = await this.dbWrite.prisma.attributeOption.delete({
where: {
id: optionId,
attributeId,
},
});
return deletedAttributeOption;
} catch (error: any) {
if (error.code === "P2025") {
// P2025 is the Prisma error code for "Record to delete does not exist."
this.logger.warn(`Attribute option not found: ${optionId}`);
throw new NotFoundException("Attribute option not found");
}
throw error;
}
}
async updateOrganizationAttributeOption(
organizationId: number,
attributeId: string,
optionId: string,
data: UpdateOrganizationAttributeOptionInput
) {
return this.dbWrite.prisma.attributeOption.update({
where: {
id: optionId,
attributeId,
},
data,
});
}
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
return this.dbRead.prisma.attributeOption.findMany({
where: {
attributeId,
},
});
}
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
const options = this.dbRead.prisma.attributeOption.findMany({
where: {
attribute: {
teamId: organizationId,
},
assignedUsers: {
some: {
member: {
userId,
},
},
},
},
});
return options;
}
async assignOrganizationAttributeOptionToUser({
organizationId,
membershipId,
attributeId,
value,
attributeOptionId,
}: {
organizationId: number;
membershipId: number;
attributeId: string;
value?: string;
attributeOptionId?: string;
}) {
let _attributeOptionId = attributeOptionId;
if (value && !attributeOptionId) {
_attributeOptionId = await this.createDynamicAttributeOption(organizationId, attributeId, value);
}
if (!_attributeOptionId) throw new Error("Attribute option not found");
return this.dbWrite.prisma.attributeToUser.create({
data: {
attributeOptionId: _attributeOptionId,
memberId: membershipId,
},
});
}
private async createDynamicAttributeOption(
organizationId: number,
attributeId: string,
value: string
): Promise<string> {
const attributeOption = await this.createOrganizationAttributeOption(organizationId, attributeId, {
value: value,
slug: slugify(value),
});
return attributeOption.id;
}
async unassignOrganizationAttributeOptionFromUser(
organizationId: number,
userId: number,
attributeOptionId: string
) {
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
organizationId,
userId
);
if (!membership) throw new Error("Membership not found");
try {
const deletedAttributeToUser = await this.dbWrite.prisma.attributeToUser.delete({
where: { memberId_attributeOptionId: { memberId: membership.id, attributeOptionId } },
});
return deletedAttributeToUser;
} catch (error: any) {
if (error.code === "P2025") {
// P2025 is the Prisma error code for "Record to delete does not exist."
this.logger.warn(`Attribute option not found: ${attributeOptionId} for user ${userId}`);
throw new NotFoundException("Attribute does not belong to this user");
}
throw error;
}
}
}
@@ -0,0 +1,77 @@
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationAttributesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
const { options, ...attributeData } = data;
const attribute = await this.dbWrite.prisma.attribute.create({
data: {
...attributeData,
teamId: organizationId,
},
});
if (attribute.type === "SINGLE_SELECT" || attribute.type === "MULTI_SELECT") {
// TODO: move this to attribute option service
await this.dbWrite.prisma.attributeOption.createMany({
data: options.map((option) => ({
...option,
attributeId: attribute.id,
})),
});
}
return attribute;
}
async getOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.dbRead.prisma.attribute.findUnique({
where: {
id: attributeId,
teamId: organizationId,
},
});
return attribute;
}
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
const attributes = await this.dbRead.prisma.attribute.findMany({
where: {
teamId: organizationId,
},
skip,
take,
});
return attributes;
}
async updateOrganizationAttribute(
organizationId: number,
attributeId: string,
data: UpdateOrganizationAttributeInput
) {
const attribute = await this.dbWrite.prisma.attribute.update({
where: {
id: attributeId,
teamId: organizationId,
},
data,
});
return attribute;
}
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.dbWrite.prisma.attribute.delete({
where: {
id: attributeId,
teamId: organizationId,
},
});
return attribute;
}
}
@@ -18,6 +18,15 @@ export class OrganizationsMembershipRepository {
});
}
async findOrgMembershipByUserId(organizationId: number, userId: number) {
return this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
userId,
},
});
}
async deleteOrgMembership(organizationId: number, membershipId: number) {
return this.dbWrite.prisma.membership.delete({
where: {
@@ -0,0 +1,114 @@
import { AssignOrganizationAttributeOptionToUserInput } from "@/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input";
import { CreateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input";
import { UpdateOrganizationAttributeOptionInput } from "@/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts";
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/repositories/attributes/organization-attribute-option.repository";
import { OrganizationAttributesService } from "@/modules/organizations/services/attributes/organization-attributes.service";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
const TYPE_SUPPORTS_VALUE = new Set(["TEXT", "NUMBER"]);
@Injectable()
export class OrganizationAttributeOptionService {
private readonly logger = new Logger("OrganizationAttributeOptionService");
constructor(
private readonly organizationAttributeOptionRepository: OrganizationAttributeOptionRepository,
private readonly organizationAttributesService: OrganizationAttributesService,
private readonly organizationsMembershipsService: OrganizationsMembershipService
) {}
async createOrganizationAttributeOption(
organizationId: number,
attributeId: string,
data: CreateOrganizationAttributeOptionInput
) {
return this.organizationAttributeOptionRepository.createOrganizationAttributeOption(
organizationId,
attributeId,
data
);
}
async deleteOrganizationAttributeOption(organizationId: number, attributeId: string, optionId: string) {
return this.organizationAttributeOptionRepository.deleteOrganizationAttributeOption(
organizationId,
attributeId,
optionId
);
}
async updateOrganizationAttributeOption(
organizationId: number,
attributeId: string,
optionId: string,
data: UpdateOrganizationAttributeOptionInput
) {
return this.organizationAttributeOptionRepository.updateOrganizationAttributeOption(
organizationId,
attributeId,
optionId,
data
);
}
async getOrganizationAttributeOptions(organizationId: number, attributeId: string) {
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptions(
organizationId,
attributeId
);
}
async assignOrganizationAttributeOptionToUser(
organizationId: number,
userId: number,
data: AssignOrganizationAttributeOptionToUserInput
) {
const attribute = await this.organizationAttributesService.getOrganizationAttribute(
organizationId,
data.attributeId
);
if (!attribute) {
throw new NotFoundException("Attribute not found");
}
const membership = await this.organizationsMembershipsService.getOrgMembershipByUserId(
organizationId,
userId
);
if (!membership || !membership.accepted)
throw new NotFoundException("User is not a member of the organization");
if (!TYPE_SUPPORTS_VALUE.has(attribute.type) && data.value) {
throw new BadRequestException("Attribute type does not support value");
}
return this.organizationAttributeOptionRepository.assignOrganizationAttributeOptionToUser({
organizationId,
membershipId: membership.id,
value: data.value,
attributeId: data.attributeId,
attributeOptionId: data.attributeOptionId,
});
}
async unassignOrganizationAttributeOptionFromUser(
organizationId: number,
userId: number,
attributeOptionId: string
) {
return this.organizationAttributeOptionRepository.unassignOrganizationAttributeOptionFromUser(
organizationId,
userId,
attributeOptionId
);
}
async getOrganizationAttributeOptionsForUser(organizationId: number, userId: number) {
return this.organizationAttributeOptionRepository.getOrganizationAttributeOptionsForUser(
organizationId,
userId
);
}
}
@@ -0,0 +1,36 @@
import { CreateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/create-organization-attribute.input";
import { UpdateOrganizationAttributeInput } from "@/modules/organizations/inputs/attributes/update-organization-attribute.input";
import { OrganizationAttributesRepository } from "@/modules/organizations/repositories/attributes/organization-attribute.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationAttributesService {
constructor(private readonly organizationAttributesRepository: OrganizationAttributesRepository) {}
async createOrganizationAttribute(organizationId: number, data: CreateOrganizationAttributeInput) {
const attribute = await this.organizationAttributesRepository.createOrganizationAttribute(organizationId, data);
return attribute;
}
async getOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.getOrganizationAttribute(organizationId, attributeId);
return attribute;
}
async getOrganizationAttributes(organizationId: number, skip?: number, take?: number) {
const attributes = await this.organizationAttributesRepository.getOrganizationAttributes(organizationId, skip, take);
return attributes;
}
async updateOrganizationAttribute(organizationId: number, attributeId: string, data: UpdateOrganizationAttributeInput) {
const attribute = await this.organizationAttributesRepository.updateOrganizationAttribute(organizationId, attributeId, data);
return attribute;
}
async deleteOrganizationAttribute(organizationId: number, attributeId: string) {
const attribute = await this.organizationAttributesRepository.deleteOrganizationAttribute(organizationId, attributeId);
return attribute;
}
}
@@ -16,6 +16,14 @@ export class OrganizationsMembershipService {
return membership;
}
async getOrgMembershipByUserId(organizationId: number, userId: number) {
const membership = await this.organizationsMembershipRepository.findOrgMembershipByUserId(
organizationId,
userId
);
return membership;
}
async getPaginatedOrgMemberships(organizationId: number, skip = 0, take = 250) {
const memberships = await this.organizationsMembershipRepository.findOrgMembershipsPaginated(
organizationId,
+962
View File
@@ -2285,6 +2285,492 @@
]
}
},
"/v2/organizations/{orgId}/attributes": {
"get": {
"operationId": "OrganizationsAttributesController_getOrganizationAttributes",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetOrganizationAttributesOutput"
}
}
}
}
}
},
"post": {
"operationId": "OrganizationsAttributesController_createOrganizationAttribute",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOrganizationAttributeInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOrganizationAttributesOutput"
}
}
}
}
}
}
},
"/v2/organizations/{orgId}/attributes/{attributeId}": {
"get": {
"operationId": "OrganizationsAttributesController_getOrganizationAttribute",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetSingleAttributeOutput"
}
}
}
}
}
},
"patch": {
"operationId": "OrganizationsAttributesController_updateOrganizationAttribute",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOrganizationAttributeInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOrganizationAttributesOutput"
}
}
}
}
}
},
"delete": {
"operationId": "OrganizationsAttributesController_deleteOrganizationAttribute",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteOrganizationAttributesOutput"
}
}
}
}
}
}
},
"/v2/organizations/{orgId}/attributes/{attributeId}/options": {
"post": {
"operationId": "OrganizationsOptionsAttributesController_createOrganizationAttributeOption",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateOrganizationAttributeOptionInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateAttributeOptionOutput"
}
}
}
}
}
},
"get": {
"operationId": "OrganizationsOptionsAttributesController_getOrganizationAttributeOptions",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetAllAttributeOptionOutput"
}
}
}
}
}
}
},
"/v2/organizations/{orgId}/attributes/{attributeId}/options/{optionId}": {
"delete": {
"operationId": "OrganizationsOptionsAttributesController_deleteOrganizationAttributeOption",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "optionId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAttributeOptionOutput"
}
}
}
}
}
},
"patch": {
"operationId": "OrganizationsOptionsAttributesController_updateOrganizationAttributeOption",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "optionId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOrganizationAttributeOptionInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateAttributeOptionOutput"
}
}
}
}
}
}
},
"/v2/organizations/{orgId}/attributes/options/{userId}": {
"post": {
"operationId": "OrganizationsOptionsAttributesController_assignOrganizationAttributeOptionToUser",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignOrganizationAttributeOptionToUserInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignOptionUserOutput"
}
}
}
}
}
},
"get": {
"operationId": "OrganizationsOptionsAttributesController_getOrganizationAttributeOptionsForUser",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetOptionUserOutput"
}
}
}
}
}
}
},
"/v2/organizations/{orgId}/attributes/options/{userId}/{attributeOptionId}": {
"delete": {
"operationId": "OrganizationsOptionsAttributesController_unassignOrganizationAttributeOptionFromUser",
"parameters": [
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "attributeOptionId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnassignOptionUserOutput"
}
}
}
}
}
}
},
"/v2/organizations/{orgId}/webhooks": {
"get": {
"operationId": "OrganizationsWebhooksController_getAllOrganizationWebhooks",
@@ -7578,6 +8064,482 @@
"userId"
]
},
"Attribute": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the attribute",
"example": "attr_123"
},
"teamId": {
"type": "number",
"description": "The team ID associated with the attribute",
"example": 1
},
"type": {
"type": "string",
"description": "The type of the attribute",
"enum": [
"TEXT",
"NUMBER",
"SINGLE_SELECT",
"MULTI_SELECT"
]
},
"name": {
"type": "string",
"description": "The name of the attribute",
"example": "Attribute Name"
},
"slug": {
"type": "string",
"description": "The slug of the attribute",
"example": "attribute-name"
},
"enabled": {
"type": "boolean",
"description": "Whether the attribute is enabled and displayed on their profile",
"example": true
},
"usersCanEditRelation": {
"type": "boolean",
"description": "Whether users can edit the relation",
"example": true
}
},
"required": [
"id",
"teamId",
"type",
"name",
"slug",
"enabled"
]
},
"GetOrganizationAttributesOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Attribute"
}
}
},
"required": [
"status",
"data"
]
},
"GetSingleAttributeOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/Attribute"
}
]
}
},
"required": [
"status",
"data"
]
},
"CreateOrganizationAttributeOptionInput": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"slug": {
"type": "string"
}
},
"required": [
"value",
"slug"
]
},
"CreateOrganizationAttributeInput": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"slug": {
"type": "string"
},
"type": {
"type": "object"
},
"options": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CreateOrganizationAttributeOptionInput"
}
},
"enabled": {
"type": "boolean"
}
},
"required": [
"name",
"slug",
"type",
"options"
]
},
"CreateOrganizationAttributesOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/Attribute"
}
},
"required": [
"status",
"data"
]
},
"UpdateOrganizationAttributeInput": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"slug": {
"type": "string"
},
"type": {
"type": "object"
},
"enabled": {
"type": "boolean"
}
}
},
"UpdateOrganizationAttributesOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/Attribute"
}
},
"required": [
"status",
"data"
]
},
"DeleteOrganizationAttributesOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/Attribute"
}
},
"required": [
"status",
"data"
]
},
"OptionOutput": {
"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"
}
},
"required": [
"id",
"attributeId",
"value",
"slug"
]
},
"CreateAttributeOptionOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/OptionOutput"
}
},
"required": [
"status",
"data"
]
},
"DeleteAttributeOptionOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/OptionOutput"
}
},
"required": [
"status",
"data"
]
},
"UpdateOrganizationAttributeOptionInput": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"slug": {
"type": "string"
}
}
},
"UpdateAttributeOptionOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/OptionOutput"
}
},
"required": [
"status",
"data"
]
},
"GetAllAttributeOptionOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OptionOutput"
}
}
},
"required": [
"status",
"data"
]
},
"AssignOrganizationAttributeOptionToUserInput": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"attributeOptionId": {
"type": "string"
},
"attributeId": {
"type": "string"
}
},
"required": [
"attributeId"
]
},
"AssignOptionUserOutputData": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the option assigned to the user"
},
"memberId": {
"type": "number",
"description": "The ID form the org membership for the user"
},
"attributeOptionId": {
"type": "string",
"description": "The value of the option"
}
},
"required": [
"id",
"memberId",
"attributeOptionId"
]
},
"AssignOptionUserOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/AssignOptionUserOutputData"
}
},
"required": [
"status",
"data"
]
},
"UnassignOptionUserOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/AssignOptionUserOutputData"
}
},
"required": [
"status",
"data"
]
},
"GetOptionUserOutputData": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the option assigned to the user"
},
"attributeId": {
"type": "string",
"description": "The ID of the attribute"
},
"value": {
"type": "string",
"description": "The value of the option"
},
"slug": {
"type": "string",
"description": "The slug of the option"
}
},
"required": [
"id",
"attributeId",
"value",
"slug"
]
},
"GetOptionUserOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GetOptionUserOutputData"
}
}
},
"required": [
"status",
"data"
]
},
"TeamWebhookOutputDto": {
"type": "object",
"properties": {
@@ -0,0 +1,30 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, User } from "@prisma/client";
export class AttributeRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async create(data: Prisma.AttributeCreateInput) {
return this.prismaWriteClient.attribute.create({ data });
}
async createOption(data: Prisma.AttributeOptionCreateInput) {
return this.prismaWriteClient.attributeOption.create({ data });
}
async delete(id: string) {
return this.prismaWriteClient.attribute.delete({ where: { id } });
}
async deleteOption(id: string) {
return this.prismaWriteClient.attributeOption.delete({ where: { id } });
}
}
+2 -4
View File
@@ -24,9 +24,7 @@
},
"buckets": {
"json": {
"include": [
"apps/web/public/static/locales/[locale]/common.json"
]
"include": ["apps/web/public/static/locales/[locale]/common.json"]
}
}
}
}