-
Notifications
You must be signed in to change notification settings - Fork 11.7k
feat(api-v2): add event-type level selected calendars for conflict checking #26730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
b2751c5
9ea142b
3bb490d
152dff0
62f4854
8eb70de
646253d
7dc025a
072f5f1
53ba3e9
d2f8059
9277dfb
6c29eb0
b1df6e3
05aa421
7d238dd
fc259b4
8f67055
34cd105
e3aa227
c504e31
450ab6a
5138284
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 |
|---|---|---|
|
|
@@ -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, | ||
| 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), | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }; | ||
| } | ||
|
|
||
|
|
@@ -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), | ||
| }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
|
@@ -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( | ||
ThyMinimalDev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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( | ||
|
||
| eventTypeId, | ||
| shouldUseEventLevelCalendars | ||
| ); | ||
|
|
||
| if (selectedCalendars !== undefined) { | ||
| await this.selectedCalendarsRepository.deleteByEventTypeId(eventTypeId); | ||
|
|
||
| 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 | ||
| ); | ||
|
|
||
| await this.selectedCalendarsRepository.createForEventType( | ||
|
||
| eventTypeId, | ||
| userId, | ||
| calendar.integration, | ||
| calendar.externalId, | ||
| matchingUserCalendar?.credentialId ?? null | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| async getEventTypeWithSelectedCalendars(eventTypeId: number, userId: number) { | ||
ThyMinimalDev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 |
|---|---|---|
|
|
@@ -134,6 +134,8 @@ export class InputEventTypesService_2024_06_14 { | |
| disableRescheduling, | ||
| disableCancelling, | ||
| calVideoSettings, | ||
| useEventLevelSelectedCalendars: _useEventLevelSelectedCalendars, | ||
|
||
| selectedCalendars: _selectedCalendars, | ||
| ...rest | ||
| } = inputEventType; | ||
| const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy); | ||
|
|
@@ -228,6 +230,8 @@ export class InputEventTypesService_2024_06_14 { | |
| disableRescheduling, | ||
| disableCancelling, | ||
| calVideoSettings, | ||
| useEventLevelSelectedCalendars: _useEventLevelSelectedCalendars, | ||
|
||
| selectedCalendars: _selectedCalendars, | ||
| ...rest | ||
| } = inputEventType; | ||
| const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
updateEventTypeSelectedCalendarsfails 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