feat: v2 managed orgs filters (#22420)

* fix: don't allow managed orgs with same slug

* feat: filter managed orgs by slug or metadata

* test

* fix: tests
This commit is contained in:
Lauris Skraucis
2025-07-14 16:37:15 +02:00
committed by GitHub
parent 13739dabd8
commit 96a659ae5a
8 changed files with 285 additions and 21 deletions
@@ -0,0 +1,29 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsString } from "class-validator";
import { SkipTakePagination } from "@calcom/platform-types";
export class GetManagedOrganizationsInput_2024_08_13 extends SkipTakePagination {
@IsOptional()
@IsString()
@ApiProperty({ example: "organization-slug", description: "The slug of the managed organization" })
slug?: string;
@IsOptional()
@IsString()
@ApiProperty({
example: "metadata-key",
description:
"The key of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataValue",
})
metadataKey?: string;
@IsOptional()
@IsString()
@ApiProperty({
example: "metadata-value",
description:
"The value of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataKey",
})
metadataValue?: string;
}
@@ -1,3 +1,4 @@
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@@ -24,6 +25,17 @@ export class ManagedOrganizationsRepository {
});
}
async getManagedOrganizationBySlug(managerOrganizationId: number, managedOrganizationSlug: string) {
return this.dbRead.prisma.managedOrganization.findFirst({
where: {
managerOrganizationId,
managedOrganization: {
slug: managedOrganizationSlug,
},
},
});
}
async getByManagerManagedIds(managerOrganizationId: number, managedOrganizationId: number) {
return this.dbRead.prisma.managedOrganization.findUnique({
where: {
@@ -35,11 +47,26 @@ export class ManagedOrganizationsRepository {
});
}
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
const { skip, take } = pagination;
async getByManagerOrganizationIdPaginated(
managerOrganizationId: number,
query: GetManagedOrganizationsInput_2024_08_13
) {
const { skip, take, slug, metadataKey, metadataValue } = query;
const managedOrganizationFilter: Prisma.TeamWhereInput = {
slug,
};
if (metadataKey && metadataValue) {
managedOrganizationFilter.metadata = {
path: [metadataKey],
equals: metadataValue,
};
}
const where: Prisma.ManagedOrganizationWhereInput = {
managerOrganizationId,
managedOrganization: managedOrganizationFilter,
};
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
@@ -66,6 +66,7 @@ describe("Organizations Organizations Endpoints", () => {
let managerOrg: Team;
let managedOrg: ManagedOrganizationWithApiKeyOutput;
let managedOrg2: ManagedOrganizationWithApiKeyOutput;
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
let managerOrgAdmin: User;
let managerOrgAdminApiKey: string;
@@ -159,27 +160,36 @@ describe("Organizations Organizations Endpoints", () => {
.expect(400);
});
const suffix = randomString(5);
const metadataKey = "first-org-metadata-key";
const metadataValue = "first-org-metadata-value";
const createManagedOrganizationBody: CreateOrganizationInput = {
name: `org ${suffix}`,
slug: `org-${suffix}`,
metadata: { [metadataKey]: metadataValue },
};
const suffix2 = randomString(5);
const createManagedOrganizationBodySecond: CreateOrganizationInput = {
name: `org2 ${suffix2}`,
slug: `org2-${suffix2}`,
metadata: { key: "value" },
};
it("should create managed organization", async () => {
const suffix = randomString();
const body: CreateOrganizationInput = {
name: `organizations organizations org ${suffix}`,
metadata: { key: "value" },
};
return request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(body)
.send(createManagedOrganizationBody)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
managedOrg = responseBody.data;
expect(managedOrg?.id).toBeDefined();
expect(managedOrg?.name).toEqual(body.name);
expect(managedOrg?.slug).toEqual(slugify(body.name));
expect(managedOrg?.metadata).toEqual(body.metadata);
expect(managedOrg?.name).toEqual(createManagedOrganizationBody.name);
expect(managedOrg?.slug).toEqual(createManagedOrganizationBody.slug);
expect(managedOrg?.metadata).toEqual(createManagedOrganizationBody.metadata);
expect(managedOrg?.apiKey).toBeDefined();
// note(Lauris): check that managed organization is correctly setup in database
@@ -284,6 +294,36 @@ describe("Organizations Organizations Endpoints", () => {
});
});
it("should not create managed organization if slug already exists", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(createManagedOrganizationBody)
.expect(409);
expect(response.body.error.message).toBe(
`Organization with slug '${createManagedOrganizationBody.slug}' already exists. Please, either provide a different slug or change name so that the automatically generated slug is different.`
);
});
it("should create second managed organization", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.send(createManagedOrganizationBodySecond)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
managedOrg2 = responseBody.data;
expect(managedOrg2?.id).toBeDefined();
expect(managedOrg2?.name).toEqual(createManagedOrganizationBodySecond.name);
expect(managedOrg2?.slug).toEqual(createManagedOrganizationBodySecond.slug);
expect(managedOrg2?.metadata).toEqual(createManagedOrganizationBodySecond.metadata);
expect(managedOrg2?.apiKey).toBeDefined();
});
});
it("should get managed organization", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
@@ -304,12 +344,69 @@ describe("Organizations Organizations Endpoints", () => {
.get(`/v2/organizations/${managerOrg.id}/organizations`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(2);
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
const responseManagedOrg2 = responseManagedOrgs.find((org) => org.id === managedOrg2.id);
expect(responseManagedOrg2?.id).toBeDefined();
expect(responseManagedOrg2?.name).toEqual(managedOrg2.name);
expect(responseManagedOrg2?.metadata).toEqual(managedOrg2.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(2);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(2);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
it("should get managed organization by slug", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${managerOrg.id}/organizations?slug=${managedOrg.slug}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
const responseManagedOrg = responseManagedOrgs[0];
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
expect(responseBody.pagination).toBeDefined();
expect(responseBody.pagination.totalItems).toEqual(1);
expect(responseBody.pagination.remainingItems).toEqual(0);
expect(responseBody.pagination.returnedItems).toEqual(1);
expect(responseBody.pagination.itemsPerPage).toEqual(250);
expect(responseBody.pagination.currentPage).toEqual(1);
expect(responseBody.pagination.totalPages).toEqual(1);
});
});
it("should get managed organization by metadata key", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${managerOrg.id}/organizations?metadataKey=${metadataKey}&metadataValue=${metadataValue}`
)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.expect(200)
.then(async (response) => {
const responseBody: GetManagedOrganizationsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responseManagedOrgs = responseBody.data;
expect(responseManagedOrgs?.length).toEqual(1);
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
expect(responseManagedOrg?.id).toBeDefined();
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
@@ -465,7 +562,40 @@ describe("Organizations Organizations Endpoints", () => {
});
});
it("should delete managed organization ", async () => {
it("should delete managed organization", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg2.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
.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?.id).toEqual(managedOrg2.id);
expect(responseManagedOrg?.name).toEqual(managedOrg2.name);
const managedOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg2.id);
expect(managedOrgInDb).toEqual(null);
const billings = await platformBillingRepositoryFixture.getByCustomerSubscriptionIds(
managerOrgBilling.customerId,
managerOrgBilling.subscriptionId!
);
expect(billings).toBeDefined();
// note(Lauris): manager billing is left and other managed org
expect(billings?.length).toEqual(2);
const managerOrgInDb =
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managerOrg.id);
expect(managerOrgInDb).toBeDefined();
expect(managerOrgInDb?.id).toEqual(managerOrg.id);
expect(managerOrgInDb?.managedOrganizations?.length).toEqual(1);
});
});
it("should delete managed organization", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
@@ -11,6 +11,7 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { CreateManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/create-managed-organization.output";
import { GetManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/get-managed-organization.output";
@@ -102,10 +103,10 @@ export class OrganizationsOrganizationsController {
})
async getOrganizations(
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
@Query() queryPagination: SkipTakePagination
@Query() query: GetManagedOrganizationsInput_2024_08_13
): Promise<GetManagedOrganizationsOutput> {
const { organizations, pagination: responsePagination } =
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, query);
return {
status: SUCCESS_STATUS,
data: organizations,
@@ -5,11 +5,12 @@ import { ManagedOrganizationsBillingService } from "@/modules/billing/services/m
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import { ManagedOrganizationsOutputService } from "@/modules/organizations/organizations/services/managed-organizations-output.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { slugify } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@@ -44,6 +45,18 @@ export class ManagedOrganizationsService {
organizationData.slug = slugify(organizationData.name);
}
const existingManagedOrganization =
await this.managedOrganizationsRepository.getManagedOrganizationBySlug(
managerOrganizationId,
organizationData.slug
);
if (existingManagedOrganization) {
throw new ConflictException(
`Organization with slug '${organizationData.slug}' already exists. Please, either provide a different slug or change name so that the automatically generated slug is different.`
);
}
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
managerOrganizationId,
{
@@ -101,11 +114,14 @@ export class ManagedOrganizationsService {
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
}
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
async getManagedOrganizations(
managerOrganizationId: number,
query: GetManagedOrganizationsInput_2024_08_13
) {
const { items: managedOrganizations, totalItems } =
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
managerOrganizationId,
pagination
query
);
return {
@@ -113,7 +129,7 @@ export class ManagedOrganizationsService {
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
),
pagination: getPagination({
...pagination,
...query,
totalCount: totalItems,
}),
};