diff --git a/apps/api/v2/src/modules/organizations/organizations/inputs/create-managed-organization.input.ts b/apps/api/v2/src/modules/organizations/organizations/inputs/create-managed-organization.input.ts index 0129261c89..c0d71cdf34 100644 --- a/apps/api/v2/src/modules/organizations/organizations/inputs/create-managed-organization.input.ts +++ b/apps/api/v2/src/modules/organizations/organizations/inputs/create-managed-organization.input.ts @@ -1,16 +1,26 @@ import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Type } from "class-transformer"; import { IsObject, IsOptional, IsString, Length } from "class-validator"; +import { + Metadata, + METADATA_DOCS, + ValidateMetadata, +} from "@calcom/platform-types/bookings/2024-08-13/inputs/validators/validate-metadata"; + export class CreateOrganizationInput extends RefreshApiKeyInput { @IsString() @Length(1) @ApiProperty({ description: "Name of the organization", example: "CalTeam" }) readonly name!: string; - @IsOptional() + @ApiPropertyOptional({ + type: Object, + description: METADATA_DOCS, + example: { key: "value" }, + }) @IsObject() - @ApiPropertyOptional({ type: Object }) - readonly metadata?: Record; + @IsOptional() + @ValidateMetadata() + metadata?: Metadata; } diff --git a/apps/api/v2/src/modules/organizations/organizations/inputs/update-managed-organization.input.ts b/apps/api/v2/src/modules/organizations/organizations/inputs/update-managed-organization.input.ts index 51210ba6d7..7a0bf47021 100644 --- a/apps/api/v2/src/modules/organizations/organizations/inputs/update-managed-organization.input.ts +++ b/apps/api/v2/src/modules/organizations/organizations/inputs/update-managed-organization.input.ts @@ -1,5 +1,11 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsString, Length } from "class-validator"; +import { IsObject, IsOptional, IsString, Length } from "class-validator"; + +import { + Metadata, + METADATA_DOCS, + ValidateMetadata, +} from "@calcom/platform-types/bookings/2024-08-13/inputs/validators/validate-metadata"; export class UpdateOrganizationInput { @IsString() @@ -8,8 +14,13 @@ export class UpdateOrganizationInput { @ApiPropertyOptional({ description: "Name of the organization", example: "CalTeam" }) readonly name?: string; + @ApiPropertyOptional({ + type: Object, + description: METADATA_DOCS, + example: { key: "value" }, + }) + @IsObject() @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly metadata?: string; + @ValidateMetadata() + metadata?: Metadata; } diff --git a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts index ab54755564..f29e41341f 100644 --- a/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/organizations/organizations-organizations.controller.e2e-spec.ts @@ -142,6 +142,21 @@ describe("Organizations Organizations Endpoints", () => { clear(); }); + it("should not create managed organization with string metadata", async () => { + const suffix = randomString(); + + const body = { + name: `organizations organizations org ${suffix}`, + metadata: JSON.stringify({ key: "value" }), + }; + + return request(app.getHttpServer()) + .post(`/v2/organizations/${managerOrg.id}/organizations`) + .set("Authorization", `Bearer ${managerOrgAdminApiKey}`) + .send(body) + .expect(400); + }); + it("should create managed organization", async () => { const suffix = randomString(); @@ -298,31 +313,48 @@ describe("Organizations Organizations Endpoints", () => { }); }); - it("should update managed organization ", async () => { - const suffix = randomString(); - - const newOrgName = `new organizations organizations org ${suffix}`; + it("should not update managed organization with string metadata", async () => { + const body = { + metadata: JSON.stringify({ key: "value" }), + }; return request(app.getHttpServer()) .patch(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`) .set("Authorization", `Bearer ${managerOrgAdminApiKey}`) - .send({ - name: newOrgName, - } satisfies UpdateOrganizationInput) + .send(body) + .expect(400); + }); + + it("should update managed organization ", async () => { + const name = `new organizations organizations org ${randomString()}`; + const metadata = { + updatedKey: "updatedValue", + }; + const body: UpdateOrganizationInput = { + name, + metadata, + }; + + return request(app.getHttpServer()) + .patch(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`) + .set("Authorization", `Bearer ${managerOrgAdminApiKey}`) + .send(body) .expect(200) .then(async (response) => { const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); const responseManagedOrg = responseBody.data; expect(responseManagedOrg?.id).toBeDefined(); - expect(responseManagedOrg?.name).toEqual(newOrgName); + expect(responseManagedOrg?.name).toEqual(name); + expect(responseManagedOrg?.metadata).toEqual(metadata); const managedOrgInDb = await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg.id); expect(managedOrgInDb).toBeDefined(); - expect(managedOrgInDb?.name).toEqual(newOrgName); + expect(managedOrgInDb?.name).toEqual(name); + expect(managedOrgInDb?.metadata).toEqual(metadata); - managedOrg = { ...managedOrg, name: newOrgName }; + managedOrg = { ...managedOrg, name, metadata }; }); }); diff --git a/apps/api/v2/src/modules/organizations/organizations/outputs/managed-organization.output.ts b/apps/api/v2/src/modules/organizations/organizations/outputs/managed-organization.output.ts index da11e3a0d6..38712a695b 100644 --- a/apps/api/v2/src/modules/organizations/organizations/outputs/managed-organization.output.ts +++ b/apps/api/v2/src/modules/organizations/organizations/outputs/managed-organization.output.ts @@ -1,7 +1,9 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Expose, Type } from "class-transformer"; +import { Expose, Transform, Type } from "class-transformer"; import { IsBoolean, IsInt, IsObject, IsOptional, IsString, Length, ValidateNested } from "class-validator"; +import { Metadata } from "@calcom/platform-types/bookings/2024-08-13/inputs/validators/validate-metadata"; + export class ManagedOrganizationOutput { @IsInt() @Expose() @@ -14,12 +16,20 @@ export class ManagedOrganizationOutput { @ApiProperty() readonly name!: string; - @IsOptional() + @ApiPropertyOptional({ + type: Object, + example: { key: "value" }, + }) @IsObject() + @IsOptional() @Expose() - @ApiPropertyOptional({ type: Object }) - @Type(() => Object) - readonly metadata?: Record; + @Transform( + // note(Lauris): added this transform because without it metadata is removed for some reason + ({ obj }: { obj: { metadata: Metadata | null | undefined } }) => { + return obj.metadata || undefined; + } + ) + metadata?: Metadata; } export class ManagedOrganizationWithApiKeyOutput extends ManagedOrganizationOutput { diff --git a/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations-output.service.ts b/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations-output.service.ts index 66920f7e8f..d4b9ff13fc 100644 --- a/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations-output.service.ts +++ b/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations-output.service.ts @@ -1,15 +1,12 @@ import { ManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/managed-organization.output"; import { Injectable } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; import { Team } from "@calcom/prisma/client"; @Injectable() export class ManagedOrganizationsOutputService { getOutputManagedOrganization(managedOrganization: Team): ManagedOrganizationOutput { - return { - id: managedOrganization.id, - name: managedOrganization.name, - metadata: managedOrganization.metadata ? JSON.parse(managedOrganization.metadata as string) : {}, - }; + return plainToClass(ManagedOrganizationOutput, managedOrganization, { strategy: "excludeAll" }); } } diff --git a/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts b/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts index fc5ed226ae..752183783d 100644 --- a/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts +++ b/apps/api/v2/src/modules/organizations/organizations/services/managed-organizations.service.ts @@ -42,7 +42,7 @@ export class ManagedOrganizationsService { ...organizationData, isOrganization: true, isPlatform: true, - metadata: JSON.stringify(organizationData.metadata || {}), + metadata: organizationData.metadata, } ); diff --git a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts index fc3ef6781e..af61e236da 100644 --- a/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/index/organizations-teams.controller.e2e-spec.ts @@ -575,11 +575,15 @@ describe("Organizations Team Endpoints", () => { it("should create first oAuth client team", async () => { const teamName = `organizations-teams-platform-api-team1-${randomString()}`; + const teamMetadata = { + key: `value ${randomString()}`, + }; return request(app.getHttpServer()) .post(`/v2/organizations/${org.id}/teams`) .set(X_CAL_CLIENT_ID, oAuthClient1.id) .send({ name: teamName, + metadata: teamMetadata, } satisfies CreateOrgTeamDto) .expect(201) .then(async (response) => { @@ -587,6 +591,7 @@ describe("Organizations Team Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); team1 = responseBody.data; expect(team1.name).toEqual(teamName); + expect(team1.metadata).toEqual(teamMetadata); expect(team1.parentId).toEqual(org.id); const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(user.id, team1.id); diff --git a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts index 21f88cdf0e..f6ec86da58 100644 --- a/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/teams/controllers/teams.controller.e2e-spec.ts @@ -73,9 +73,12 @@ describe("Teams endpoint", () => { }); describe("User has membership in created team", () => { - it("should create a team", async () => { + it("should create first team", async () => { const body: CreateTeamInput = { name: `teams-dog-${randomString()}`, + metadata: { + teamKey: "teamValue", + }, }; return request(app.getHttpServer()) @@ -90,13 +93,30 @@ describe("Teams endpoint", () => { expect(responseData).toBeDefined(); expect(responseData.id).toBeDefined(); expect(responseData.name).toEqual(body.name); + expect(responseData.metadata).toEqual(body.metadata); team1 = responseData; }); }); - it("should create a team", async () => { + it("should not create a team with string metadata", async () => { + const body = { + name: `teams-dog-${randomString()}`, + metadata: JSON.stringify({ + teamKey: "teamValue", + }), + }; + + return request(app.getHttpServer()) + .post("/v2/teams") + .send(body) + .set({ Authorization: `Bearer cal_test_${aliceApiKey}` }) + .expect(400); + }); + + it("should create second team", async () => { const body: CreateTeamInput = { name: `teams-cats-${randomString()}`, + metadata: {}, }; return request(app.getHttpServer()) @@ -111,6 +131,7 @@ describe("Teams endpoint", () => { expect(responseData).toBeDefined(); expect(responseData.id).toBeDefined(); expect(responseData.name).toEqual(body.name); + expect(responseData.metadata).toEqual(body.metadata); team2 = responseData; }); }); @@ -139,16 +160,21 @@ describe("Teams endpoint", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(responseBody.data.length).toEqual(2); - expect(responseBody.data[0].id).toBeDefined(); - expect(responseBody.data[0].name).toEqual(team1.name); - expect(responseBody.data[1].id).toBeDefined(); - expect(responseBody.data[1].name).toEqual(team2.name); + + const responseTeam1 = responseBody.data.find((team) => team.id === team1.id); + const responseTeam2 = responseBody.data.find((team) => team.id === team2.id); + + expect(responseTeam1).toBeDefined(); + expect(responseTeam2).toBeDefined(); }); }); it("should update a team", async () => { const body: UpdateTeamDto = { name: `teams-dogs-shepherds-${randomString()}`, + metadata: { + teamKey: `teamValue ${randomString()}`, + }, }; return request(app.getHttpServer()) @@ -162,6 +188,7 @@ describe("Teams endpoint", () => { expect(responseBody.data).toBeDefined(); expect(responseBody.data.id).toBeDefined(); expect(responseBody.data.name).toEqual(body.name); + expect(responseBody.data.metadata).toEqual(body.metadata); team1 = responseBody.data; }); }); diff --git a/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts index c28a92609c..1f2d7ffbef 100644 --- a/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts +++ b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { IsBoolean, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +import { + Metadata, + METADATA_DOCS, + ValidateMetadata, +} from "@calcom/platform-types/bookings/2024-08-13/inputs/validators/validate-metadata"; export class CreateTeamInput { @IsString() @@ -56,10 +62,15 @@ export class CreateTeamInput { @ApiPropertyOptional() readonly hideBookATeamMember?: boolean; + @ApiPropertyOptional({ + type: Object, + description: METADATA_DOCS, + example: { key: "value" }, + }) + @IsObject() @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + @ValidateMetadata() + metadata?: Metadata; @IsOptional() @IsString() diff --git a/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts index 49685659e9..7a5bf9c6e3 100644 --- a/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts +++ b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts @@ -1,5 +1,11 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { IsBoolean, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +import { + Metadata, + METADATA_DOCS, + ValidateMetadata, +} from "@calcom/platform-types/bookings/2024-08-13/inputs/validators/validate-metadata"; export class UpdateTeamDto { @IsString() @@ -56,10 +62,15 @@ export class UpdateTeamDto { @ApiPropertyOptional() readonly hideBookATeamMember?: boolean; + @ApiPropertyOptional({ + type: Object, + description: METADATA_DOCS, + example: { key: "value" }, + }) + @IsObject() @IsOptional() - @IsString() - @ApiPropertyOptional() - readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object. + @ValidateMetadata() + metadata?: Metadata; @IsOptional() @IsString() diff --git a/packages/platform/types/bookings/2024-08-13/inputs/validators/validate-metadata.ts b/packages/platform/types/bookings/2024-08-13/inputs/validators/validate-metadata.ts index 8b6f910ba8..d8352f00de 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/validators/validate-metadata.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/validators/validate-metadata.ts @@ -1,6 +1,12 @@ import type { ValidationOptions, ValidationArguments } from "class-validator"; import { registerDecorator } from "class-validator"; +export const METADATA_DOCS = `You can store any additional data you want here. +Metadata must have at most 50 keys, each key up to 40 characters. +Values can be strings (up to 500 characters), numbers, or booleans.`; + +export type Metadata = Record; + export function ValidateMetadata(validationOptions?: ValidationOptions) { return function (object: any, propertyName: string) { registerDecorator({ @@ -41,21 +47,13 @@ export function ValidateMetadata(validationOptions?: ValidationOptions) { } function isInvalidValidKeyValue(keyValue: any) { - if (!isString(keyValue)) { - return true; + if (typeof keyValue === "number" || typeof keyValue === "boolean") { + return false; } - if (isTooLong(keyValue)) { - return true; + if (typeof keyValue === "string") { + return keyValue.length > 500; } - return false; -} - -function isString(keyValue: any): keyValue is string { - return typeof keyValue === "string"; -} - -function isTooLong(keyValue: string) { - return keyValue.length > 500; + return true; } diff --git a/packages/platform/types/teams/outputs/team.output.ts b/packages/platform/types/teams/outputs/team.output.ts index 1c59c3099e..e01da271c3 100644 --- a/packages/platform/types/teams/outputs/team.output.ts +++ b/packages/platform/types/teams/outputs/team.output.ts @@ -1,6 +1,8 @@ import { ApiProperty as DocsProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Expose } from "class-transformer"; -import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { Expose, Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator"; + +import type { Metadata } from "@calcom/platform-types/bookings/2024-08-13/inputs/validators/validate-metadata"; export class TeamOutputDto { @IsInt() @@ -79,11 +81,20 @@ export class TeamOutputDto { @ApiPropertyOptional() readonly hideBookATeamMember?: boolean = false; + @ApiPropertyOptional({ + type: Object, + example: { key: "value" }, + }) + @IsObject() @IsOptional() - @IsString() @Expose() - @ApiPropertyOptional() - readonly metadata?: string; + @Transform( + // note(Lauris): added this transform because without it metadata is removed for some reason + ({ obj }: { obj: { metadata: Metadata | null | undefined } }) => { + return obj.metadata || undefined; + } + ) + metadata?: Metadata; @IsOptional() @IsString()