refactor: v2 api event-types/:eventTypeId access (#24969)

* refactor: EventTypeAccess service

* feat: event-types/:id system admin access and team event access

* fix: implement cubic feedback

* fix: e2e

* fix: e2e

* fix: oasdiff ignore non-breaking change

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2025-11-07 21:17:16 +02:00
committed by GitHub
co-authored by Udit Takkar
parent 81a99ea0b2
commit 829edec0ea
10 changed files with 725 additions and 488 deletions
+2
View File
@@ -0,0 +1,2 @@
GET /v2/event-types/{eventTypeId} added to the 'data' response property 'oneOf' list for the response status '200'
GET /v2/event-types/{eventTypeId} added '#/components/schemas/EventTypeOutput_2024_06_14, #/components/schemas/TeamEventTypeOutput_2024_06_14' to the 'data' response property 'oneOf' list for the response status '200'
@@ -1,5 +1,5 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
@@ -13,9 +13,8 @@ import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/ge
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
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";
@@ -109,12 +108,11 @@ 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,
private readonly regularBookingService: RegularBookingService,
private readonly recurringBookingService: RecurringBookingService,
private readonly instantBookingCreateService: InstantBookingCreateService
private readonly instantBookingCreateService: InstantBookingCreateService,
private readonly eventTypeAccessService: EventTypeAccessService
) {}
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
@@ -128,7 +126,7 @@ export class BookingsService_2024_08_13 {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
const userIsEventTypeAdminOrOwner = authUser
? await this.userIsEventTypeAdminOrOwner(authUser, eventType)
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, eventType)
: false;
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser, userIsEventTypeAdminOrOwner);
@@ -197,44 +195,6 @@ export class BookingsService_2024_08_13 {
}
}
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType) {
const authUserId = authUser.id;
const authUserRole = authUser.role;
const eventTypeId = eventType.id;
const teamId = eventType.teamId;
const eventTypeOwnerId = eventType.userId || null;
if (authUserRole === "ADMIN") return true;
if (eventTypeOwnerId === authUserId) return true;
if (eventTypeId) {
const [isUserHost, isUserAssigned] = await Promise.all([
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
]);
if (isUserHost || isUserAssigned) return true;
}
if (teamId) {
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(
authUserId,
teamId
);
if (membership) return true;
}
if (
eventTypeOwnerId &&
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId))
) {
return true;
}
return false;
}
async getBookedEventType(body: CreateBookingInput) {
if (body.eventTypeId) {
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
@@ -616,7 +576,7 @@ export class BookingsService_2024_08_13 {
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
const userIsEventTypeAdminOrOwner =
authUser && booking?.eventType
? await this.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, booking.eventType)
: false;
if (booking) {
@@ -805,7 +765,7 @@ export class BookingsService_2024_08_13 {
const userIsEventTypeAdminOrOwner =
authUser && databaseBooking.eventType
? await this.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(authUser, databaseBooking.eventType)
: false;
const isRecurring = !!databaseBooking.recurringEventId;
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
@@ -22,17 +22,31 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, ConfirmationPolicyEnum, NoticeThresholdUnitEnum, FrequencyInput } from "@calcom/platform-enums";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
ConfirmationPolicyEnum,
NoticeThresholdUnitEnum,
FrequencyInput,
} from "@calcom/platform-enums";
import { SchedulingType } from "@calcom/platform-libraries";
import { type ApiSuccessResponse, type CreateEventTypeInput_2024_06_14, type EventTypeOutput_2024_06_14, type GuestsDefaultFieldOutput_2024_06_14, type NameDefaultFieldInput_2024_06_14, type NotesDefaultFieldInput_2024_06_14, type SplitNameDefaultFieldOutput_2024_06_14, type UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import {
BaseConfirmationPolicy_2024_06_14,
TeamEventTypeOutput_2024_06_14,
type ApiSuccessResponse,
type CreateEventTypeInput_2024_06_14,
type EventTypeOutput_2024_06_14,
type GuestsDefaultFieldOutput_2024_06_14,
type NameDefaultFieldInput_2024_06_14,
type NotesDefaultFieldInput_2024_06_14,
type SplitNameDefaultFieldOutput_2024_06_14,
type UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { FAILED_RECURRING_EVENT_TYPE_WITH_BOOKER_LIMITS_ERROR_MESSAGE } from "@calcom/platform-types/event-types/event-types_2024_06_14/inputs/validators/CantHaveRecurrenceAndBookerActiveBookingsLimit";
import { REQUIRES_AT_LEAST_ONE_PROPERTY_ERROR } from "@calcom/platform-types/utils/RequiresOneOfPropertiesWhenNotDisabled";
import type { PlatformOAuthClient, Team, User, Schedule, EventType } from "@calcom/prisma/client";
const orderBySlug = (a: { slug: string }, b: { slug: string }) => {
if (a.slug < b.slug) return -1;
if (a.slug > b.slug) return 1;
@@ -83,6 +97,8 @@ describe("Event types Endpoints", () => {
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
let apiKeyOrgUser: string;
let systemAdminUser: User;
let systemAdminApiKeyString: string;
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
@@ -196,6 +212,17 @@ describe("Event types Endpoints", () => {
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;
systemAdminUser = await userRepositoryFixture.create({
email: `event-types-2024-06-14-system-admin-${randomString()}@api.com`,
username: `event-types-2024-06-14-system-admin-${randomString()}`,
role: "ADMIN",
});
const { keyString: adminKeyString } = await apiKeysRepositoryFixture.createApiKey(
systemAdminUser.id,
null
);
systemAdminApiKeyString = `cal_test_${adminKeyString}`;
orgUser = await userRepositoryFixture.create({
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
name: `event-types-2024-06-14-org-user-${randomString()}`,
@@ -1375,6 +1402,99 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.color).toEqual(eventType.color);
});
it("system admin can access another user's event type by id", async () => {
return request(app.getHttpServer())
.get(`/api/v2/event-types/${orgUserEventType1.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${systemAdminApiKeyString}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(orgUserEventType1.id);
expect(responseBody.data.ownerId).toEqual(orgUser.id);
});
});
it("user can access a team event type as team member (using user's API key)", async () => {
const team = await teamRepositoryFixture.create({
name: `event-types-2024-06-14-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
title: `event-types-2024-06-14-team-event-${randomString()}`,
slug: `event-types-2024-06-14-team-event-${randomString()}`,
length: 60,
locations: [],
schedulingType: "COLLECTIVE",
team: { connect: { id: team.id } },
});
return request(app.getHttpServer())
.get(`/api/v2/event-types/${teamEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamEventType.id);
expect(responseBody.data.teamId).toEqual(team.id);
await teamRepositoryFixture.delete(team.id);
});
});
it("user can access a team event type as HOST even if membership role is MEMBER (using user's API key)", async () => {
const team = await teamRepositoryFixture.create({
name: `event-types-2024-06-14-host-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
title: `event-types-2024-06-14-host-event-${randomString()}`,
slug: `event-types-2024-06-14-host-event-${randomString()}`,
length: 60,
locations: [],
schedulingType: "COLLECTIVE",
team: { connect: { id: team.id } },
hosts: {
create: [
{
user: { connect: { id: user.id } },
},
],
},
});
return request(app.getHttpServer())
.get(`/api/v2/event-types/${teamEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamEventType.id);
expect(responseBody.data.teamId).toEqual(team.id);
await teamRepositoryFixture.delete(team.id);
});
});
it(`/GET/event-types by username and eventSlug`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.slug}`)
@@ -1780,7 +1900,7 @@ describe("Event types Endpoints", () => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
const policy = updatedEventType.confirmationPolicy as any;
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
expect(policy?.type).toEqual(ConfirmationPolicyEnum.ALWAYS);
expect(policy?.blockUnconfirmedBookingsInBooker).toEqual(false);
expect(policy?.noticeThreshold).toBeUndefined();
@@ -1808,7 +1928,7 @@ describe("Event types Endpoints", () => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;
const policy = updatedEventType.confirmationPolicy as any;
const policy = updatedEventType.confirmationPolicy as BaseConfirmationPolicy_2024_06_14;
expect(policy?.type).toEqual(ConfirmationPolicyEnum.TIME);
expect(policy?.noticeThreshold).toEqual({
unit: NoticeThresholdUnitEnum.MINUTES,
@@ -2793,4 +2913,4 @@ describe("Event types Endpoints", () => {
await app.close();
});
});
});
});
@@ -23,6 +23,8 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
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 { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -73,7 +75,8 @@ export class EventTypesController_2024_06_14 {
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14,
private readonly outputTeamEventTypesResponsePipe: OutputTeamEventTypesResponsePipe
) {}
@Post("/")
@@ -107,21 +110,36 @@ export class EventTypesController_2024_06_14 {
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get an event type",
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>`,
description: `<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
Access control: This endpoint fetches an event type by ID and returns it only if the authenticated user is authorized. Authorization is granted to:
- System admins
- The event type owner
- Hosts of the event type or users assigned to the event type
- Team admins/owners of the team that owns the team event type
- Organization admins/owners of the event type owner's organization
- Organization admins/owners of the team's parent organization
Note: Update and delete endpoints remain restricted to the event type owner only.`,
})
async getEventTypeById(
@Param("eventTypeId") eventTypeId: string,
@GetUser() user: UserWithProfile
@GetUser() user: ApiAuthGuardUser
): Promise<GetEventTypeOutput_2024_06_14> {
const eventType = await this.eventTypesService.getUserEventType(user.id, Number(eventTypeId));
const eventType = await this.eventTypesService.getEventTypeByIdIfAuthorized(user, Number(eventTypeId));
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
const responseEventType =
"hosts" in eventType
? await this.outputTeamEventTypesResponsePipe.transform(eventType)
: this.eventTypeResponseTransformPipe.transform(eventType);
return {
status: SUCCESS_STATUS,
data: this.eventTypeResponseTransformPipe.transform(eventType),
data: responseEventType,
};
}
@@ -10,10 +10,15 @@ import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -26,6 +31,8 @@ import { Module } from "@nestjs/common";
EventTypesService_2024_06_14,
InputEventTypesService_2024_06_14,
OutputEventTypesService_2024_06_14,
EventTypeAccessService,
TeamsRepository,
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
@@ -35,6 +42,9 @@ import { Module } from "@nestjs/common";
CredentialsRepository,
AppsRepository,
CalendarsRepository,
OutputTeamEventTypesResponsePipe,
OutputOrganizationsEventTypesService,
TeamsEventTypesRepository,
],
controllers: [EventTypesController_2024_06_14],
exports: [
@@ -42,6 +52,7 @@ import { Module } from "@nestjs/common";
EventTypesRepository_2024_06_14,
InputEventTypesService_2024_06_14,
OutputEventTypesService_2024_06_14,
EventTypeAccessService,
],
})
export class EventTypesModule_2024_06_14 {}
@@ -93,6 +93,19 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getEventTypeByIdWithHosts(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: {
users: true,
schedule: true,
destinationCalendar: true,
calVideoSettings: true,
hosts: true,
},
});
}
async getEventTypeByIdIncludeUsersAndTeam(eventTypeId: number) {
const eventType = await this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
@@ -1,19 +1,22 @@
import { ApiProperty } from "@nestjs/swagger";
import { ApiExtraModels, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, ValidateNested } from "class-validator";
import { IsIn } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { EventTypeOutput_2024_06_14 } from "@calcom/platform-types";
import { EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
@ApiExtraModels(EventTypeOutput_2024_06_14, TeamEventTypeOutput_2024_06_14)
export class GetEventTypeOutput_2024_06_14 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsIn([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: EventTypeOutput_2024_06_14,
oneOf: [
{ $ref: getSchemaPath(EventTypeOutput_2024_06_14) },
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
],
})
@ValidateNested()
@Type(() => EventTypeOutput_2024_06_14)
data!: EventTypeOutput_2024_06_14 | null;
@Type(() => Object)
data!: EventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14 | null;
}
@@ -1,10 +1,14 @@
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/constants/constants";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { DatabaseEventType } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { DatabaseTeamEventType } from "@/modules/organizations/event-types/services/output.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersService } from "@/modules/users/services/users.service";
@@ -30,7 +34,8 @@ export class EventTypesService_2024_06_14 {
private readonly usersService: UsersService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService,
private readonly schedulesRepository: SchedulesRepository_2024_06_11
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
private readonly eventTypeAccessService: EventTypeAccessService
) {}
async createUserEventType(user: UserWithProfile, body: InputEventTransformed_2024_06_14) {
@@ -39,7 +44,7 @@ export class EventTypesService_2024_06_14 {
}
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
const { destinationCalendar: _destinationCalendar, ...rest } = body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -79,6 +84,31 @@ export class EventTypesService_2024_06_14 {
};
}
async getEventTypeByIdIfAuthorized(
authUser: ApiAuthGuardUser,
eventTypeId: number
): Promise<DatabaseTeamEventType | ({ ownerId: number } & DatabaseEventType) | null> {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithHosts(eventTypeId);
if (!eventType) {
return null;
}
const hasAccess = await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
authUser,
eventType as unknown as EventType
);
if (!hasAccess) {
return null;
}
return {
ownerId: eventType.userId ?? 0,
...eventType,
};
}
async checkCanCreateEventType(userId: number, body: InputEventTransformed_2024_06_14) {
const existsWithSlug = await this.eventTypesRepository.getUserEventTypeBySlug(userId, body.slug);
if (existsWithSlug) {
@@ -0,0 +1,78 @@
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { Injectable } from "@nestjs/common";
import type { EventType } from "@calcom/prisma/client";
@Injectable()
export class EventTypeAccessService {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly membershipsRepository: MembershipsRepository,
private readonly membershipsService: MembershipsService,
private readonly teamsRepository: TeamsRepository
) {}
async userIsEventTypeAdminOrOwner(authUser: ApiAuthGuardUser, eventType: EventType): Promise<boolean> {
const authUserId = authUser.id;
const eventTypeId = eventType.id;
const teamId = eventType.teamId;
const eventTypeOwnerId = eventType.userId || null;
if (authUser.isSystemAdmin) return true;
if (eventTypeOwnerId === authUserId) return true;
if (eventTypeId) {
const isHostOrAssigned = await this.isUserHostOrAssignedToEventType(authUserId, eventTypeId);
if (isHostOrAssigned) return true;
}
if (teamId) {
const isTeamOrParentOrgAdmin = await this.isUserTeamAdminOrParentOrgAdmin(authUserId, teamId);
if (isTeamOrParentOrgAdmin) return true;
}
if (eventTypeOwnerId) {
const isOrgAdminOrOwnerOfEventOwner = await this.isUserOrgAdminOrOwnerOfEventOwner(
authUserId,
eventTypeOwnerId
);
if (isOrgAdminOrOwnerOfEventOwner) return true;
}
return false;
}
private async isUserHostOrAssignedToEventType(authUserId: number, eventTypeId: number): Promise<boolean> {
const [isUserHost, isUserAssigned] = await Promise.all([
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
]);
return isUserHost || isUserAssigned;
}
private async isUserTeamAdminOrParentOrgAdmin(authUserId: number, teamId: number): Promise<boolean> {
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(authUserId, teamId);
if (membership) return true;
const team = await this.teamsRepository.getById(teamId);
const parentOrgId = team?.parentId ?? null;
if (parentOrgId) {
const isOrgAdmin = await this.membershipsRepository.isUserOrganizationAdmin(authUserId, parentOrgId);
if (isOrgAdmin) return true;
}
return false;
}
private async isUserOrgAdminOrOwnerOfEventOwner(
authUserId: number,
eventTypeOwnerId: number
): Promise<boolean> {
return this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, eventTypeOwnerId);
}
}
+421 -419
View File
@@ -11579,7 +11579,7 @@
"get": {
"operationId": "EventTypesController_2024_06_14_getEventTypeById",
"summary": "Get an event type",
"description": "<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>",
"description": "<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>\n \n Access control: This endpoint fetches an event type by ID and returns it only if the authenticated user is authorized. Authorization is granted to:\n - System admins\n - The event type owner\n - Hosts of the event type or users assigned to the event type\n - Team admins/owners of the team that owns the team event type\n - Organization admins/owners of the event type owner's organization\n - Organization admins/owners of the team's parent organization\n\n Note: Update and delete endpoints remain restricted to the event type owner only.",
"parameters": [
{
"name": "cal-api-version",
@@ -19166,6 +19166,380 @@
},
"required": ["status", "data"]
},
"EventTypeTeam": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"slug": {
"type": "string"
},
"bannerUrl": {
"type": "string"
},
"name": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"weekStart": {
"type": "string"
},
"brandColor": {
"type": "string"
},
"darkBrandColor": {
"type": "string"
},
"theme": {
"type": "string"
}
},
"required": [
"id",
"slug",
"bannerUrl",
"name",
"logoUrl",
"weekStart",
"brandColor",
"darkBrandColor",
"theme"
]
},
"TeamEventTypeOutput_2024_06_14": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"lengthInMinutes": {
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [15, 30, 60],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "number"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"slug": {
"type": "string",
"example": "learn-the-secrets-of-masterchief"
},
"description": {
"type": "string",
"example": "Discover the culinary wonders of Argentina by making the best flan ever!"
},
"locations": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/OutputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputOrganizersDefaultAppLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputUnknownLocation_2024_06_14"
}
]
}
},
"bookingFields": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/NameDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/EmailDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/RescheduleReasonDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TitleDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/GuestsDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/AddressFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TextFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/NumberFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TextAreaFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/SelectFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/MultiSelectFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/MultiEmailFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/CheckboxGroupFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/RadioGroupFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/BooleanFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/UrlFieldOutput_2024_06_14"
}
]
}
},
"disableGuests": {
"type": "boolean"
},
"slotInterval": {
"type": "object",
"example": 60,
"nullable": true
},
"minimumBookingNotice": {
"type": "number",
"example": 0
},
"beforeEventBuffer": {
"type": "number",
"example": 0
},
"afterEventBuffer": {
"type": "number",
"example": 0
},
"recurrence": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/Recurrence_2024_06_14"
}
]
},
"metadata": {
"type": "object"
},
"price": {
"type": "number"
},
"currency": {
"type": "string"
},
"lockTimeZoneToggleOnBookingPage": {
"type": "boolean"
},
"seatsPerTimeSlot": {
"type": "object",
"nullable": true
},
"forwardParamsSuccessRedirect": {
"type": "object",
"nullable": true
},
"successRedirectUrl": {
"type": "object",
"nullable": true
},
"isInstantEvent": {
"type": "boolean"
},
"seatsShowAvailabilityCount": {
"type": "boolean",
"nullable": true
},
"scheduleId": {
"type": "number",
"nullable": true
},
"bookingLimitsCount": {
"type": "object"
},
"bookerActiveBookingsLimit": {
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
},
"onlyShowFirstAvailableSlot": {
"type": "boolean"
},
"bookingLimitsDuration": {
"type": "object"
},
"bookingWindow": {
"type": "array",
"description": "Limit how far in the future this event can be booked",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14"
},
{
"$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14"
},
{
"$ref": "#/components/schemas/RangeWindow_2024_06_14"
}
]
}
},
"bookerLayouts": {
"$ref": "#/components/schemas/BookerLayouts_2024_06_14"
},
"confirmationPolicy": {
"type": "object"
},
"requiresBookerEmailVerification": {
"type": "boolean"
},
"hideCalendarNotes": {
"type": "boolean"
},
"color": {
"$ref": "#/components/schemas/EventTypeColor_2024_06_14"
},
"seats": {
"$ref": "#/components/schemas/Seats_2024_06_14"
},
"offsetStart": {
"type": "number"
},
"customName": {
"type": "string"
},
"destinationCalendar": {
"$ref": "#/components/schemas/DestinationCalendar_2024_06_14"
},
"useDestinationCalendarEmail": {
"type": "boolean"
},
"hideCalendarEventDetails": {
"type": "boolean"
},
"hideOrganizerEmail": {
"type": "boolean",
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
},
"calVideoSettings": {
"description": "Cal video settings for the event type",
"allOf": [
{
"$ref": "#/components/schemas/CalVideoSettings"
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"teamId": {
"type": "number"
},
"ownerId": {
"type": "object",
"nullable": true
},
"parentEventTypeId": {
"type": "object",
"description": "For managed event types, parent event type is the event type that this event type is based on",
"nullable": true
},
"hosts": {
"type": "array",
"items": {
"type": "string"
}
},
"assignAllTeamMembers": {
"type": "boolean"
},
"schedulingType": {
"type": "string",
"enum": ["roundRobin", "collective", "managed"]
},
"team": {
"$ref": "#/components/schemas/EventTypeTeam"
},
"emailSettings": {
"description": "Email settings for this event type. Only available for organization team event types.",
"allOf": [
{
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
}
]
},
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
}
},
"required": [
"id",
"lengthInMinutes",
"title",
"slug",
"description",
"locations",
"bookingFields",
"disableGuests",
"recurrence",
"metadata",
"price",
"currency",
"lockTimeZoneToggleOnBookingPage",
"forwardParamsSuccessRedirect",
"successRedirectUrl",
"isInstantEvent",
"scheduleId",
"hidden",
"bookingRequiresAuthentication",
"teamId",
"hosts",
"schedulingType",
"team"
]
},
"GetEventTypeOutput_2024_06_14": {
"type": "object",
"properties": {
@@ -19175,10 +19549,12 @@
"example": "success"
},
"data": {
"nullable": true,
"allOf": [
"oneOf": [
{
"$ref": "#/components/schemas/EventTypeOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TeamEventTypeOutput_2024_06_14"
}
]
}
@@ -19596,6 +19972,48 @@
},
"required": ["status", "data"]
},
"StripConnectOutputDto": {
"type": "object",
"properties": {
"authUrl": {
"type": "string"
}
},
"required": ["authUrl"]
},
"StripConnectOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/StripConnectOutputDto"
}
},
"required": ["status", "data"]
},
"StripCredentialsSaveOutputResponseDto": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
},
"required": ["url"]
},
"StripCredentialsCheckOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "object",
"example": "success"
}
},
"required": ["status"]
},
"OrgTeamOutputDto": {
"type": "object",
"properties": {
@@ -21111,380 +21529,6 @@
},
"required": ["lengthInMinutes", "title", "slug", "schedulingType"]
},
"EventTypeTeam": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"slug": {
"type": "string"
},
"bannerUrl": {
"type": "string"
},
"name": {
"type": "string"
},
"logoUrl": {
"type": "string"
},
"weekStart": {
"type": "string"
},
"brandColor": {
"type": "string"
},
"darkBrandColor": {
"type": "string"
},
"theme": {
"type": "string"
}
},
"required": [
"id",
"slug",
"bannerUrl",
"name",
"logoUrl",
"weekStart",
"brandColor",
"darkBrandColor",
"theme"
]
},
"TeamEventTypeOutput_2024_06_14": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1
},
"lengthInMinutes": {
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [15, 30, 60],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "number"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
},
"slug": {
"type": "string",
"example": "learn-the-secrets-of-masterchief"
},
"description": {
"type": "string",
"example": "Discover the culinary wonders of Argentina by making the best flan ever!"
},
"locations": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/OutputAddressLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputLinkLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputIntegrationLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputPhoneLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputOrganizersDefaultAppLocation_2024_06_14"
},
{
"$ref": "#/components/schemas/OutputUnknownLocation_2024_06_14"
}
]
}
},
"bookingFields": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/NameDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/EmailDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/LocationDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/RescheduleReasonDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TitleDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/NotesDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/GuestsDefaultFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/PhoneFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/AddressFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TextFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/NumberFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/TextAreaFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/SelectFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/MultiSelectFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/MultiEmailFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/CheckboxGroupFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/RadioGroupFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/BooleanFieldOutput_2024_06_14"
},
{
"$ref": "#/components/schemas/UrlFieldOutput_2024_06_14"
}
]
}
},
"disableGuests": {
"type": "boolean"
},
"slotInterval": {
"type": "object",
"example": 60,
"nullable": true
},
"minimumBookingNotice": {
"type": "number",
"example": 0
},
"beforeEventBuffer": {
"type": "number",
"example": 0
},
"afterEventBuffer": {
"type": "number",
"example": 0
},
"recurrence": {
"nullable": true,
"allOf": [
{
"$ref": "#/components/schemas/Recurrence_2024_06_14"
}
]
},
"metadata": {
"type": "object"
},
"price": {
"type": "number"
},
"currency": {
"type": "string"
},
"lockTimeZoneToggleOnBookingPage": {
"type": "boolean"
},
"seatsPerTimeSlot": {
"type": "object",
"nullable": true
},
"forwardParamsSuccessRedirect": {
"type": "object",
"nullable": true
},
"successRedirectUrl": {
"type": "object",
"nullable": true
},
"isInstantEvent": {
"type": "boolean"
},
"seatsShowAvailabilityCount": {
"type": "boolean",
"nullable": true
},
"scheduleId": {
"type": "number",
"nullable": true
},
"bookingLimitsCount": {
"type": "object"
},
"bookerActiveBookingsLimit": {
"$ref": "#/components/schemas/BookerActiveBookingsLimitOutput_2024_06_14"
},
"onlyShowFirstAvailableSlot": {
"type": "boolean"
},
"bookingLimitsDuration": {
"type": "object"
},
"bookingWindow": {
"type": "array",
"description": "Limit how far in the future this event can be booked",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/BusinessDaysWindow_2024_06_14"
},
{
"$ref": "#/components/schemas/CalendarDaysWindow_2024_06_14"
},
{
"$ref": "#/components/schemas/RangeWindow_2024_06_14"
}
]
}
},
"bookerLayouts": {
"$ref": "#/components/schemas/BookerLayouts_2024_06_14"
},
"confirmationPolicy": {
"type": "object"
},
"requiresBookerEmailVerification": {
"type": "boolean"
},
"hideCalendarNotes": {
"type": "boolean"
},
"color": {
"$ref": "#/components/schemas/EventTypeColor_2024_06_14"
},
"seats": {
"$ref": "#/components/schemas/Seats_2024_06_14"
},
"offsetStart": {
"type": "number"
},
"customName": {
"type": "string"
},
"destinationCalendar": {
"$ref": "#/components/schemas/DestinationCalendar_2024_06_14"
},
"useDestinationCalendarEmail": {
"type": "boolean"
},
"hideCalendarEventDetails": {
"type": "boolean"
},
"hideOrganizerEmail": {
"type": "boolean",
"description": "Boolean to Hide organizer's email address from the booking screen, email notifications, and calendar events"
},
"calVideoSettings": {
"description": "Cal video settings for the event type",
"allOf": [
{
"$ref": "#/components/schemas/CalVideoSettings"
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users who are the event-type owner or org/team admin/owner can book this event type."
},
"teamId": {
"type": "number"
},
"ownerId": {
"type": "object",
"nullable": true
},
"parentEventTypeId": {
"type": "object",
"description": "For managed event types, parent event type is the event type that this event type is based on",
"nullable": true
},
"hosts": {
"type": "array",
"items": {
"type": "string"
}
},
"assignAllTeamMembers": {
"type": "boolean"
},
"schedulingType": {
"type": "string",
"enum": ["roundRobin", "collective", "managed"]
},
"team": {
"$ref": "#/components/schemas/EventTypeTeam"
},
"emailSettings": {
"description": "Email settings for this event type. Only available for organization team event types.",
"allOf": [
{
"$ref": "#/components/schemas/EmailSettings_2024_06_14"
}
]
},
"rescheduleWithSameRoundRobinHost": {
"type": "boolean",
"description": "Rescheduled events will be assigned to the same host as initially scheduled."
}
},
"required": [
"id",
"lengthInMinutes",
"title",
"slug",
"description",
"locations",
"bookingFields",
"disableGuests",
"recurrence",
"metadata",
"price",
"currency",
"lockTimeZoneToggleOnBookingPage",
"forwardParamsSuccessRedirect",
"successRedirectUrl",
"isInstantEvent",
"scheduleId",
"hidden",
"bookingRequiresAuthentication",
"teamId",
"hosts",
"schedulingType",
"team"
]
},
"CreateTeamEventTypeOutput": {
"type": "object",
"properties": {
@@ -24945,48 +24989,6 @@
},
"required": ["status", "data"]
},
"StripConnectOutputDto": {
"type": "object",
"properties": {
"authUrl": {
"type": "string"
}
},
"required": ["authUrl"]
},
"StripConnectOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": ["success", "error"]
},
"data": {
"$ref": "#/components/schemas/StripConnectOutputDto"
}
},
"required": ["status", "data"]
},
"StripCredentialsSaveOutputResponseDto": {
"type": "object",
"properties": {
"url": {
"type": "string"
}
},
"required": ["url"]
},
"StripCredentialsCheckOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "object",
"example": "success"
}
},
"required": ["status"]
},
"GetDefaultScheduleOutput_2024_06_11": {
"type": "object",
"properties": {