feat: Add private links to API (#22943)

* --init

* address change requests

* adding further changes

* address feedback

* further changes

* further clean-up

* clean up

* fix module import and others

* add guards

* remove unnecessary comments

* remove unnecessary comments

* cleanup

* sort coderabbig suggestions

* improve check

* chore: bump platform libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
This commit is contained in:
Syed Ali Shahbaz
2025-08-11 11:04:23 +00:00
committed by GitHub
co-authored by Lauris Skraucis supalarry
parent 076e93ca56
commit af51df2b4d
23 changed files with 5286 additions and 862 deletions
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.294",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -0,0 +1,178 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.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 { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreatePrivateLinkInput } from "@calcom/platform-types";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { randomString } from "test/utils/randomString";
describe("Event Types Private Links Endpoints", () => {
let app: INestApplication;
let oAuthClient: any;
let organization: any;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let user: any;
let eventType: any;
const userEmail = `private-links-user-${randomString()}@api.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: `private-links-organization-${randomString()}`,
slug: `private-links-org-slug-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name: `private-links-user-${randomString()}`,
username: `private-links-user-${randomString()}`,
});
// create an event type owned by user
eventType = await eventTypesRepositoryFixture.create(
{
title: `private-links-event-type-${randomString()}`,
slug: `private-links-event-type-${randomString()}`,
length: 30,
locations: [],
},
user.id
);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("POST /v2/event-types/:eventTypeId/private-links - create private link", async () => {
const body: CreatePrivateLinkInput = {
expiresAt: undefined,
maxUsageCount: 5,
};
const response = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.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/event-types/:eventTypeId/private-links - list private links", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThanOrEqual(1);
});
it("PATCH /v2/event-types/:eventTypeId/private-links/:linkId - update private link", async () => {
// create a link first
const createResp = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 3 })
.expect(201);
const linkId = createResp.body.data.linkId;
const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 10 })
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.maxUsageCount).toBe(10);
});
it("DELETE /v2/event-types/:eventTypeId/private-links/:linkId - delete private link", async () => {
// create a link to delete
const createResp = await request(app.getHttpServer())
.post(`/api/v2/event-types/${eventType.id}/private-links`)
.set("Authorization", `Bearer whatever`)
.send({ maxUsageCount: 2 })
.expect(201);
const linkId = createResp.body.data.linkId;
const response = await request(app.getHttpServer())
.delete(`/api/v2/event-types/${eventType.id}/private-links/${linkId}`)
.set("Authorization", `Bearer whatever`)
.expect(200);
expect(response.body.status).toBe(SUCCESS_STATUS);
expect(response.body.data.linkId).toBe(linkId);
});
afterAll(async () => {
// cleanup created entities
try {
if (eventType?.id) {
const repo = new EventTypesRepositoryFixture((app as any).select(AppModule));
await repo.delete(eventType.id);
}
} catch {}
await app.close();
});
});
@@ -0,0 +1,123 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags, OmitType } from "@nestjs/swagger";
import {
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import {
CreatePrivateLinkInput,
CreatePrivateLinkOutput,
DeletePrivateLinkOutput,
GetPrivateLinksOutput,
UpdatePrivateLinkInput,
UpdatePrivateLinkOutput,
} from "@calcom/platform-types";
import { PrivateLinksService } from "../services/private-links.service";
class UpdatePrivateLinkBody extends OmitType(UpdatePrivateLinkInput, ["linkId"] as const) {}
@Controller({
path: "/v2/event-types/:eventTypeId/private-links",
})
@UseGuards(PermissionsGuard)
@DocsTags("Event Types Private Links")
export class EventTypesPrivateLinksController {
constructor(private readonly privateLinksService: PrivateLinksService) {}
@Post("/")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Create a private link for an event type" })
async createPrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Body() body: CreatePrivateLinkInput,
@GetUser("id") userId: number
): Promise<CreatePrivateLinkOutput> {
const privateLink = await this.privateLinksService.createPrivateLink(eventTypeId, userId, body);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Get("/")
@Permissions([EVENT_TYPE_READ])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Get all private links for an event type" })
async getPrivateLinks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@GetUser("id") userId: number
): Promise<GetPrivateLinksOutput> {
const privateLinks = await this.privateLinksService.getPrivateLinks(eventTypeId, userId);
return {
status: SUCCESS_STATUS,
data: privateLinks,
};
}
@Patch("/:linkId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({ summary: "Update a private link for an event type" })
async updatePrivateLink(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("linkId") linkId: string,
@Body() body: UpdatePrivateLinkBody,
@GetUser("id") userId: number
): Promise<UpdatePrivateLinkOutput> {
const updateInput = { ...body, linkId };
const privateLink = await this.privateLinksService.updatePrivateLink(eventTypeId, userId, updateInput);
return {
status: SUCCESS_STATUS,
data: privateLink,
};
}
@Delete("/:linkId")
@Permissions([EVENT_TYPE_WRITE])
@UseGuards(ApiAuthGuard, EventTypeOwnershipGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@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
): Promise<DeletePrivateLinkOutput> {
await this.privateLinksService.deletePrivateLink(eventTypeId, userId, linkId);
return {
status: SUCCESS_STATUS,
data: {
linkId,
message: "Private link deleted successfully",
},
};
}
}
@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { EventTypesPrivateLinksController } from "./controllers/event-types-private-links.controller";
import { PrivateLinksInputService } from "./services/private-links-input.service";
import { PrivateLinksOutputService } from "./services/private-links-output.service";
import { PrivateLinksService } from "./services/private-links.service";
import { PrivateLinksRepository } from "./private-links.repository";
@Module({
imports: [TokensModule, OAuthClientModule, PrismaModule, EventTypesModule_2024_06_14],
controllers: [EventTypesPrivateLinksController],
providers: [
PrivateLinksService,
PrivateLinksInputService,
PrivateLinksOutputService,
PrivateLinksRepository,
EventTypeOwnershipGuard,
],
})
export class EventTypesPrivateLinksModule {}
@@ -0,0 +1,54 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class PrivateLinksRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async listByEventTypeId(eventTypeId: number) {
return this.dbRead.prisma.hashedLink.findMany({
where: { eventTypeId },
select: { link: true, expiresAt: true, maxUsageCount: true, usageCount: true },
});
}
async findWithEventTypeDetails(linkId: string) {
return this.dbRead.prisma.hashedLink.findUnique({
where: { link: linkId },
select: {
link: true,
expiresAt: true,
maxUsageCount: true,
usageCount: true,
},
});
}
async create(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
return this.dbWrite.prisma.hashedLink.create({
data: {
eventTypeId,
link: link.link,
expiresAt: link.expiresAt,
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
},
});
}
async update(eventTypeId: number, link: { link: string; expiresAt: Date | null; maxUsageCount?: number | null }) {
return this.dbWrite.prisma.hashedLink.updateMany({
where: { eventTypeId, link: link.link },
data: {
expiresAt: link.expiresAt,
...(typeof link.maxUsageCount === "number" ? { maxUsageCount: link.maxUsageCount } : {}),
},
});
}
async delete(eventTypeId: number, linkId: string) {
return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } });
}
}
@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { CreatePrivateLinkInput, UpdatePrivateLinkInput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksInputService {
constructor() {}
transformCreateInput(input: CreatePrivateLinkInput): CreatePrivateLinkInput {
const hasExpires = input.expiresAt !== undefined && input.expiresAt !== null;
const hasMaxCount = typeof input.maxUsageCount === "number";
if (!hasExpires && !hasMaxCount) {
throw new Error("Either expiresAt or maxUsageCount must be provided");
}
if (hasExpires && hasMaxCount) {
throw new Error("Provide only one of expiresAt or maxUsageCount");
}
return {
expiresAt: input.expiresAt,
maxUsageCount: input.maxUsageCount ?? (hasMaxCount ? input.maxUsageCount : undefined),
};
}
transformUpdateInput(input: UpdatePrivateLinkInput): UpdatePrivateLinkInput {
return {
linkId: input.linkId,
expiresAt: input.expiresAt,
maxUsageCount: input.maxUsageCount,
};
}
}
@@ -0,0 +1,43 @@
import { Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
export type PrivateLinkData = {
id: string;
eventTypeId: number;
isExpired: boolean;
bookingUrl: string;
expiresAt?: Date | null;
maxUsageCount?: number | null;
usageCount?: number;
};
import { PrivateLinkOutput, TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput } from "@calcom/platform-types";
@Injectable()
export class PrivateLinksOutputService {
constructor() {}
transformToOutput(data: PrivateLinkData): PrivateLinkOutput {
const baseData = {
linkId: data.id.toString(),
eventTypeId: data.eventTypeId,
isExpired: data.isExpired,
bookingUrl: data.bookingUrl,
};
if (data.expiresAt !== null && data.expiresAt !== undefined) {
return plainToClass(TimeBasedPrivateLinkOutput, { ...baseData, expiresAt: data.expiresAt });
}
return plainToClass(UsageBasedPrivateLinkOutput, {
...baseData,
maxUsageCount: data.maxUsageCount || 0,
usageCount: data.usageCount || 0,
});
}
transformArrayToOutput(data: PrivateLinkData[]): PrivateLinkOutput[] {
return data.map((item) => this.transformToOutput(item));
}
}
@@ -0,0 +1,128 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { generateHashedLink, isLinkExpired } from "@calcom/platform-libraries/private-links";
import { CreatePrivateLinkInput, PrivateLinkOutput, UpdatePrivateLinkInput } from "@calcom/platform-types";
import { PrivateLinksInputService } from "@/ee/event-types-private-links/services/private-links-input.service";
import {
PrivateLinksOutputService,
type PrivateLinkData,
} from "@/ee/event-types-private-links/services/private-links-output.service";
import { PrivateLinksRepository } from "@/ee/event-types-private-links/private-links.repository";
@Injectable()
export class PrivateLinksService {
constructor(
private readonly inputService: PrivateLinksInputService,
private readonly outputService: PrivateLinksOutputService,
private readonly repo: PrivateLinksRepository
) {}
async createPrivateLink(
eventTypeId: number,
userId: number,
input: CreatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformCreateInput(input);
const created = await this.repo.create(eventTypeId, {
link: generateHashedLink(userId),
expiresAt: transformedInput.expiresAt ?? null,
maxUsageCount: transformedInput.maxUsageCount ?? null,
});
const mapped: PrivateLinkData = {
id: created.link,
eventTypeId,
isExpired: isLinkExpired(created as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${created.link}`,
expiresAt: created.expiresAt ?? null,
maxUsageCount: (created as any).maxUsageCount ?? null,
usageCount: (created as any).usageCount ?? 0,
};
return this.outputService.transformToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to create private link");
}
}
async getPrivateLinks(eventTypeId: number, userId: number): Promise<PrivateLinkOutput[]> {
try {
const links = await this.repo.listByEventTypeId(eventTypeId);
const mapped: PrivateLinkData[] = links.map((l) => ({
id: l.link,
eventTypeId,
isExpired: isLinkExpired(l as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${l.link}`,
expiresAt: l.expiresAt ?? null,
maxUsageCount: l.maxUsageCount ?? null,
usageCount: l.usageCount ?? 0,
}));
return this.outputService.transformArrayToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to get private links");
}
}
async updatePrivateLink(
eventTypeId: number,
userId: number,
input: UpdatePrivateLinkInput
): Promise<PrivateLinkOutput> {
try {
const transformedInput = this.inputService.transformUpdateInput(input);
const updatedResult = await this.repo.update(eventTypeId, {
link: transformedInput.linkId,
expiresAt: transformedInput.expiresAt ?? null,
maxUsageCount: transformedInput.maxUsageCount ?? null,
});
if (!updatedResult || (updatedResult as any).count === 0) {
throw new NotFoundException("Updated link not found");
}
const updated = await this.repo.findWithEventTypeDetails(transformedInput.linkId);
if (!updated) throw new NotFoundException("Updated link not found");
const mapped: PrivateLinkData = {
id: updated.link,
eventTypeId,
isExpired: isLinkExpired(updated as any),
bookingUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL || "https://cal.com"}/d/${updated.link}`,
expiresAt: updated.expiresAt ?? null,
maxUsageCount: updated.maxUsageCount ?? null,
usageCount: updated.usageCount ?? 0,
};
return this.outputService.transformToOutput(mapped);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("not found")) {
throw new NotFoundException(error.message);
}
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to update private link");
}
}
async deletePrivateLink(eventTypeId: number, userId: number, linkId: string): Promise<void> {
try {
const { count } = await this.repo.delete(eventTypeId, linkId);
if (count === 0) {
throw new NotFoundException("Deleted link not found");
}
} catch (error) {
if (error instanceof Error) {
if (error.message.includes("not found")) {
throw new NotFoundException(error.message);
}
throw new BadRequestException(error.message);
}
throw new BadRequestException("Failed to delete private link");
}
}
}
@@ -6,6 +6,7 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
@@ -41,6 +42,7 @@ import {
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -14,6 +14,7 @@ import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.m
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -35,6 +36,7 @@ import { Module } from "@nestjs/common";
SlotsModule_2024_09_04,
TeamsModule,
RoutingFormsModule,
EventTypesPrivateLinksModule,
],
})
export class PlatformEndpointsModule implements NestModule {
@@ -0,0 +1,44 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import {
BadRequestException,
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
@Injectable()
export class EventTypeOwnershipGuard implements CanActivate {
constructor(private readonly eventTypesService: EventTypesService_2024_06_14) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const user = request.user as ApiAuthGuardUser | undefined;
const eventTypeIdParam = request.params?.eventTypeId;
if (!user) {
throw new ForbiddenException("EventTypeOwnershipGuard - No user associated with the request.");
}
if (!eventTypeIdParam) {
throw new BadRequestException("Missing eventTypeId param.");
}
const eventTypeId = Number(eventTypeIdParam);
if (!Number.isInteger(eventTypeId) || eventTypeId <= 0) {
throw new BadRequestException("Invalid eventTypeId param.");
}
const eventType = await this.eventTypesService.getUserEventType(user.id, eventTypeId);
if (!eventType) {
// Mirrors EventTypesService behavior: NotFound when not owned or not present
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return true;
}
}
+446 -15
View File
@@ -9893,6 +9893,198 @@
]
}
},
"/v2/event-types/{eventTypeId}/private-links": {
"post": {
"operationId": "EventTypesPrivateLinksController_createPrivateLink",
"summary": "Create a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"get": {
"operationId": "EventTypesPrivateLinksController_getPrivateLinks",
"summary": "Get all private links for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetPrivateLinksOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/event-types/{eventTypeId}/private-links/{linkId}": {
"patch": {
"operationId": "EventTypesPrivateLinksController_updatePrivateLink",
"summary": "Update a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkBody"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdatePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
},
"delete": {
"operationId": "EventTypesPrivateLinksController_deletePrivateLink",
"summary": "Delete a private link for an event type",
"parameters": [
{
"name": "eventTypeId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "linkId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeletePrivateLinkOutput"
}
}
}
}
},
"tags": [
"Event Types Private Links"
]
}
},
"/v2/organizations/{orgId}/organizations": {
"post": {
"operationId": "OrganizationsOrganizationsController_createOrganization",
@@ -21856,7 +22048,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
]
},
"recipient": {
@@ -22218,7 +22411,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22303,7 +22497,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -22379,7 +22574,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -22474,7 +22670,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -22543,7 +22740,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -22621,7 +22819,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -22699,7 +22898,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -22888,7 +23088,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_address",
"description": "Action to perform, send an email to a specific email address",
@@ -22978,7 +23179,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_attendee",
"description": "Action to perform, send an email to the attendees of the event",
@@ -23059,7 +23261,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "email_host",
"description": "Action to perform, send an email to the host of the event",
@@ -23140,7 +23343,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_attendee",
"description": "Action to perform, send a text message to the phone numbers of the attendees",
@@ -23218,7 +23422,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_number",
"description": "Action to perform, send a text message via whatsapp to a specific phone number",
@@ -23301,7 +23506,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "whatsapp_attendee",
"description": "Action to perform",
@@ -23375,7 +23581,8 @@
"sms_attendee",
"sms_number",
"whatsapp_attendee",
"whatsapp_number"
"whatsapp_number",
"cal_ai_phone_call"
],
"default": "sms_number",
"description": "Action to perform, send a text message to a specific phone number",
@@ -28012,6 +28219,230 @@
"data"
]
},
"CreatePrivateLinkInput": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string",
"description": "Expiration date for time-based links",
"format": "date-time",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
"example": 10,
"minimum": 1,
"default": 1
}
}
},
"TimeBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"expiresAt": {
"type": "string",
"description": "Expiration date for this time-based link",
"format": "date-time",
"example": "2025-12-31T23:59:59.000Z"
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"expiresAt"
]
},
"UsageBasedPrivateLinkOutput": {
"type": "object",
"properties": {
"linkId": {
"type": "string",
"description": "The private link ID",
"example": "abc123def456"
},
"eventTypeId": {
"type": "number",
"description": "Event type ID this link belongs to",
"example": 123
},
"isExpired": {
"type": "boolean",
"description": "Whether the link is currently expired",
"example": false
},
"bookingUrl": {
"type": "string",
"description": "Full booking URL for this private link",
"format": "uri",
"example": "https://cal.com/d/abc123def456/30min"
},
"maxUsageCount": {
"type": "number",
"description": "Maximum number of times this link can be used",
"example": 10
},
"usageCount": {
"type": "number",
"description": "Current usage count for this link",
"example": 3
}
},
"required": [
"linkId",
"eventTypeId",
"isExpired",
"bookingUrl",
"maxUsageCount",
"usageCount"
]
},
"CreatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Created private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"GetPrivateLinksOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "array",
"description": "Array of private links for the event type (mix of time-based and usage-based)",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
}
},
"required": [
"status",
"data"
]
},
"UpdatePrivateLinkBody": {
"type": "object",
"properties": {
"expiresAt": {
"format": "date-time",
"type": "string",
"description": "New expiration date for time-based links",
"example": "2024-12-31T23:59:59.000Z"
},
"maxUsageCount": {
"type": "number",
"description": "New maximum number of times the link can be used",
"example": 10,
"minimum": 1
}
}
},
"UpdatePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"description": "Updated private link data (either time-based or usage-based)",
"oneOf": [
{
"$ref": "#/components/schemas/TimeBasedPrivateLinkOutput"
},
{
"$ref": "#/components/schemas/UsageBasedPrivateLinkOutput"
}
]
}
},
"required": [
"status",
"data"
]
},
"DeletePrivateLinkOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Response status",
"example": "success"
},
"data": {
"type": "object",
"description": "Deleted link information",
"properties": {
"linkId": {
"type": "string",
"example": "abc123def456"
},
"message": {
"type": "string",
"example": "Private link deleted successfully"
}
}
}
},
"required": [
"status",
"data"
]
},
"UserWebhookOutputDto": {
"type": "object",
"properties": {
+2 -1
View File
@@ -27,7 +27,8 @@
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"],
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"]
"@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"],
"@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"]
},
"incremental": true,
"skipLibCheck": true,
File diff suppressed because it is too large Load Diff
@@ -129,6 +129,24 @@ export class HashedLinkService {
return hashedLink;
}
async createLinkForEventType(eventTypeId: number, input: string | HashedLinkInputType) {
const normalized = this.normalizeLinkInput(input);
return this.hashedLinkRepository.createLink(eventTypeId, normalized);
}
async updateLinkForEventType(eventTypeId: number, input: string | HashedLinkInputType) {
const normalized = this.normalizeLinkInput(input);
return this.hashedLinkRepository.updateLink(eventTypeId, normalized);
}
async deleteLinkForEventType(eventTypeId: number, linkId: string) {
return this.hashedLinkRepository.deleteLinks(eventTypeId, [linkId]);
}
async listLinksByEventType(eventTypeId: number) {
return this.hashedLinkRepository.findLinksByEventTypeId(eventTypeId);
}
/**
* Checks if a user has permission to access a link
* @param link - Link object with event type information
+8
View File
@@ -78,6 +78,11 @@
"import": "./dist/bookings.js",
"require": "./dist/bookings.cjs",
"types": "./dist/bookings.d.ts"
},
"./private-links": {
"import": "./dist/private-links.js",
"require": "./dist/private-links.cjs",
"types": "./dist/private-links.d.ts"
}
},
"typesVersions": {
@@ -108,6 +113,9 @@
],
"bookings": [
"dist/bookings.d.ts"
],
"private-links": [
"dist/private-links.d.ts"
]
}
}
@@ -0,0 +1,2 @@
export { generateHashedLink } from "@calcom/lib/generateHashedLink";
export { isLinkExpired } from "@calcom/lib/hashedLinksUtils";
@@ -27,6 +27,7 @@ export default defineConfig({
conferencing: resolve(__dirname, "./conferencing.ts"),
repositories: resolve(__dirname, "./repositories.ts"),
bookings: resolve(__dirname, "./bookings.ts"),
"private-links": resolve(__dirname, "./private-links.ts"),
},
name: "calcom-lib",
fileName: "calcom-lib",
@@ -1 +1,3 @@
export * from "./event-types_2024_06_14";
export * from "./inputs/private-link.input";
export * from "./outputs/private-link.output";
@@ -0,0 +1,61 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsOptional, IsDate, IsInt, Min, IsString } from "class-validator";
export class CreatePrivateLinkInput {
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiPropertyOptional({
description: "Expiration date for time-based links",
type: String,
format: "date-time",
example: "2024-12-31T23:59:59.000Z",
})
expiresAt?: Date;
@IsOptional()
@IsInt()
@Min(1)
@ApiPropertyOptional({
description: "Maximum number of times the link can be used. If omitted and expiresAt is not provided, defaults to 1 (one time use).",
type: Number,
example: 10,
minimum: 1,
default: 1,
})
maxUsageCount?: number;
}
export class UpdatePrivateLinkInput {
@IsString()
@ApiProperty({
description: "The private link hash to update",
type: String,
example: "abc123def456",
})
linkId!: string;
@IsOptional()
@IsDate()
@Type(() => Date)
@ApiPropertyOptional({
description: "New expiration date for time-based links",
type: Date,
example: "2024-12-31T23:59:59.000Z",
})
expiresAt?: Date;
@IsOptional()
@IsInt()
@Min(1)
@ApiPropertyOptional({
description: "New maximum number of times the link can be used",
type: Number,
example: 10,
minimum: 1,
})
maxUsageCount?: number;
}
@@ -0,0 +1,132 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
// Base class with common properties
abstract class BasePrivateLinkOutput {
@ApiProperty({
description: "The private link ID",
type: String,
example: "abc123def456",
})
linkId!: string;
@ApiProperty({
description: "Event type ID this link belongs to",
type: Number,
example: 123,
})
eventTypeId!: number;
@ApiProperty({
description: "Whether the link is currently expired",
type: Boolean,
example: false,
})
isExpired!: boolean;
@ApiProperty({
description: "Full booking URL for this private link",
type: String,
format: "uri",
example: "https://cal.com/d/abc123def456/30min",
})
bookingUrl!: string;
}
// Time-based private link (expires at a specific date)
export class TimeBasedPrivateLinkOutput extends BasePrivateLinkOutput {
@ApiProperty({
description: "Expiration date for this time-based link",
type: String,
format: "date-time",
example: "2025-12-31T23:59:59.000Z",
})
expiresAt!: Date;
}
// Usage-based private link (expires after N uses)
export class UsageBasedPrivateLinkOutput extends BasePrivateLinkOutput {
@ApiProperty({
description: "Maximum number of times this link can be used",
type: Number,
example: 10,
})
maxUsageCount!: number;
@ApiProperty({
description: "Current usage count for this link",
type: Number,
example: 3,
})
usageCount!: number;
}
// Union type for either time-based or usage-based links
export type PrivateLinkOutput = TimeBasedPrivateLinkOutput | UsageBasedPrivateLinkOutput;
@ApiExtraModels(TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput)
export class CreatePrivateLinkOutput {
@ApiProperty({ description: "Response status", example: "success" })
status!: string;
@ApiProperty({
description: "Created private link data (either time-based or usage-based)",
oneOf: [
{ $ref: getSchemaPath(TimeBasedPrivateLinkOutput) },
{ $ref: getSchemaPath(UsageBasedPrivateLinkOutput) },
],
})
data!: PrivateLinkOutput;
}
@ApiExtraModels(TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput)
export class GetPrivateLinksOutput {
@ApiProperty({ description: "Response status", example: "success" })
status!: string;
@ApiProperty({
description: "Array of private links for the event type (mix of time-based and usage-based)",
type: "array",
items: {
oneOf: [
{ $ref: getSchemaPath(TimeBasedPrivateLinkOutput) },
{ $ref: getSchemaPath(UsageBasedPrivateLinkOutput) },
],
},
})
data!: PrivateLinkOutput[];
}
@ApiExtraModels(TimeBasedPrivateLinkOutput, UsageBasedPrivateLinkOutput)
export class UpdatePrivateLinkOutput {
@ApiProperty({ description: "Response status", example: "success" })
status!: string;
@ApiProperty({
description: "Updated private link data (either time-based or usage-based)",
oneOf: [
{ $ref: getSchemaPath(TimeBasedPrivateLinkOutput) },
{ $ref: getSchemaPath(UsageBasedPrivateLinkOutput) },
],
})
data!: PrivateLinkOutput;
}
export class DeletePrivateLinkOutput {
@ApiProperty({ description: "Response status", example: "success" })
status!: string;
@ApiProperty({
description: "Deleted link information",
type: "object",
properties: {
linkId: { type: "string", example: "abc123def456" },
message: { type: "string", example: "Private link deleted successfully" },
},
})
data!: {
linkId: string;
message: string;
};
}
+1
View File
@@ -7,6 +7,7 @@ export * from "./bookings";
export * from "./billing";
export * from "./schedules";
export * from "./event-types";
export * from "./event-types/inputs/private-link.input";
export * from "./organizations";
export * from "./teams";
export * from "./embed";
+5 -5
View File
@@ -2500,7 +2500,7 @@ __metadata:
"@axiomhq/winston": ^1.2.0
"@calcom/platform-constants": "*"
"@calcom/platform-enums": "*"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291"
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.294"
"@calcom/platform-types": "*"
"@calcom/platform-utils": "*"
"@calcom/prisma": "*"
@@ -3558,13 +3558,13 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.291":
version: 0.0.291
resolution: "@calcom/platform-libraries@npm:0.0.291"
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.294":
version: 0.0.294
resolution: "@calcom/platform-libraries@npm:0.0.294"
dependencies:
"@calcom/features": "*"
"@calcom/lib": "*"
checksum: 09cb5db584698acccd4447b4007df2fe2d66352c6ba823ef8c354fd3458e59414a9502f6950438e1260df71613b95f1e5a14bb54e2c7ffe6cc1dafe8b643fb9d
checksum: ce7beda30e629e87d560386bf1f117280ec3466ba7dfa2089de79c2d03c46d24ee3a5388a63974ba084b6624e863c2feb5e02f42ef08c72daefc7c51e771d5f8
languageName: node
linkType: hard