Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.291",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.294",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});


Original file line number Diff line number Diff line change
@@ -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",
},
};
}
}


Original file line number Diff line number Diff line change
@@ -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 {}


Original file line number Diff line number Diff line change
@@ -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 } });
}
}


Loading
Loading