fix: v2 team metadata (#19939)

* refactor: allow metadata to also receive numbers and booleans

* refactor: Team metadata

* refactor: Managed orgs metadata

* refactor: Team metadata

* test: organizations teams create team with metadata

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2025-03-11 15:03:30 +00:00
committed by GitHub
co-authored by Morgan
parent b7c5cfcf02
commit e6f561c3d7
12 changed files with 184 additions and 61 deletions
@@ -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<string, unknown>;
@IsOptional()
@ValidateMetadata()
metadata?: Metadata;
}
@@ -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;
}
@@ -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<ManagedOrganizationWithApiKeyOutput> = 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 };
});
});
@@ -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<string, unknown>;
@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 {
@@ -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" });
}
}
@@ -42,7 +42,7 @@ export class ManagedOrganizationsService {
...organizationData,
isOrganization: true,
isPlatform: true,
metadata: JSON.stringify(organizationData.metadata || {}),
metadata: organizationData.metadata,
}
);
@@ -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);
@@ -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;
});
});
@@ -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()
@@ -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()
@@ -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<string, string | number | boolean>;
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;
}
@@ -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()