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:
co-authored by
Lauris Skraucis
supalarry
parent
076e93ca56
commit
af51df2b4d
@@ -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": "*",
|
||||
|
||||
+178
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+123
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3972
-840
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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user