From 2de33fe1bf32512c66a061e284a73f89703b767c Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 5 Sep 2024 09:15:12 +0100 Subject: [PATCH] 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 --- apps/api/v2/package.json | 2 + ...rganization-attributes-options.e2e-spec.ts | 250 +++++ .../organization-attributes.e2e-spec.ts | 213 ++++ ...nizations-attributes-options.controller.ts | 173 ++++ .../organizations-attributes.controller.ts | 129 +++ ...zations-attributes-options-assign.input.ts | 17 + .../create-organization-attribute.input.ts | 35 + ...ate-organization-attribute-option.input.ts | 11 + ...-organizaiton-attribute-option.input.ts.ts | 11 + .../update-organization-attribute.input.ts | 20 + .../organizations/organizations.module.ts | 16 + .../outputs/attributes/attribute.output.ts | 71 ++ .../outputs/attributes/base.output.ts | 11 + .../create-organization-attributes.output.ts | 11 + .../delete-organization-attributes.output.ts | 11 + .../get-organization-attributes.output.ts | 22 + .../options/assign-option-user.output.ts | 33 + .../options/create-option.output.ts | 11 + .../options/delete-option.output.ts | 11 + .../options/get-option-user.output.ts | 29 + .../attributes/options/get-option.output.ts | 11 + .../attributes/options/option.output.ts | 36 + .../options/update-option.output.ts | 11 + .../update-organization-attributes.output.ts | 11 + ...rganization-attribute-option.repository.ts | 160 +++ .../organization-attribute.repository.ts | 77 ++ .../organizations-membership.repository.ts | 9 + .../organization-attributes-option.service.ts | 114 +++ .../organization-attributes.service.ts | 36 + .../organizations-membership.service.ts | 8 + apps/api/v2/swagger/documentation.json | 962 ++++++++++++++++++ .../attributes.repository.fixture.ts | 30 + i18n.json | 6 +- 33 files changed, 2554 insertions(+), 4 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts create mode 100644 apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts create mode 100644 apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts create mode 100644 apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts create mode 100644 apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts create mode 100644 apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts create mode 100644 apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts create mode 100644 apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 7971b4ebab..10f4c71ecc 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -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" diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.ts new file mode 100644 index 0000000000..c291190ef9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes-options.e2e-spec.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(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts new file mode 100644 index 0000000000..1365670a19 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organization-attributes.e2e-spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts new file mode 100644 index 0000000000..44c0378223 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes-options.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const attributeOptions = + await this.organizationsAttributesOptionsService.getOrganizationAttributeOptionsForUser(orgId, userId); + return { + status: SUCCESS_STATUS, + data: attributeOptions, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts new file mode 100644 index 0000000000..0464c6443b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/attributes/organizations-attributes.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const attribute = await this.organizationsAttributesService.deleteOrganizationAttribute( + orgId, + attributeId + ); + return { + status: SUCCESS_STATUS, + data: attribute, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts new file mode 100644 index 0000000000..6654e4eb69 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/assign/organizations-attributes-options-assign.input.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts new file mode 100644 index 0000000000..3d5abd7882 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/create-organization-attribute.input.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts new file mode 100644 index 0000000000..c255495586 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/options/create-organization-attribute-option.input.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from "class-validator"; + +export class CreateOrganizationAttributeOptionInput { + @IsString() + @IsNotEmpty() + readonly value!: string; + + @IsString() + @IsNotEmpty() + readonly slug!: string; +} \ No newline at end of file diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts new file mode 100644 index 0000000000..4fdc26abae --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/options/update-organizaiton-attribute-option.input.ts.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class UpdateOrganizationAttributeOptionInput { + @IsString() + @IsOptional() + readonly value?: string; + + @IsString() + @IsOptional() + readonly slug?: string; +} diff --git a/apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts b/apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts new file mode 100644 index 0000000000..e68f9dc433 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/inputs/attributes/update-organization-attribute.input.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index 54155ba02e..82e381396e 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -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, ], }) diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts new file mode 100644 index 0000000000..bdf05d3f40 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/attribute.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts new file mode 100644 index 0000000000..21bf06f1b7 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/base.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts new file mode 100644 index 0000000000..aa2ff2f547 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/create-organization-attributes.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts new file mode 100644 index 0000000000..5aa36314c9 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/delete-organization-attributes.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts new file mode 100644 index 0000000000..a4104834eb --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/get-organization-attributes.output.ts @@ -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[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts new file mode 100644 index 0000000000..fdb8fae13d --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/assign-option-user.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts new file mode 100644 index 0000000000..81f83e67f1 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/create-option.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts new file mode 100644 index 0000000000..6b6203e8b0 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/delete-option.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts new file mode 100644 index 0000000000..6f29020fd3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option-user.output.ts @@ -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[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts new file mode 100644 index 0000000000..11b9cd5066 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/get-option.output.ts @@ -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[]; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts new file mode 100644 index 0000000000..f3c202fc0a --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/option.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts new file mode 100644 index 0000000000..14b85aa2de --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/options/update-option.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts b/apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts new file mode 100644 index 0000000000..3aa5b2c9b4 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/outputs/attributes/update-organization-attributes.output.ts @@ -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; +} diff --git a/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts new file mode 100644 index 0000000000..150965bdf0 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute-option.repository.ts @@ -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 { + 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; + } + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts new file mode 100644 index 0000000000..0b3a7314d3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/attributes/organization-attribute.repository.ts @@ -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; + } +} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts index 2f2d3dd948..eca3f5afdd 100644 --- a/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-membership.repository.ts @@ -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: { diff --git a/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts new file mode 100644 index 0000000000..c0f73b34b3 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes-option.service.ts @@ -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 + ); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts new file mode 100644 index 0000000000..2e2891fecc --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/attributes/organization-attributes.service.ts @@ -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; + } + +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts index c1a393dbda..7ca9490d2a 100644 --- a/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/services/organizations-membership.service.ts @@ -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, diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 539c6ea9b8..fb4fbc1086 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -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": { diff --git a/apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts new file mode 100644 index 0000000000..c33d247a6a --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/attributes.repository.fixture.ts @@ -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 } }); + } +} diff --git a/i18n.json b/i18n.json index 2761b147de..c81e05b176 100644 --- a/i18n.json +++ b/i18n.json @@ -24,9 +24,7 @@ }, "buckets": { "json": { - "include": [ - "apps/web/public/static/locales/[locale]/common.json" - ] + "include": ["apps/web/public/static/locales/[locale]/common.json"] } } -} \ No newline at end of file +}