Skip to content
2 changes: 1 addition & 1 deletion apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.305",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.306",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,10 @@ export class EventTypesPrivateLinksController {
async updatePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: UpdatePrivateLinkBody,
@GetUser("id") userId: number
@Body() body: UpdatePrivateLinkBody
): Promise<UpdatePrivateLinkOutput> {
const updateInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);

return {
status: SUCCESS_STATUS,
Expand All @@ -88,10 +87,9 @@ export class EventTypesPrivateLinksController {
@ApiOperation({ summary: "Delete a private link for an event type" })
async deletePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@GetUser("id") userId: number
@Param("linkId") linkId: string
): Promise<DeletePrivateLinkOutput> {
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);

return {
status: SUCCESS_STATUS,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_API_KEY_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
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 { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
} from "@calcom/platform-types";

import { PrivateLinksService } from "../services/private-links.service";

@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links",
version: API_VERSIONS_VALUES,
})
@DocsTags("Orgs / Teams / Event Types / Private Links")
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesPrivateLinksController {
constructor(
private readonly privateLinksService: PrivateLinksService,
private readonly teamsEventTypesService: TeamsEventTypesService
) {}

@Post("/")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Create a private link for a team event type" })
async createPrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: CreatePrivateLinkInput
): Promise<CreatePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
// Use teamId as the seed for link generation in org/team context
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, teamId, body);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}

@Get("/")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Get all private links for a team event type" })
async getPrivateLinks(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<GetPrivateLinksOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
return {
status: SUCCESS_STATUS,
data: privateLinks,
};
}

@Patch("/:linkId")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Update a private link for a team event type" })
async updatePrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: Omit<UpdatePrivateLinkInput, "linkId">
): Promise<UpdatePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
const updateInput: UpdatePrivateLinkInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
return {
Comment on lines +90 to +95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix: Using TypeScript Omit drops validation metadata; use OmitType DTO instead

Using a TS utility type here prevents class-validator/class-transformer from applying the decorators on UpdatePrivateLinkInput. Define a DTO via OmitType (like in EventTypesPrivateLinksController) and use it in the method signature.

Apply:

-import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
+import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";

Introduce a DTO (near other imports or above the controller):

class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}

And update the method signature:

-    @Body() body: Omit<UpdatePrivateLinkInput, "linkId">
+    @Body() body: UpdatePrivateLinkBody

status: SUCCESS_STATUS,
data: privateLink,
};
}

@Delete("/:linkId")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiOperation({ summary: "Delete a private link for a team event type" })
async deletePrivateLink(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string
): Promise<DeletePrivateLinkOutput> {
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
return {
status: SUCCESS_STATUS,
data: {
linkId,
message: "Private link deleted successfully",
},
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { PrivateLinksOutputService } from "./services/private-links-output.servi
import { PrivateLinksService } from "./services/private-links.service";

@Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
imports: [TokensModule, PrismaModule, EventTypesModule_2024_06_14],
controllers: [EventTypesPrivateLinksController],
providers: [
PrivateLinksService,
Expand All @@ -21,5 +21,6 @@ import { PrivateLinksService } from "./services/private-links.service";
PrivateLinksRepository,
EventTypeOwnershipGuard,
],
exports: [PrivateLinksService],
})
export class EventTypesPrivateLinksModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,7 @@ export class PrivateLinksService {
}
}

async updatePrivateLink(
eventTypeId: number,
userId: number,
input: UpdatePrivateLinkInput
): Promise<PrivateLinkOutput> {
async updatePrivateLink(eventTypeId: number, input: UpdatePrivateLinkInput): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformUpdateInput(input);
const updatedResult = await this.repo.update(eventTypeId, {
Expand Down Expand Up @@ -106,7 +102,7 @@ export class PrivateLinksService {
}
}

async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
async deletePrivateLink(eventTypeId: number, linkId: string): Promise<void> {
try {
const { count } = await this.repo.delete(eventTypeId, linkId);
if (count === 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
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 { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreatePrivateLinkInput } from "@calcom/platform-types";

describe("Organizations / Teams / Event Types / Private Links Endpoints", () => {
let app: INestApplication;

let orgFixture: OrganizationRepositoryFixture;
let teamFixture: TeamRepositoryFixture;
let userFixture: UserRepositoryFixture;
let eventTypesFixture: EventTypesRepositoryFixture;

let org: any;
let team: any;
let user: any;
let eventType: any;

const userEmail = `org-private-links-user-${randomString()}@api.com`;

beforeAll(async () => {
const testingModuleBuilder = withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
)
// Bypass org admin plan and admin API checks and roles in this e2e
.overrideGuard(PlatformPlanGuard)
.useValue({ canActivate: () => true })
.overrideGuard(IsAdminAPIEnabledGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
// Keep IsOrgGuard and IsTeamInOrg to validate org/team path integrity
.overrideGuard(ApiAuthGuard)
.useValue({ canActivate: () => true });

const moduleRef = await testingModuleBuilder.compile();

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);

orgFixture = new OrganizationRepositoryFixture(moduleRef);
teamFixture = new TeamRepositoryFixture(moduleRef);
userFixture = new UserRepositoryFixture(moduleRef);
eventTypesFixture = new EventTypesRepositoryFixture(moduleRef);

user = await userFixture.create({
email: userEmail,
username: `org-private-links-user-${randomString()}`,
name: "Test User",
});

org = await orgFixture.create({
name: `org-private-links-org-${randomString()}`,
slug: `org-private-links-org-${randomString()}`,
isOrganization: true,
});

team = await teamFixture.create({
name: `org-private-links-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});

// Create a team-owned event type
eventType = await eventTypesFixture.createTeamEventType({
title: `org-private-links-event-type-${randomString()}`,
slug: `org-private-links-event-type-${randomString()}`,
length: 30,
locations: [],
team: { connect: { id: team.id } },
});

await app.init();
});

it("POST /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - create", async () => {
const body: CreatePrivateLinkInput = { maxUsageCount: 5 };
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send(body)
.expect(201);

expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBeDefined();
expect(response.body.data.maxUsageCount).toBe(5);
expect(response.body.data.usageCount).toBeDefined();
});

it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.expect(200);

expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
});
Comment on lines +113 to +121
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Strengthen GET test by ensuring a non-empty list and asserting contents

Currently we only assert the response is an array. Create a link within this test and assert the list includes it to avoid flakiness if prior tests change.

Apply this diff:

-  it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
-    const response = await request(app.getHttpServer())
+  it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
+    // Ensure at least one link exists for this event type
+    const create = await request(app.getHttpServer())
+      .post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
+      .set("Authorization", "Bearer test")
+      .send({ maxUsageCount: 2 })
+      .expect(201);
+    const createdId = create.body.data.linkId as string;
+
+    const response = await request(app.getHttpServer())
       .get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
       .set("Authorization", "Bearer test")
       .expect(200);
 
     expect(response.body.status).toBe(SUCCESS_STATUS);
-    expect(Array.isArray(response.body.data)).toBe(true);
+    expect(Array.isArray(response.body.data)).toBe(true);
+    // Expect the created link to be present
+    expect(response.body.data.some((l: any) => l.linkId === createdId)).toBe(true);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
});
it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
// Ensure at least one link exists for this event type
const create = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 2 })
.expect(201);
const createdId = create.body.data.linkId as string;
const response = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
// Expect the created link to be present
expect(response.body.data.some((l: any) => l.linkId === createdId)).toBe(true);
});
🤖 Prompt for AI Agents
In
apps/api/v2/src/modules/organizations/event-types/organizations-event-types-private-links.e2e-spec.ts
around lines 113 to 121, the GET test only asserts the response is an array —
make it deterministic by creating a private link first and then asserting the
GET result contains that created link; specifically, within the test create a
private link for the same org/team/eventType (using the existing factory or by
POSTing to the private-links endpoint), capture the created link's id (or url),
then call the GET and assert response.body.data.length > 0 and that some item in
response.body.data has the same id (or url) as the created link; ensure the
created link uses the same Authorization and test context so the GET will return
it.


it("PATCH /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - update", async () => {
// create first
const createResp = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 3 })
.expect(201);

const linkId = createResp.body.data.linkId as string;

const response = await request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 10 })
.expect(200);

expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.maxUsageCount).toBe(10);
});

it("DELETE /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - delete", async () => {
// create first
const createResp = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
.set("Authorization", "Bearer test")
.send({ maxUsageCount: 2 })
.expect(201);

const linkId = createResp.body.data.linkId as string;

const response = await request(app.getHttpServer())
.delete(
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
)
.set("Authorization", "Bearer test")
.expect(200);

expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBe(linkId);
});

afterAll(async () => {
try {
if (eventType?.id) {
await eventTypesFixture.delete(eventType.id);
}
if (team?.id) {
await teamFixture.delete(team.id);
}
if (org?.id) {
await orgFixture.delete(org.id);
}
if (user?.email) {
await userFixture.deleteByEmail(user.email);
}
} catch {}
await app.close();
});
});
Loading
Loading