-
Notifications
You must be signed in to change notification settings - Fork 12k
feat: Add private links to API #22943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
5cdddd8
--init
alishaz-polymath 2323a82
address change requests
alishaz-polymath 7b00c99
adding further changes
alishaz-polymath 049f5a2
address feedback
alishaz-polymath 853faf3
further changes
alishaz-polymath 28aa86f
further clean-up
alishaz-polymath ef89452
clean up
alishaz-polymath 29a95c4
fix module import and others
alishaz-polymath f5b1a01
add guards
alishaz-polymath ece2882
Merge branch 'main' into feat/private-links-api-v2
alishaz-polymath 527e84f
remove unnecessary comments
alishaz-polymath 955a0e4
remove unnecessary comments
alishaz-polymath 6911818
cleanup
alishaz-polymath a0534ab
sort coderabbig suggestions
alishaz-polymath 07c5f44
Merge branch 'main' into feat/private-links-api-v2
alishaz-polymath 6e02012
improve check
alishaz-polymath cf1b819
Merge branch 'main' into feat/private-links-api-v2
supalarry 7551ecb
chore: bump platform libraries
supalarry 5b288a5
Merge branch 'main' into feat/private-links-api-v2
alishaz-polymath File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
178 changes: 178 additions & 0 deletions
178
...ee/event-types-private-links/controllers/event-types-private-links.controller.e2e-spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); | ||
|
|
||
|
|
123 changes: 123 additions & 0 deletions
123
...i/v2/src/ee/event-types-private-links/controllers/event-types-private-links.controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| }, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
|
|
27 changes: 27 additions & 0 deletions
27
apps/api/v2/src/ee/event-types-private-links/event-types-private-links.module.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 {} | ||
|
|
||
|
|
||
54 changes: 54 additions & 0 deletions
54
apps/api/v2/src/ee/event-types-private-links/private-links.repository.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } : {}), | ||
| }, | ||
| }); | ||
| } | ||
alishaz-polymath marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 } : {}), | ||
| }, | ||
| }); | ||
alishaz-polymath marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| async delete(eventTypeId: number, linkId: string) { | ||
| return this.dbWrite.prisma.hashedLink.deleteMany({ where: { eventTypeId, link: linkId } }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.