Skip to content

Commit c268925

Browse files
feat: org/team event type private links endpoints (#23048)
* init * added test * remove unnecessary comments * remove unnecessary userId
1 parent 378e8e0 commit c268925

File tree

10 files changed

+1343
-244
lines changed

10 files changed

+1343
-244
lines changed

apps/api/v2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@axiomhq/winston": "^1.2.0",
3939
"@calcom/platform-constants": "*",
4040
"@calcom/platform-enums": "*",
41-
"@calcom/platform-libraries": "npm:@calcom/[email protected].305",
41+
"@calcom/platform-libraries": "npm:@calcom/[email protected].306",
4242
"@calcom/platform-types": "*",
4343
"@calcom/platform-utils": "*",
4444
"@calcom/prisma": "*",

apps/api/v2/src/ee/event-types-private-links/controllers/event-types-private-links.controller.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,10 @@ export class EventTypesPrivateLinksController {
6969
async updatePrivateLink(
7070
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
7171
@Param("linkId") linkId: string,
72-
@Body() body: UpdatePrivateLinkBody,
73-
@GetUser("id") userId: number
72+
@Body() body: UpdatePrivateLinkBody
7473
): Promise<UpdatePrivateLinkOutput> {
7574
const updateInput = { ...body, linkId };
76-
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
75+
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
7776

7877
return {
7978
status: SUCCESS_STATUS,
@@ -88,10 +87,9 @@ export class EventTypesPrivateLinksController {
8887
@ApiOperation({ summary: "Delete a private link for an event type" })
8988
async deletePrivateLink(
9089
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
91-
@Param("linkId") linkId: string,
92-
@GetUser("id") userId: number
90+
@Param("linkId") linkId: string
9391
): Promise<DeletePrivateLinkOutput> {
94-
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
92+
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
9593

9694
return {
9795
status: SUCCESS_STATUS,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
2+
import {
3+
OPTIONAL_API_KEY_HEADER,
4+
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
5+
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
6+
} from "@/lib/docs/headers";
7+
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
8+
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
9+
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
10+
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
11+
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
12+
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
13+
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
14+
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
15+
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
16+
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
17+
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
18+
19+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
20+
import {
21+
CreatePrivateLinkInput,
22+
CreatePrivateLinkOutput,
23+
DeletePrivateLinkOutput,
24+
GetPrivateLinksOutput,
25+
UpdatePrivateLinkInput,
26+
UpdatePrivateLinkOutput,
27+
} from "@calcom/platform-types";
28+
29+
import { PrivateLinksService } from "../services/private-links.service";
30+
31+
@Controller({
32+
path: "/v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links",
33+
version: API_VERSIONS_VALUES,
34+
})
35+
@DocsTags("Orgs / Teams / Event Types / Private Links")
36+
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
37+
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
38+
@ApiHeader(OPTIONAL_API_KEY_HEADER)
39+
export class OrganizationsEventTypesPrivateLinksController {
40+
constructor(
41+
private readonly privateLinksService: PrivateLinksService,
42+
private readonly teamsEventTypesService: TeamsEventTypesService
43+
) {}
44+
45+
@Post("/")
46+
@Roles("TEAM_ADMIN")
47+
@PlatformPlan("ESSENTIALS")
48+
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
49+
@ApiOperation({ summary: "Create a private link for a team event type" })
50+
async createPrivateLink(
51+
@Param("teamId", ParseIntPipe) teamId: number,
52+
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
53+
@Body() body: CreatePrivateLinkInput
54+
): Promise<CreatePrivateLinkOutput> {
55+
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
56+
// Use teamId as the seed for link generation in org/team context
57+
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, teamId, body);
58+
return {
59+
status: SUCCESS_STATUS,
60+
data: privateLink,
61+
};
62+
}
63+
64+
@Get("/")
65+
@Roles("TEAM_ADMIN")
66+
@PlatformPlan("ESSENTIALS")
67+
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
68+
@ApiOperation({ summary: "Get all private links for a team event type" })
69+
async getPrivateLinks(
70+
@Param("teamId", ParseIntPipe) teamId: number,
71+
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
72+
): Promise<GetPrivateLinksOutput> {
73+
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
74+
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId);
75+
return {
76+
status: SUCCESS_STATUS,
77+
data: privateLinks,
78+
};
79+
}
80+
81+
@Patch("/:linkId")
82+
@Roles("TEAM_ADMIN")
83+
@PlatformPlan("ESSENTIALS")
84+
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
85+
@ApiOperation({ summary: "Update a private link for a team event type" })
86+
async updatePrivateLink(
87+
@Param("teamId", ParseIntPipe) teamId: number,
88+
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
89+
@Param("linkId") linkId: string,
90+
@Body() body: Omit<UpdatePrivateLinkInput, "linkId">
91+
): Promise<UpdatePrivateLinkOutput> {
92+
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
93+
const updateInput: UpdatePrivateLinkInput = { ...body, linkId };
94+
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, updateInput);
95+
return {
96+
status: SUCCESS_STATUS,
97+
data: privateLink,
98+
};
99+
}
100+
101+
@Delete("/:linkId")
102+
@Roles("TEAM_ADMIN")
103+
@PlatformPlan("ESSENTIALS")
104+
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
105+
@ApiOperation({ summary: "Delete a private link for a team event type" })
106+
async deletePrivateLink(
107+
@Param("teamId", ParseIntPipe) teamId: number,
108+
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
109+
@Param("linkId") linkId: string
110+
): Promise<DeletePrivateLinkOutput> {
111+
await this.teamsEventTypesService.validateEventTypeExists(teamId, eventTypeId);
112+
await this.privateLinksService.deletePrivateLink(eventTypeId, linkId);
113+
return {
114+
status: SUCCESS_STATUS,
115+
data: {
116+
linkId,
117+
message: "Private link deleted successfully",
118+
},
119+
};
120+
}
121+
}

apps/api/v2/src/ee/event-types-private-links/event-types-private-links.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { PrivateLinksOutputService } from "./services/private-links-output.servi
1212
import { PrivateLinksService } from "./services/private-links.service";
1313

1414
@Module({
15-
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
15+
imports: [TokensModule, PrismaModule, EventTypesModule_2024_06_14],
1616
controllers: [EventTypesPrivateLinksController],
1717
providers: [
1818
PrivateLinksService,
@@ -21,5 +21,6 @@ import { PrivateLinksService } from "./services/private-links.service";
2121
PrivateLinksRepository,
2222
EventTypeOwnershipGuard,
2323
],
24+
exports: [PrivateLinksService],
2425
})
2526
export class EventTypesPrivateLinksModule {}

apps/api/v2/src/ee/event-types-private-links/services/private-links.service.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,7 @@ export class PrivateLinksService {
6868
}
6969
}
7070

71-
async updatePrivateLink(
72-
eventTypeId: number,
73-
userId: number,
74-
input: UpdatePrivateLinkInput
75-
): Promise<PrivateLinkOutput> {
71+
async updatePrivateLink(eventTypeId: number, input: UpdatePrivateLinkInput): Promise<PrivateLinkOutput> {
7672
try {
7773
const transformedInput = this.inputService.transformUpdateInput(input);
7874
const updatedResult = await this.repo.update(eventTypeId, {
@@ -106,7 +102,7 @@ export class PrivateLinksService {
106102
}
107103
}
108104

109-
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
105+
async deletePrivateLink(eventTypeId: number, linkId: string): Promise<void> {
110106
try {
111107
const { count } = await this.repo.delete(eventTypeId, linkId);
112108
if (count === 0) {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { bootstrap } from "@/app";
2+
import { AppModule } from "@/app.module";
3+
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
4+
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
5+
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
6+
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
7+
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
8+
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
9+
import { TokensModule } from "@/modules/tokens/tokens.module";
10+
import { UsersModule } from "@/modules/users/users.module";
11+
import { INestApplication } from "@nestjs/common";
12+
import { NestExpressApplication } from "@nestjs/platform-express";
13+
import { Test } from "@nestjs/testing";
14+
import * as request from "supertest";
15+
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
16+
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
17+
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
18+
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
19+
import { randomString } from "test/utils/randomString";
20+
import { withApiAuth } from "test/utils/withApiAuth";
21+
22+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
23+
import { CreatePrivateLinkInput } from "@calcom/platform-types";
24+
25+
describe("Organizations / Teams / Event Types / Private Links Endpoints", () => {
26+
let app: INestApplication;
27+
28+
let orgFixture: OrganizationRepositoryFixture;
29+
let teamFixture: TeamRepositoryFixture;
30+
let userFixture: UserRepositoryFixture;
31+
let eventTypesFixture: EventTypesRepositoryFixture;
32+
33+
let org: any;
34+
let team: any;
35+
let user: any;
36+
let eventType: any;
37+
38+
const userEmail = `org-private-links-user-${randomString()}@api.com`;
39+
40+
beforeAll(async () => {
41+
const testingModuleBuilder = withApiAuth(
42+
userEmail,
43+
Test.createTestingModule({
44+
providers: [PrismaExceptionFilter, HttpExceptionFilter],
45+
imports: [AppModule, UsersModule, TokensModule],
46+
})
47+
)
48+
// Bypass org admin plan and admin API checks and roles in this e2e
49+
.overrideGuard(PlatformPlanGuard)
50+
.useValue({ canActivate: () => true })
51+
.overrideGuard(IsAdminAPIEnabledGuard)
52+
.useValue({ canActivate: () => true })
53+
.overrideGuard(RolesGuard)
54+
.useValue({ canActivate: () => true })
55+
// Keep IsOrgGuard and IsTeamInOrg to validate org/team path integrity
56+
.overrideGuard(ApiAuthGuard)
57+
.useValue({ canActivate: () => true });
58+
59+
const moduleRef = await testingModuleBuilder.compile();
60+
61+
app = moduleRef.createNestApplication();
62+
bootstrap(app as NestExpressApplication);
63+
64+
orgFixture = new OrganizationRepositoryFixture(moduleRef);
65+
teamFixture = new TeamRepositoryFixture(moduleRef);
66+
userFixture = new UserRepositoryFixture(moduleRef);
67+
eventTypesFixture = new EventTypesRepositoryFixture(moduleRef);
68+
69+
user = await userFixture.create({
70+
email: userEmail,
71+
username: `org-private-links-user-${randomString()}`,
72+
name: "Test User",
73+
});
74+
75+
org = await orgFixture.create({
76+
name: `org-private-links-org-${randomString()}`,
77+
slug: `org-private-links-org-${randomString()}`,
78+
isOrganization: true,
79+
});
80+
81+
team = await teamFixture.create({
82+
name: `org-private-links-team-${randomString()}`,
83+
isOrganization: false,
84+
parent: { connect: { id: org.id } },
85+
});
86+
87+
// Create a team-owned event type
88+
eventType = await eventTypesFixture.createTeamEventType({
89+
title: `org-private-links-event-type-${randomString()}`,
90+
slug: `org-private-links-event-type-${randomString()}`,
91+
length: 30,
92+
locations: [],
93+
team: { connect: { id: team.id } },
94+
});
95+
96+
await app.init();
97+
});
98+
99+
it("POST /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - create", async () => {
100+
const body: CreatePrivateLinkInput = { maxUsageCount: 5 };
101+
const response = await request(app.getHttpServer())
102+
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
103+
.set("Authorization", "Bearer test")
104+
.send(body)
105+
.expect(201);
106+
107+
expect(response.body.status).toBe(SUCCESS_STATUS);
108+
expect(response.body.data.linkId).toBeDefined();
109+
expect(response.body.data.maxUsageCount).toBe(5);
110+
expect(response.body.data.usageCount).toBeDefined();
111+
});
112+
113+
it("GET /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links - list", async () => {
114+
const response = await request(app.getHttpServer())
115+
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
116+
.set("Authorization", "Bearer test")
117+
.expect(200);
118+
119+
expect(response.body.status).toBe(SUCCESS_STATUS);
120+
expect(Array.isArray(response.body.data)).toBe(true);
121+
});
122+
123+
it("PATCH /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - update", async () => {
124+
// create first
125+
const createResp = await request(app.getHttpServer())
126+
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
127+
.set("Authorization", "Bearer test")
128+
.send({ maxUsageCount: 3 })
129+
.expect(201);
130+
131+
const linkId = createResp.body.data.linkId as string;
132+
133+
const response = await request(app.getHttpServer())
134+
.patch(
135+
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
136+
)
137+
.set("Authorization", "Bearer test")
138+
.send({ maxUsageCount: 10 })
139+
.expect(200);
140+
141+
expect(response.body.status).toBe(SUCCESS_STATUS);
142+
expect(response.body.data.maxUsageCount).toBe(10);
143+
});
144+
145+
it("DELETE /v2/organizations/:orgId/teams/:teamId/event-types/:eventTypeId/private-links/:linkId - delete", async () => {
146+
// create first
147+
const createResp = await request(app.getHttpServer())
148+
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links`)
149+
.set("Authorization", "Bearer test")
150+
.send({ maxUsageCount: 2 })
151+
.expect(201);
152+
153+
const linkId = createResp.body.data.linkId as string;
154+
155+
const response = await request(app.getHttpServer())
156+
.delete(
157+
`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}/private-links/${linkId}`
158+
)
159+
.set("Authorization", "Bearer test")
160+
.expect(200);
161+
162+
expect(response.body.status).toBe(SUCCESS_STATUS);
163+
expect(response.body.data.linkId).toBe(linkId);
164+
});
165+
166+
afterAll(async () => {
167+
try {
168+
if (eventType?.id) {
169+
await eventTypesFixture.delete(eventType.id);
170+
}
171+
if (team?.id) {
172+
await teamFixture.delete(team.id);
173+
}
174+
if (org?.id) {
175+
await orgFixture.delete(org.id);
176+
}
177+
if (user?.email) {
178+
await userFixture.deleteByEmail(user.email);
179+
}
180+
} catch {}
181+
await app.close();
182+
});
183+
});

0 commit comments

Comments
 (0)