Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b2751c5
feat(api-v2): add event-type level selected calendars for conflict ch…
devin-ai-integration[bot] Jan 12, 2026
9ea142b
test(api-v2): add e2e tests for event-type level selected calendars
devin-ai-integration[bot] Jan 12, 2026
3bb490d
Merge branch 'main' into devin/event-type-selected-calendars-1768203692
ThyMinimalDev Jan 12, 2026
152dff0
fix(api-v2): address review feedback for selected calendars feature
devin-ai-integration[bot] Jan 12, 2026
62f4854
Merge branch 'devin/event-type-selected-calendars-1768203692' of http…
devin-ai-integration[bot] Jan 12, 2026
8eb70de
fix(api-v2): include selectedCalendars in GET event-type endpoint
devin-ai-integration[bot] Jan 12, 2026
646253d
Merge branch 'main' into devin/event-type-selected-calendars-1768203692
devin-ai-integration[bot] Jan 12, 2026
7dc025a
Merge branch 'main' into devin/event-type-selected-calendars-1768203692
ThyMinimalDev Jan 12, 2026
072f5f1
refactor(api-v2): address review feedback for selected calendars
devin-ai-integration[bot] Jan 16, 2026
53ba3e9
fix(api-v2): forward destinationCalendar to updateEventType in create…
devin-ai-integration[bot] Jan 16, 2026
d2f8059
refactor(api-v2): remove useEventLevelSelectedCalendars from API types
devin-ai-integration[bot] Jan 16, 2026
9277dfb
fix(api-v2): remove useEventLevelSelectedCalendars from public endpoi…
devin-ai-integration[bot] Jan 16, 2026
6c29eb0
fix(api-v2): remove remaining useEventLevelSelectedCalendars referenc…
devin-ai-integration[bot] Jan 16, 2026
b1df6e3
Merge branch 'devin/event-type-selected-calendars-1768203692' of http…
devin-ai-integration[bot] Jan 16, 2026
05aa421
fix(api-v2): revert function rename and fix type error in output service
devin-ai-integration[bot] Jan 16, 2026
7d238dd
fix(api-v2): update E2E tests to remove useEventLevelSelectedCalendar…
devin-ai-integration[bot] Jan 16, 2026
fc259b4
refactor(api-v2): separate function for removing selectedCalendars fr…
devin-ai-integration[bot] Jan 16, 2026
8f67055
Merge branch 'main' into devin/event-type-selected-calendars-1768203692
ThyMinimalDev Jan 18, 2026
34cd105
refactor(api-v2): move Prisma calls from service to repositories
devin-ai-integration[bot] Jan 18, 2026
e3aa227
Merge branch 'devin/event-type-selected-calendars-1768203692' of http…
devin-ai-integration[bot] Jan 18, 2026
c504e31
fix(api-v2): restore PrismaWriteService in service constructor
devin-ai-integration[bot] Jan 18, 2026
450ab6a
refactor(api-v2): keep transaction in service, move Prisma calls to r…
devin-ai-integration[bot] Jan 18, 2026
5138284
refactor(api-v2): move selected calendar tx methods to SelectedCalend…
devin-ai-integration[bot] Jan 18, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -3180,4 +3180,213 @@ describe("Event types Endpoints", () => {
await app.close();
});
});

describe("Event-type level selected calendars", () => {
let app: INestApplication;

let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;

const userEmail = `selected-calendars-test-${randomString()}@api.com`;
const name = `selected-calendars-test-${randomString()}`;
const username = name;
let user: User;
let apiKeyString: string;
let createdEventTypeId: number;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();

app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);

oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);

organization = await teamRepositoryFixture.create({
name: `selected-calendars-org-${randomString()}`,
slug: `selected-calendars-org-slug-${randomString()}`,
});

oAuthClient = await createOAuthClient(organization.id);

user = await userRepositoryFixture.create({
email: userEmail,
name,
username,
});

const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;

await app.init();
});

async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";

const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}

it("should create an event type with useEventLevelSelectedCalendars and selectedCalendars", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Event with selected calendars",
slug: `event-selected-calendars-${randomString()}`,
lengthInMinutes: 30,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
useEventLevelSelectedCalendars: true,
selectedCalendars: [
{
integration: "google_calendar",
externalId: "primary",
},
],
};

const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201);

const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;

expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.useEventLevelSelectedCalendars).toEqual(true);
expect(createdEventType.selectedCalendars).toBeDefined();
expect(createdEventType.selectedCalendars).toHaveLength(1);
expect(createdEventType.selectedCalendars?.[0].integration).toEqual("google_calendar");
expect(createdEventType.selectedCalendars?.[0].externalId).toEqual("primary");

createdEventTypeId = createdEventType.id;
});

it("should update an event type with selectedCalendars", async () => {
const updateBody: UpdateEventTypeInput_2024_06_14 = {
selectedCalendars: [
{
integration: "google_calendar",
externalId: "primary",
},
{
integration: "google_calendar",
externalId: "[email protected]",
},
],
};

const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(updateBody)
.expect(200);

const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;

expect(updatedEventType.useEventLevelSelectedCalendars).toEqual(true);
expect(updatedEventType.selectedCalendars).toBeDefined();
expect(updatedEventType.selectedCalendars).toHaveLength(2);
});

it("should get an event type with selectedCalendars via authenticated endpoint", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${createdEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);

const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const eventType = responseBody.data;

expect(eventType.useEventLevelSelectedCalendars).toEqual(true);
expect(eventType.selectedCalendars).toBeDefined();
expect(eventType.selectedCalendars).toHaveLength(2);
});

it("should NOT include selectedCalendars in public endpoint response", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200);

const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
const eventTypes = responseBody.data;

const eventType = eventTypes.find((et) => et.id === createdEventTypeId);
expect(eventType).toBeDefined();
expect(eventType?.selectedCalendars).toBeUndefined();
expect(eventType?.useEventLevelSelectedCalendars).toBeUndefined();
});

it("should clear selectedCalendars when useEventLevelSelectedCalendars is set to false", async () => {
const updateBody: UpdateEventTypeInput_2024_06_14 = {
useEventLevelSelectedCalendars: false,
selectedCalendars: [],
};

const response = await request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventTypeId}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(updateBody)
.expect(200);

const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const updatedEventType = responseBody.data;

expect(updatedEventType.useEventLevelSelectedCalendars).toEqual(false);
expect(
updatedEventType.selectedCalendars === undefined || updatedEventType.selectedCalendars?.length === 0
).toBe(true);
});

afterAll(async () => {
try {
await eventTypesRepositoryFixture.delete(createdEventTypeId);
} catch (e) {
console.log(e);
}
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
console.log(e);
}
await app.close();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,21 @@ export class EventTypesController_2024_06_14 {

const eventType = await this.eventTypesService.createUserEventType(user, transformedBody);

await this.eventTypesService.updateEventTypeSelectedCalendars(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Operations are not atomic - if updateEventTypeSelectedCalendars fails after event type creation, the event type will exist without the requested selected calendars, leaving the system in an inconsistent state. Consider wrapping all three operations in a database transaction or handling partial failure by cleaning up the created event type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/v2/src/ee/event-types/event-types_2024_06_14/controllers/event-types.controller.ts, line 103:

<comment>Operations are not atomic - if `updateEventTypeSelectedCalendars` fails after event type creation, the event type will exist without the requested selected calendars, leaving the system in an inconsistent state. Consider wrapping all three operations in a database transaction or handling partial failure by cleaning up the created event type.</comment>

<file context>
@@ -100,9 +100,21 @@ export class EventTypesController_2024_06_14 {
 
     const eventType = await this.eventTypesService.createUserEventType(user, transformedBody);
 
+    await this.eventTypesService.updateEventTypeSelectedCalendars(
+      eventType.id,
+      user.id,
</file context>
Fix with Cubic

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This controller is getting too crowded. We should move updateEventTypeSelectedCalendars at the end of createUserEventType.

eventType.id,
user.id,
body.useEventLevelSelectedCalendars,
body.selectedCalendars
);

const eventTypeWithSelectedCalendars = await this.eventTypesService.getEventTypeWithSelectedCalendars(
eventType.id,
user.id
);

return {
status: SUCCESS_STATUS,
data: this.eventTypeResponseTransformPipe.transform(eventType),
data: this.eventTypeResponseTransformPipe.transform(eventTypeWithSelectedCalendars ?? eventType),
};
}

Expand Down Expand Up @@ -201,9 +213,21 @@ export class EventTypesController_2024_06_14 {

const eventType = await this.eventTypesService.updateEventType(eventTypeId, transformedBody, user);

await this.eventTypesService.updateEventTypeSelectedCalendars(
eventTypeId,
user.id,
body.useEventLevelSelectedCalendars,
body.selectedCalendars
);

const eventTypeWithSelectedCalendars = await this.eventTypesService.getEventTypeWithSelectedCalendars(
eventTypeId,
user.id
);

return {
status: SUCCESS_STATUS,
data: this.eventTypeResponseTransformPipe.transform(eventType),
data: this.eventTypeResponseTransformPipe.transform(eventTypeWithSelectedCalendars ?? eventType),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,24 @@ export class EventTypesRepository_2024_06_14 {
});
return !!eventType;
}

async updateUseEventLevelSelectedCalendars(eventTypeId: number, useEventLevelSelectedCalendars: boolean) {
return this.dbWrite.prisma.eventType.update({
where: { id: eventTypeId },
data: { useEventLevelSelectedCalendars },
});
}

async getByIdIncludeSelectedCalendars(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
include: {
users: true,
schedule: true,
destinationCalendar: true,
calVideoSettings: true,
selectedCalendars: true,
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import {
getEventTypesPublic,
EventTypesPublic,
} from "@calcom/platform-libraries/event-types";
import type { GetEventTypesQuery_2024_06_14, SortOrderType } from "@calcom/platform-types";
import type {
GetEventTypesQuery_2024_06_14,
SortOrderType,
SelectedCalendar_2024_06_14,
} from "@calcom/platform-types";
import type { EventType } from "@calcom/prisma/client";

@Injectable()
Expand Down Expand Up @@ -385,4 +389,58 @@ export class EventTypesService_2024_06_14 {
throw new NotFoundException(`User with ID=${userId} does not own schedule with ID=${scheduleId}`);
}
}

async updateEventTypeSelectedCalendars(
eventTypeId: number,
userId: number,
useEventLevelSelectedCalendars: boolean | undefined,
selectedCalendars: SelectedCalendar_2024_06_14[] | undefined
) {
if (useEventLevelSelectedCalendars === undefined && selectedCalendars === undefined) {
return;
}

const hasSelectedCalendars = selectedCalendars && selectedCalendars.length > 0;
const shouldUseEventLevelCalendars = useEventLevelSelectedCalendars ?? hasSelectedCalendars ?? false;

await this.eventTypesRepository.updateUseEventLevelSelectedCalendars(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Multiple related database operations (update flag, delete calendars, create new calendars) are not wrapped in a transaction. If any operation fails, data will be in an inconsistent state. Wrap all operations in a database transaction to ensure atomicity.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts, line 406:

<comment>Multiple related database operations (update flag, delete calendars, create new calendars) are not wrapped in a transaction. If any operation fails, data will be in an inconsistent state. Wrap all operations in a database transaction to ensure atomicity.</comment>

<file context>
@@ -385,4 +389,58 @@ export class EventTypesService_2024_06_14 {
+    const hasSelectedCalendars = selectedCalendars && selectedCalendars.length > 0;
+    const shouldUseEventLevelCalendars = useEventLevelSelectedCalendars ?? hasSelectedCalendars ?? false;
+
+    await this.eventTypesRepository.updateUseEventLevelSelectedCalendars(
+      eventTypeId,
+      shouldUseEventLevelCalendars
</file context>

✅ Addressed in 152dff0

eventTypeId,
shouldUseEventLevelCalendars
);

if (selectedCalendars !== undefined) {
await this.selectedCalendarsRepository.deleteByEventTypeId(eventTypeId);

if (hasSelectedCalendars) {
const userSelectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(userId);

for (const calendar of selectedCalendars) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: O(n*m) complexity: loop performs linear search on userSelectedCalendars for each calendar. Create a Map for O(1) lookups instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts, line 417:

<comment>O(n*m) complexity: loop performs linear search on userSelectedCalendars for each calendar. Create a Map for O(1) lookups instead.</comment>

<file context>
@@ -385,4 +389,58 @@ export class EventTypesService_2024_06_14 {
+      if (hasSelectedCalendars) {
+        const userSelectedCalendars = await this.selectedCalendarsRepository.getUserSelectedCalendars(userId);
+
+        for (const calendar of selectedCalendars) {
+          const matchingUserCalendar = userSelectedCalendars.find(
+            (uc) => uc.integration === calendar.integration && uc.externalId === calendar.externalId
</file context>

✅ Addressed in 152dff0

const matchingUserCalendar = userSelectedCalendars.find(
(uc) => uc.integration === calendar.integration && uc.externalId === calendar.externalId
);

await this.selectedCalendarsRepository.createForEventType(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: N+1 query problem: sequential awaits in loop. Consider bulk insert operation or Promise.all for better performance.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/event-types.service.ts, line 422:

<comment>N+1 query problem: sequential awaits in loop. Consider bulk insert operation or Promise.all for better performance.</comment>

<file context>
@@ -385,4 +389,58 @@ export class EventTypesService_2024_06_14 {
+            (uc) => uc.integration === calendar.integration && uc.externalId === calendar.externalId
+          );
+
+          await this.selectedCalendarsRepository.createForEventType(
+            eventTypeId,
+            userId,
</file context>

✅ Addressed in 152dff0

eventTypeId,
userId,
calendar.integration,
calendar.externalId,
matchingUserCalendar?.credentialId ?? null
);
}
}
}
}

async getEventTypeWithSelectedCalendars(eventTypeId: number, userId: number) {
const eventType = await this.eventTypesRepository.getByIdIncludeSelectedCalendars(eventTypeId);

if (!eventType) {
return null;
}

return {
ownerId: userId,
...eventType,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export class InputEventTypesService_2024_06_14 {
disableRescheduling,
disableCancelling,
calVideoSettings,
useEventLevelSelectedCalendars: _useEventLevelSelectedCalendars,
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Missing validation for selectedCalendars input. The field is accepted but never validated to ensure the calendars exist and are accessible by the user. Add a validation method similar to validateInputDestinationCalendar that checks:

  1. Each calendar's integration is valid
  2. The externalId exists and belongs to the user
  3. The user has the necessary permissions
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/v2/src/ee/event-types/event-types_2024_06_14/services/input-event-types.service.ts, line 137:

<comment>Missing validation for `selectedCalendars` input. The field is accepted but never validated to ensure the calendars exist and are accessible by the user. Add a validation method similar to `validateInputDestinationCalendar` that checks:
1. Each calendar's integration is valid
2. The externalId exists and belongs to the user
3. The user has the necessary permissions</comment>

<file context>
@@ -134,6 +134,8 @@ export class InputEventTypesService_2024_06_14 {
       disableRescheduling,
       disableCancelling,
       calVideoSettings,
+      useEventLevelSelectedCalendars: _useEventLevelSelectedCalendars,
+      selectedCalendars: _selectedCalendars,
       ...rest
</file context>
Fix with Cubic

selectedCalendars: _selectedCalendars,
...rest
} = inputEventType;
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
Expand Down Expand Up @@ -228,6 +230,8 @@ export class InputEventTypesService_2024_06_14 {
disableRescheduling,
disableCancelling,
calVideoSettings,
useEventLevelSelectedCalendars: _useEventLevelSelectedCalendars,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We call await this.eventTypesService.updateEventTypeSelectedCalendars( in controller and then remove these properties here. If we move await this.eventTypesService.updateEventTypeSelectedCalendars( then we can keep them instead of discarding them.

selectedCalendars: _selectedCalendars,
...rest
} = inputEventType;
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
Expand Down
Loading
Loading