diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 83531a3b88..4cc8b9ce90 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.321", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts index bd61468ee8..faa856e291 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/bookings.module.ts @@ -22,6 +22,7 @@ import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module"; import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { KyselyModule } from "@/modules/kysely/kysely.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; @@ -53,6 +54,7 @@ import { Module } from "@nestjs/common"; StripeModule, TeamsModule, TeamsEventTypesModule, + MembershipsModule, ], providers: [ TokensRepository, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts index 981a59b6af..092b4f9c4a 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/bookings.controller.ts @@ -13,10 +13,14 @@ import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.ser import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions"; import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers"; import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { + AuthOptionalUser, + GetOptionalUser, +} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UsersService } from "@/modules/users/services/users.service"; import { UserWithProfile } from "@/modules/users/users.repository"; @@ -96,6 +100,7 @@ export class BookingsController_2024_08_13 { ) {} @Post("/") + @UseGuards(OptionalApiAuthGuard) @ApiOperation({ summary: "Create a booking", description: ` @@ -138,9 +143,10 @@ export class BookingsController_2024_08_13 { async createBooking( @Body(new CreateBookingInputPipe()) body: CreateBookingInput, - @Req() request: Request + @Req() request: Request, + @GetOptionalUser() user: AuthOptionalUser ): Promise { - const booking = await this.bookingsService.createBooking(request, body); + const booking = await this.bookingsService.createBooking(request, body, user); if (Array.isArray(booking)) { await this.bookingsService.billBookings(booking); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts index 3e813bc117..eef32e1823 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts @@ -3,6 +3,7 @@ import { AppModule } from "@/app.module"; import { HttpExceptionFilter } from "@/filters/http-exception.filter"; import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; import { Locales } from "@/lib/enums/locales"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { CreateManagedUserData, CreateManagedUserOutput, @@ -80,7 +81,7 @@ describe("Managed user bookings 2024-08-13", () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [PrismaExceptionFilter, HttpExceptionFilter], - imports: [AppModule, UsersModule], + imports: [AppModule, UsersModule, MembershipsModule], }).compile(); app = moduleRef.createNestApplication(); @@ -772,6 +773,77 @@ describe("Managed user bookings 2024-08-13", () => { }); }); + describe("event type booking requires authentication", () => { + let eventTypeRequiringAuthenticationId: number; + + let body: CreateBookingInput_2024_08_13; + + beforeAll(async () => { + const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create( + { + title: `event-type-requiring-authentication-${randomString()}`, + slug: `event-type-requiring-authentication-${randomString()}`, + length: 60, + requiresConfirmation: true, + bookingRequiresAuthentication: true, + }, + secondManagedUser.user.id + ); + eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id; + + body = { + start: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)).toISOString(), + eventTypeId: eventTypeRequiringAuthenticationId, + attendee: { + email: "external@example.com", + name: "External Attendee", + timeZone: "Europe/Rome", + }, + }; + }); + + it("can't be booked without credentials", async () => { + await request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(401); + }); + + it("can't be booked with managed user credentials who is not admin and not event type owner", async () => { + await request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${firstManagedUser.accessToken}`) + .expect(403); + }); + + it("can be booked with managed user credentials who is event type owner", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${secondManagedUser.accessToken}`) + .expect(201); + + const bookingId = response.body.data.id; + await bookingsRepositoryFixture.deleteById(bookingId); + }); + + it("can be booked with managed user credentials who is admin", async () => { + const response = await request(app.getHttpServer()) + .post(`/v2/bookings`) + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`) + .expect(201); + + const bookingId = response.body.data.id; + await bookingsRepositoryFixture.deleteById(bookingId); + }); + }); + afterAll(async () => { await userRepositoryFixture.delete(firstManagedUser.user.id); await userRepositoryFixture.delete(secondManagedUser.user.id); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index 6ec04b417a..c66307bf06 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -6,9 +6,12 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service"; import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; import { getPagination } from "@/lib/pagination/pagination"; +import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator"; import { BillingService } from "@/modules/billing/services/billing.service"; import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository"; import { KyselyReadService } from "@/modules/kysely/kysely-read.service"; +import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { MembershipsService } from "@/modules/memberships/services/memberships.service"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; @@ -18,7 +21,14 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve import { TeamsRepository } from "@/modules/teams/teams/teams.repository"; import { UsersService } from "@/modules/users/services/users.service"; import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; -import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; import { BadRequestException } from "@nestjs/common"; import { Request } from "express"; import { DateTime } from "luxon"; @@ -95,10 +105,12 @@ export class BookingsService_2024_08_13 { private readonly organizationsRepository: OrganizationsRepository, private readonly teamsRepository: TeamsRepository, private readonly teamsEventTypesRepository: TeamsEventTypesRepository, + private readonly membershipsRepository: MembershipsRepository, + private readonly membershipsService: MembershipsService, private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13 ) {} - async createBooking(request: Request, body: CreateBookingInput) { + async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) { let bookingTeamEventType = false; try { const eventType = await this.getBookedEventType(body); @@ -108,6 +120,7 @@ export class BookingsService_2024_08_13 { if (!eventType) { this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body); } + await this.checkBookingRequiresAuthenticationSetting(eventType, authUser); if (eventType.schedulingType === "MANAGED") { throw new BadRequestException( @@ -142,6 +155,56 @@ export class BookingsService_2024_08_13 { } } + async checkBookingRequiresAuthenticationSetting( + eventType: EventTypeWithOwnerAndTeam, + authUser: AuthOptionalUser + ) { + if (!eventType.bookingRequiresAuthentication) return true; + if (!authUser) { + throw new UnauthorizedException( + "checkBookingRequiresAuthentication - request must be authenticated by passing credentials belonging to event type owner, host or team or org admin or owner." + ); + } + + const authUserId = authUser.id; + const authUserRole = authUser.role; + const eventTypeId = eventType.id; + const teamId = eventType.teamId; + const bookingUserId = eventType.owner?.id || null; + + if (authUserRole === "ADMIN") return; + + if (bookingUserId === authUserId) return; + + if (eventTypeId) { + const [isUserHost, isUserAssigned] = await Promise.all([ + this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId), + this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId), + ]); + + if (isUserHost || isUserAssigned) return; + } + + if (teamId) { + const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership( + authUserId, + teamId + ); + if (membership) return; + } + + if ( + bookingUserId && + (await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, bookingUserId)) + ) { + return; + } + + throw new ForbiddenException( + "checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner." + ); + } + async getBookedEventType(body: CreateBookingInput) { if (body.eventTypeId) { return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId); diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts index 21a0ee75d5..9f22ce102e 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.e2e-spec.ts @@ -498,6 +498,7 @@ describe("Event types Endpoints", () => { lightThemeHex: "#fafafa", }, customName: `{Event type title} between {Organiser} and {Scheduler}`, + bookingRequiresAuthentication: true, }; return request(app.getHttpServer()) @@ -570,6 +571,7 @@ describe("Event types Endpoints", () => { ]; expect(createdEventType.bookingFields).toEqual(expectedBookingFields); + expect(createdEventType.bookingRequiresAuthentication).toEqual(true); eventType = responseBody.data; }); }); @@ -1026,6 +1028,7 @@ describe("Event types Endpoints", () => { lightThemeHex: "#fafafa", }, customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`, + bookingRequiresAuthentication: false, }; return request(app.getHttpServer()) @@ -1120,6 +1123,8 @@ describe("Event types Endpoints", () => { eventType.color = updatedEventType.color; eventType.bookingFields = updatedEventType.bookingFields; eventType.calVideoSettings = updatedEventType.calVideoSettings; + + expect(updatedEventType.bookingRequiresAuthentication).toEqual(false); }); }); @@ -2028,7 +2033,6 @@ describe("Event types Endpoints", () => { const username = name; let user: User; let legacyEventTypeId1: number; - let legacyEventTypeId2: number; beforeAll(async () => { const moduleRef = await withApiAuth( diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts index 5dea699a28..c648b0b4bf 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/event-types.repository.ts @@ -132,4 +132,26 @@ export class EventTypesRepository_2024_06_14 { async deleteEventType(eventTypeId: number) { return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } }); } + + async isUserHostOfEventType(userId: number, eventTypeId: number) { + const eventType = await this.dbRead.prisma.eventType.findFirst({ + where: { + id: eventTypeId, + hosts: { some: { userId: userId } }, + }, + select: { id: true }, + }); + return !!eventType; + } + + async isUserAssignedToEventType(userId: number, eventTypeId: number) { + const eventType = await this.dbRead.prisma.eventType.findFirst({ + where: { + id: eventTypeId, + users: { some: { id: userId } }, + }, + select: { id: true }, + }); + return !!eventType; + } } diff --git a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts index 16db8f263e..2df15e3c3d 100644 --- a/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts +++ b/apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/output-event-types.service.ts @@ -90,6 +90,7 @@ type Input = Pick< | "hideOrganizerEmail" | "calVideoSettings" | "hidden" + | "bookingRequiresAuthentication" >; @Injectable() @@ -129,6 +130,7 @@ export class OutputEventTypesService_2024_06_14 { hideOrganizerEmail, calVideoSettings, hidden, + bookingRequiresAuthentication, } = databaseEventType; const locations = this.transformLocations(databaseEventType.locations); @@ -206,6 +208,7 @@ export class OutputEventTypesService_2024_06_14 { hideOrganizerEmail, calVideoSettings, hidden, + bookingRequiresAuthentication, }; } diff --git a/apps/api/v2/src/modules/auth/decorators/get-optional-user/get-optional-user.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-optional-user/get-optional-user.decorator.ts index 4566a9e747..a27cf32014 100644 --- a/apps/api/v2/src/modules/auth/decorators/get-optional-user/get-optional-user.decorator.ts +++ b/apps/api/v2/src/modules/auth/decorators/get-optional-user/get-optional-user.decorator.ts @@ -2,6 +2,8 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st import { ExecutionContext } from "@nestjs/common"; import { createParamDecorator } from "@nestjs/common"; +export type AuthOptionalUser = ApiAuthGuardUser | null; + export const GetOptionalUser = createParamDecorator< keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[], ExecutionContext diff --git a/apps/api/v2/src/modules/memberships/memberships.repository.ts b/apps/api/v2/src/modules/memberships/memberships.repository.ts index d51de8e7e8..578fd508c8 100644 --- a/apps/api/v2/src/modules/memberships/memberships.repository.ts +++ b/apps/api/v2/src/modules/memberships/memberships.repository.ts @@ -1,7 +1,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { Injectable } from "@nestjs/common"; -import { MembershipRole } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; @Injectable() export class MembershipsRepository { @@ -90,6 +90,26 @@ export class MembershipsRepository { return !!adminMembership; } + async getOrgIdsWhereUserIsAdminOrOwner(userId: number) { + const userOrgAdminMemberships = await this.dbRead.prisma.membership.findMany({ + where: { + userId: userId, + accepted: true, + role: { + in: ["OWNER", "ADMIN"], + }, + team: { + parentId: null, + }, + }, + select: { + teamId: true, + }, + }); + + return userOrgAdminMemberships.map((m) => m.teamId); + } + async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) { const membership = await this.dbRead.prisma.membership.create({ data: { @@ -103,4 +123,46 @@ export class MembershipsRepository { return membership; } + + async getUserAdminOrOwnerTeamMembership(userId: number, teamId: number) { + return this.dbRead.prisma.membership.findFirst({ + where: { + userId: userId, + teamId: teamId, + accepted: true, + role: { + in: ["OWNER", "ADMIN"], + }, + }, + }); + } + + async getUserMembershipInOneOfOrgs(userId: number, orgIds: number[]) { + return this.dbRead.prisma.membership.findFirst({ + where: { + userId: userId, + accepted: true, + teamId: { + in: orgIds, + }, + team: { + parentId: null, + }, + }, + }); + } + + async getUserMembershipInOneOfOrgsTeams(userId: number, orgIds: number[]) { + return this.dbRead.prisma.membership.findFirst({ + where: { + userId: userId, + accepted: true, + team: { + parentId: { + in: orgIds, + }, + }, + }, + }); + } } diff --git a/apps/api/v2/src/modules/memberships/services/memberships.service.ts b/apps/api/v2/src/modules/memberships/services/memberships.service.ts index 815e35b8ed..4e20533d12 100644 --- a/apps/api/v2/src/modules/memberships/services/memberships.service.ts +++ b/apps/api/v2/src/modules/memberships/services/memberships.service.ts @@ -21,4 +21,28 @@ export class MembershipsService { "teamId" ); } + + async isUserOrgAdminOrOwnerOfAnotherUser(userId: number, anotherUserId: number) { + const orgIdsWhereUserIsAdminOrOwner = await this.membershipsRepository.getOrgIdsWhereUserIsAdminOrOwner( + userId + ); + + if (orgIdsWhereUserIsAdminOrOwner.length === 0) { + return false; + } + + const anotherUserOrgMembership = await this.membershipsRepository.getUserMembershipInOneOfOrgs( + anotherUserId, + orgIdsWhereUserIsAdminOrOwner + ); + + if (anotherUserOrgMembership) return true; + + const anotherUserOrgTeamMembership = await this.membershipsRepository.getUserMembershipInOneOfOrgsTeams( + anotherUserId, + orgIdsWhereUserIsAdminOrOwner + ); + + return !!anotherUserOrgTeamMembership; + } } diff --git a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts index e0af1153dd..2c6fe32577 100644 --- a/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts +++ b/apps/api/v2/src/modules/organizations/event-types/services/output.service.ts @@ -75,6 +75,7 @@ type Input = Pick< | "team" | "calVideoSettings" | "hidden" + | "bookingRequiresAuthentication" >; @Injectable() diff --git a/apps/api/v2/src/modules/organizations/teams/schedules/organizations-teams-schedules.controller.ts b/apps/api/v2/src/modules/organizations/teams/schedules/organizations-teams-schedules.controller.ts index 87e078e775..229cca73b1 100644 --- a/apps/api/v2/src/modules/organizations/teams/schedules/organizations-teams-schedules.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/schedules/organizations-teams-schedules.controller.ts @@ -43,6 +43,8 @@ export class OrganizationsTeamsSchedulesController { @DocsTags("Orgs / Teams / Schedules") @ApiOperation({ summary: "Get all team member schedules" }) async getTeamSchedules( + // note(Lauirs): putting orgId so swagger is generacted correctly + @Param("orgId", ParseIntPipe) _orgId: number, @Param("teamId", ParseIntPipe) teamId: number, @Query() queryParams: SkipTakePagination ): Promise { @@ -65,6 +67,9 @@ export class OrganizationsTeamsSchedulesController { summary: "Get schedules of a team member", }) async getUserSchedules( + // note(Lauirs): putting orgId and teamId so swagger is generacted correctly + @Param("orgId", ParseIntPipe) _orgId: number, + @Param("teamId", ParseIntPipe) _teamId: number, @Param("userId", ParseIntPipe) userId: number ): Promise { const schedules = await this.schedulesService.getUserSchedules(userId); diff --git a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts index 2757573daf..ae160e1dc8 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-09-04/controllers/slots.controller.ts @@ -1,6 +1,9 @@ import { VERSION_2024_09_04 } from "@/lib/api-versions"; import { OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER } from "@/lib/docs/headers"; -import { GetOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator"; +import { + AuthOptionalUser, + GetOptionalUser, +} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator"; import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard"; import { GetReservedSlotOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-reserved-slot.output"; import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output"; @@ -26,7 +29,6 @@ import { ApiResponse as DocsResponse, ApiQuery, } from "@nestjs/swagger"; -import { User } from "@prisma/client"; import { plainToClass } from "class-transformer"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; @@ -261,7 +263,7 @@ export class SlotsController_2024_09_04 { @ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER) async reserveSlot( @Body() body: ReserveSlotInput_2024_09_04, - @GetOptionalUser() user: User + @GetOptionalUser() user: AuthOptionalUser ): Promise { const reservedSlot = await this.slotsService.reserveSlot(body, user?.id); diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index b6055d15dc..327b23877a 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -5547,6 +5547,97 @@ ] } }, + "/v2/organizations/{orgId}/teams/{teamId}/schedules": { + "get": { + "operationId": "OrganizationsTeamsSchedulesController_getTeamSchedules", + "summary": "Get all team member schedules", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "example": 25, + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Orgs / Teams / Schedules" + ] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": { "get": { "operationId": "OrganizationsStripeController_getTeamStripeConnectUrl", @@ -5720,6 +5811,22 @@ "type": "string" } }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, { "name": "userId", "required": true, @@ -13215,6 +13322,71 @@ ] } }, + "/v2/teams/{teamId}/schedules": { + "get": { + "operationId": "TeamsSchedulesController_getTeamSchedules", + "summary": "Get all team member schedules", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "example": 25, + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": [ + "Teams / Schedules" + ] + } + }, "/v2/teams/{teamId}/verified-resources/emails/verification-code/request": { "post": { "operationId": "TeamsVerifiedResourcesController_requestEmailVerificationCode", @@ -16504,6 +16676,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "locations": { "type": "array", "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", @@ -18166,6 +18346,13 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "ownerId": { "type": "number", "example": 10 @@ -18195,6 +18382,8 @@ "successRedirectUrl", "isInstantEvent", "scheduleId", + "hidden", + "bookingRequiresAuthentication", "ownerId", "users" ] @@ -18515,6 +18704,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "locations": { "type": "array", "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", @@ -20343,6 +20540,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "schedulingType": { "type": "string", "enum": [ @@ -20777,6 +20982,13 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "teamId": { "type": "number" }, @@ -21223,6 +21435,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "hosts": { "type": "array", "items": { @@ -22589,6 +22809,11 @@ }, "unit": { "type": "object", + "enum": [ + "hour", + "minute", + "day" + ], "description": "Unit for the offset time", "example": "hour" } @@ -22612,6 +22837,9 @@ "type": { "type": "string", "default": "beforeEvent", + "enum": [ + "beforeEvent" + ], "description": "Trigger type for the workflow", "example": "beforeEvent" } @@ -22635,6 +22863,9 @@ "type": { "type": "string", "default": "afterEvent", + "enum": [ + "afterEvent" + ], "description": "Trigger type for the workflow", "example": "afterEvent" } @@ -22650,6 +22881,9 @@ "type": { "type": "string", "default": "eventCancelled", + "enum": [ + "eventCancelled" + ], "description": "Trigger type for the workflow" } }, @@ -22663,6 +22897,9 @@ "type": { "type": "string", "default": "newEvent", + "enum": [ + "newEvent" + ], "description": "Trigger type for the workflow" } }, @@ -22676,6 +22913,9 @@ "type": { "type": "string", "default": "rescheduleEvent", + "enum": [ + "rescheduleEvent" + ], "description": "Trigger type for the workflow" } }, @@ -22697,6 +22937,9 @@ "type": { "type": "string", "default": "afterGuestsCalVideoNoShow", + "enum": [ + "afterGuestsCalVideoNoShow" + ], "description": "Trigger type for the workflow", "example": "afterGuestsCalVideoNoShow" } @@ -22720,6 +22963,9 @@ "type": { "type": "string", "default": "afterHostsCalVideoNoShow", + "enum": [ + "afterHostsCalVideoNoShow" + ], "description": "Trigger type for the workflow", "example": "afterHostsCalVideoNoShow" } @@ -23314,6 +23560,17 @@ "properties": { "type": { "type": "object", + "enum": [ + [ + "beforeEvent", + "eventCancelled", + "newEvent", + "afterEvent", + "rescheduleEvent", + "afterHostsCalVideoNoShow", + "afterGuestsCalVideoNoShow" + ] + ], "description": "Trigger type for the workflow" } }, diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index b8a7e892bd..322e220fe9 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -5315,6 +5315,95 @@ "tags": ["Orgs / Teams / Routing forms / Responses"] } }, + "/v2/organizations/{orgId}/teams/{teamId}/schedules": { + "get": { + "operationId": "OrganizationsTeamsSchedulesController_getTeamSchedules", + "summary": "Get all team member schedules", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "For non-platform customers - value must be `Bearer ` where `` is api key prefixed with cal_", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-secret-key", + "in": "header", + "description": "For platform customers - OAuth client secret key", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "x-cal-client-id", + "in": "header", + "description": "For platform customers - OAuth client ID", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "example": 25, + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": ["Orgs / Teams / Schedules"] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": { "get": { "operationId": "OrganizationsStripeController_getTeamStripeConnectUrl", @@ -5482,6 +5571,22 @@ "type": "string" } }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, { "name": "userId", "required": true, @@ -12629,6 +12734,69 @@ "tags": ["Teams / Memberships"] } }, + "/v2/teams/{teamId}/schedules": { + "get": { + "operationId": "TeamsSchedulesController_getTeamSchedules", + "summary": "Get all team member schedules", + "parameters": [ + { + "name": "Authorization", + "in": "header", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "Maximum number of items to return", + "example": 25, + "schema": { + "minimum": 1, + "maximum": 250, + "default": 250, + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "Number of items to skip", + "example": 0, + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11" + } + } + } + } + }, + "tags": ["Teams / Schedules"] + } + }, "/v2/teams/{teamId}/verified-resources/emails/verification-code/request": { "post": { "operationId": "TeamsVerifiedResourcesController_requestEmailVerificationCode", @@ -15566,6 +15734,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "locations": { "type": "array", "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", @@ -17045,6 +17221,13 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "ownerId": { "type": "number", "example": 10 @@ -17074,6 +17257,8 @@ "successRedirectUrl", "isInstantEvent", "scheduleId", + "hidden", + "bookingRequiresAuthentication", "ownerId", "users" ] @@ -17372,6 +17557,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "locations": { "type": "array", "description": "Locations where the event will take place. If not provided, cal video link will be used as the location.", @@ -18935,6 +19128,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "schedulingType": { "type": "string", "enum": ["collective", "roundRobin", "managed"], @@ -19338,6 +19539,13 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "teamId": { "type": "number" }, @@ -19747,6 +19955,14 @@ } ] }, + "hidden": { + "type": "boolean" + }, + "bookingRequiresAuthentication": { + "type": "boolean", + "default": false, + "description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type." + }, "hosts": { "type": "array", "items": { @@ -20844,6 +21060,7 @@ }, "unit": { "type": "object", + "enum": ["hour", "minute", "day"], "description": "Unit for the offset time", "example": "hour" } @@ -20864,6 +21081,7 @@ "type": { "type": "string", "default": "beforeEvent", + "enum": ["beforeEvent"], "description": "Trigger type for the workflow", "example": "beforeEvent" } @@ -20884,6 +21102,7 @@ "type": { "type": "string", "default": "afterEvent", + "enum": ["afterEvent"], "description": "Trigger type for the workflow", "example": "afterEvent" } @@ -20896,6 +21115,7 @@ "type": { "type": "string", "default": "eventCancelled", + "enum": ["eventCancelled"], "description": "Trigger type for the workflow" } }, @@ -20907,6 +21127,7 @@ "type": { "type": "string", "default": "newEvent", + "enum": ["newEvent"], "description": "Trigger type for the workflow" } }, @@ -20918,6 +21139,7 @@ "type": { "type": "string", "default": "rescheduleEvent", + "enum": ["rescheduleEvent"], "description": "Trigger type for the workflow" } }, @@ -20937,6 +21159,7 @@ "type": { "type": "string", "default": "afterGuestsCalVideoNoShow", + "enum": ["afterGuestsCalVideoNoShow"], "description": "Trigger type for the workflow", "example": "afterGuestsCalVideoNoShow" } @@ -20957,6 +21180,7 @@ "type": { "type": "string", "default": "afterHostsCalVideoNoShow", + "enum": ["afterHostsCalVideoNoShow"], "description": "Trigger type for the workflow", "example": "afterHostsCalVideoNoShow" } @@ -21428,6 +21652,17 @@ "properties": { "type": { "type": "object", + "enum": [ + [ + "beforeEvent", + "eventCancelled", + "newEvent", + "afterEvent", + "rescheduleEvent", + "afterHostsCalVideoNoShow", + "afterGuestsCalVideoNoShow" + ] + ], "description": "Trigger type for the workflow" } }, diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index b3d234424e..20eebec16b 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -147,6 +147,7 @@ const commons = { instantMeetingParameters: [], eventTypeColor: null, hostGroups: [], + bookingRequiresAuthentication: false, }; export const dynamicEvent = { diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index f967e421c2..c37d0cec07 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -160,6 +160,7 @@ export const buildEventType = (eventType?: Partial): EventType => { customReplyToEmail: null, restrictionScheduleId: null, useBookerTimezone: false, + bookingRequiresAuthentication: false, ...eventType, }; }; diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts index cb716300de..5ea2b2e49d 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/create-event-type.input.ts @@ -451,6 +451,15 @@ class BaseCreateEventTypeInput { @IsBoolean() @DocsPropertyOptional() hidden?: boolean; + + @IsOptional() + @IsBoolean() + @DocsPropertyOptional({ + default: false, + description: + "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type.", + }) + bookingRequiresAuthentication?: boolean; } export class CreateEventTypeInput_2024_06_14 extends BaseCreateEventTypeInput { @IsOptional() diff --git a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts index f4c711c888..c91b85bc7d 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/inputs/update-event-type.input.ts @@ -419,6 +419,15 @@ class BaseUpdateEventTypeInput { @IsBoolean() @DocsPropertyOptional() hidden?: boolean; + + @IsOptional() + @IsBoolean() + @DocsPropertyOptional({ + default: false, + description: + "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type.", + }) + bookingRequiresAuthentication?: boolean; } export class UpdateEventTypeInput_2024_06_14 extends BaseUpdateEventTypeInput { @IsOptional() diff --git a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts index b94e7faf93..09bf5fe172 100644 --- a/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts +++ b/packages/platform/types/event-types/event-types_2024_06_14/outputs/event-type.output.ts @@ -444,6 +444,14 @@ class BaseEventTypeOutput_2024_06_14 { @IsBoolean() @DocsProperty() hidden?: boolean; + + @IsOptional() + @IsBoolean() + @DocsProperty({ + description: + "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type.", + }) + bookingRequiresAuthentication?: boolean; } export class TeamEventTypeResponseHost extends TeamEventTypeHostInput { diff --git a/packages/prisma/migrations/20250820094118_add_event_type_booking_requires_auth_column/migration.sql b/packages/prisma/migrations/20250820094118_add_event_type_booking_requires_auth_column/migration.sql new file mode 100644 index 0000000000..967a3ceb5e --- /dev/null +++ b/packages/prisma/migrations/20250820094118_add_event_type_booking_requires_auth_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "bookingRequiresAuthentication" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index a905c2aee8..6c172e2cf0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -228,6 +228,8 @@ model EventType { restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id]) hostGroups HostGroup[] + bookingRequiresAuthentication Boolean @default(false) + @@unique([userId, slug]) @@unique([teamId, slug]) @@unique([userId, parentId]) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 32e9d2495e..3937a8aea0 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -681,6 +681,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit