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:
co-authored by
Morgan
parent
b7c5cfcf02
commit
e6f561c3d7
+14
-4
@@ -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;
|
||||
}
|
||||
|
||||
+15
-4
@@ -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;
|
||||
}
|
||||
|
||||
+42
-10
@@ -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 };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+15
-5
@@ -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 {
|
||||
|
||||
+2
-5
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ export class ManagedOrganizationsService {
|
||||
...organizationData,
|
||||
isOrganization: true,
|
||||
isPlatform: true,
|
||||
metadata: JSON.stringify(organizationData.metadata || {}),
|
||||
metadata: organizationData.metadata,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+5
@@ -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()
|
||||
|
||||
+11
-13
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user