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 90d122dd19..d615a2faed 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 @@ -99,6 +99,10 @@ export class BookingsController_2024_08_13 { For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body. The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone. + + Finally, there are 2 ways to book an event type: + 1. Provide \`eventTypeId\` in the request body. + 2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization. `, }) @ApiBody({ diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts index b8227c329f..6a6040186f 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/user-bookings.e2e-spec.ts @@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; -import { User } from "@prisma/client"; +import { EventType, User } from "@prisma/client"; import { advanceTo, clear } from "jest-date-mock"; import * as request from "supertest"; import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; @@ -66,6 +66,7 @@ describe("Bookings Endpoints 2024-08-13", () => { let user: User; let eventTypeId: number; + let eventType: EventType; const eventTypeSlug = `user-bookings-event-type-${randomString()}`; let recurringEventTypeId: number; const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`; @@ -128,6 +129,7 @@ describe("Bookings Endpoints 2024-08-13", () => { user.id ); eventTypeId = event.id; + eventType = event; const recurringEvent = await eventTypesRepositoryFixture.create( // note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row @@ -1663,6 +1665,135 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + describe("book by username and event type slug", () => { + it("should not create a booking by if neither event type id nor username and event type slug provided", async () => { + const googleMeetUrl = "https://meet.google.com/abc-def-ghi"; + const body = { + start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(), + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: googleMeetUrl, + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + guests: ["bob@gmail.com"], + }; + + const response = await request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + + expect( + response.body.error.message.includes( + "Either eventTypeId OR (eventTypeSlug + username) must be provided" + ) + ).toBe(true); + }); + + it("should create a booking by username and event type slug", async () => { + const googleMeetUrl = "https://meet.google.com/abc-def-ghi"; + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(), + eventTypeSlug: eventType.slug, + username: user.username || "", + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: googleMeetUrl, + bookingFieldsResponses: { + customField: "customValue", + }, + metadata: { + userId: "100", + }, + guests: ["bob@gmail.com"], + }; + + const beforeCreate = new Date(); + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const afterCreate = new Date(); + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.hosts[0].username).toEqual(user.username); + expect(data.hosts[0].email).toEqual(user.email); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2036, 0, 8, 16, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(eventTypeId); + expect(data.eventType).toEqual({ + id: eventTypeId, + slug: eventTypeSlug, + }); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.location).toEqual(body.location); + expect(data.meetingUrl).toEqual(body.location); + expect(data.absentHost).toEqual(false); + expect(data.bookingFieldsResponses).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + ...body.bookingFieldsResponses, + guests: body.guests, + location: { + optionValue: googleMeetUrl, + value: "link", + }, + }); + expect(data.guests).toEqual(body.guests); + + // Check createdAt date is between the time of the request and after the request + const createdAtDate = new Date(data.createdAt); + expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + + // Check updatedAt date is between the time of the request and after the request + expect(data.updatedAt).toBeDefined(); + const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null; + expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime()); + expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime()); + + expect(data.metadata).toEqual(body.metadata); + createdBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + }); + describe("booking location", () => { const address = "123 Main St"; const link = "https://cal.com/join/123456"; 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 7bdc5e19ef..2f1e8863af 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 @@ -45,7 +45,7 @@ import { CancelBookingInput, } from "@calcom/platform-types"; import { PrismaClient } from "@calcom/prisma"; -import { EventType } from "@calcom/prisma/client"; +import { EventType, User, Team } from "@calcom/prisma/client"; type CreatedBooking = { hosts: { id: number }[]; @@ -61,6 +61,8 @@ const eventTypeBookingFieldSchema = z.object({ const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema); +export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null }; + @Injectable() export class BookingsService_2024_08_13 { private readonly logger = new Logger("BookingsService"); @@ -80,27 +82,43 @@ export class BookingsService_2024_08_13 { async createBooking(request: Request, body: CreateBookingInput) { try { - if ("instant" in body && body.instant) { - return await this.createInstantBooking(request, body); + const eventType = await this.getBookedEventType(body); + if (!eventType) { + if (body.username && body.eventTypeSlug && !body.organizationSlug) { + throw new NotFoundException( + `Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.` + ); + } + if (body.username && body.eventTypeSlug && body.organizationSlug) { + throw new NotFoundException( + `Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.` + ); + } + throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`); + } + + body.eventTypeId = eventType.id; + + if ("instant" in body && body.instant) { + return await this.createInstantBooking(request, body, eventType); } - const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId); const isRecurring = !!eventType?.recurringEvent; const isSeated = !!eventType?.seatsPerTimeSlot; await this.hasRequiredBookingFieldsResponses(body, eventType); if (isRecurring && isSeated) { - return await this.createRecurringSeatedBooking(request, body); + return await this.createRecurringSeatedBooking(request, body, eventType); } if (isRecurring && !isSeated) { - return await this.createRecurringBooking(request, body); + return await this.createRecurringBooking(request, body, eventType); } if (isSeated) { - return await this.createSeatedBooking(request, body); + return await this.createSeatedBooking(request, body, eventType); } - return await this.createRegularBooking(request, body); + return await this.createRegularBooking(request, body, eventType); } catch (error) { if (error instanceof Error) { if (error.message === "no_available_users_found_error") { @@ -111,6 +129,22 @@ export class BookingsService_2024_08_13 { } } + async getBookedEventType(body: CreateBookingInput) { + if (body.eventTypeId) { + return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId); + } else if (body.username && body.eventTypeSlug) { + const user = await this.usersRepository.findByUsername(body.username, body.organizationSlug); + if (!user) { + throw new NotFoundException(`User with username ${body.username} not found`); + } + return await this.eventTypesRepository.getUserEventTypeBySlugWithOwnerAndTeam( + user.id, + body.eventTypeSlug + ); + } + return null; + } + async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) { const bookingFields = body.bookingFieldsResponses; if (!bookingFields || !eventType || !eventType.bookingFields) { @@ -133,8 +167,12 @@ export class BookingsService_2024_08_13 { return true; } - async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) { - const bookingRequest = await this.inputService.createBookingRequest(request, body); + async createInstantBooking( + request: Request, + body: CreateInstantBookingInput_2024_08_13, + eventType: EventTypeWithOwnerAndTeam + ) { + const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType); const booking = await handleInstantMeeting(bookingRequest); const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent( @@ -147,8 +185,8 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputBooking(databaseBooking); } - async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { - const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); + async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) { + const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType); const bookings = await handleNewRecurringBooking({ bookingData: bookingRequest.body, userId: bookingRequest.userId, @@ -164,8 +202,8 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputRecurringBookings(ids); } - async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) { - const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body); + async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) { + const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType); const bookings = await handleNewRecurringBooking({ bookingData: bookingRequest.body, userId: bookingRequest.userId, @@ -181,8 +219,8 @@ export class BookingsService_2024_08_13 { ); } - async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) { - const bookingRequest = await this.inputService.createBookingRequest(request, body); + async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) { + const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType); const booking = await handleNewBooking({ bookingData: bookingRequest.body, userId: bookingRequest.userId, @@ -206,8 +244,8 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputBooking(databaseBooking); } - async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) { - const bookingRequest = await this.inputService.createBookingRequest(request, body); + async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) { + const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType); const booking = await handleNewBooking({ bookingData: bookingRequest.body, userId: bookingRequest.userId, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts index 18a19b3569..fdb8fa265d 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/input.service.ts @@ -1,4 +1,5 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository"; +import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service"; import { bookingResponsesSchema, seatedBookingDataSchema, @@ -95,10 +96,15 @@ export class InputBookingsService_2024_08_13 { async createBookingRequest( request: Request, - body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13 + body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13, + eventType: EventTypeWithOwnerAndTeam ): Promise { - const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId); - const bodyTransformed = await this.transformInputCreateBooking(body, oAuthClientParams?.platformClientId); + const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType); + const bodyTransformed = await this.transformInputCreateBooking( + body, + eventType, + oAuthClientParams?.platformClientId + ); const newRequest = { ...request }; const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined; @@ -124,15 +130,11 @@ export class InputBookingsService_2024_08_13 { return newRequest as unknown as BookingRequest; } - async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13, platformClientId?: string) { - const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam( - inputBooking.eventTypeId - ); - - if (!eventType) { - throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`); - } - + async transformInputCreateBooking( + inputBooking: CreateBookingInput_2024_08_13, + eventType: EventTypeWithOwnerAndTeam, + platformClientId?: string + ) { this.validateBookingLengthInMinutes(inputBooking, eventType); const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length; @@ -199,12 +201,14 @@ export class InputBookingsService_2024_08_13 { async createRecurringBookingRequest( request: Request, - body: CreateRecurringBookingInput_2024_08_13 + body: CreateRecurringBookingInput_2024_08_13, + eventType: EventTypeWithOwnerAndTeam ): Promise { - const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId); + const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType); // note(Lauris): update to this.transformInputCreate when rescheduling is implemented const bodyTransformed = await this.transformInputCreateRecurringBooking( body, + eventType, oAuthClientParams?.platformClientId ); @@ -375,14 +379,9 @@ export class InputBookingsService_2024_08_13 { async transformInputCreateRecurringBooking( inputBooking: CreateRecurringBookingInput_2024_08_13, + eventType: EventTypeWithOwnerAndTeam, platformClientId?: string ) { - const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam( - inputBooking.eventTypeId - ); - if (!eventType) { - throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`); - } if (!eventType.recurringEvent) { throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`); } diff --git a/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts b/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts index 3daec813f8..6873221308 100644 --- a/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/shared/platform-bookings.service.ts @@ -59,4 +59,28 @@ export class PlatformBookingsService { return undefined; } + + async getOAuthClientParamsForEventType(eventType: { + userId: number | null | undefined; + teamId: number | null | undefined; + }) { + let oAuthClient: PlatformOAuthClient | null = null; + if (eventType.userId) { + oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId); + } else if (eventType.teamId) { + oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId); + } + + if (oAuthClient) { + return { + platformClientId: oAuthClient.id, + platformCancelUrl: oAuthClient.bookingCancelRedirectUri, + platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri, + platformBookingUrl: oAuthClient.bookingRedirectUri, + arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled, + }; + } + + return undefined; + } } 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 0ed17a4c98..f4161051b3 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 @@ -111,6 +111,18 @@ export class EventTypesRepository_2024_06_14 { }); } + async getUserEventTypeBySlugWithOwnerAndTeam(userId: number, slug: string) { + return this.dbRead.prisma.eventType.findUnique({ + where: { + userId_slug: { + userId: userId, + slug: slug, + }, + }, + include: { owner: true, team: true }, + }); + } + async deleteEventType(eventTypeId: number) { return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } }); } diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index 64e76f5bec..7d7434a014 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -6,6 +6,7 @@ import { DestinationCalendarsModule } from "@/modules/destination-calendars/dest import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module"; import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module"; import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module"; +import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module"; import { RouterModule } from "@/modules/router/router.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; import { TimezoneModule } from "@/modules/timezones/timezones.module"; @@ -28,6 +29,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; StripeModule, ConferencingModule, OrganizationsTeamsBookingsModule, + OrganizationsUsersBookingsModule, OrganizationsBookingsModule, RouterModule, ], diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts index c71bc0cebb..20743bda70 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.e2e-spec.ts @@ -208,6 +208,10 @@ describe("OAuth Client Users Endpoints", () => { locale: Locales.FR, name: "Alice Smith", avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + bio: "I am a bio", + metadata: { + key: "value", + }, }; const response = await request(app.getHttpServer()) @@ -231,6 +235,8 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat); expect(responseBody.data.user.locale).toEqual(requestBody.locale); expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl); + expect(responseBody.data.user.bio).toEqual(requestBody.bio); + expect(responseBody.data.user.metadata).toEqual(requestBody.metadata); const [emailUser, emailDomain] = responseBody.data.user.email.split("@"); const [domainName, TLD] = emailDomain.split("."); expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`)); @@ -272,6 +278,10 @@ describe("OAuth Client Users Endpoints", () => { locale: Locales.FR, name: "Bob Smith", avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + bio: "I am a bio", + metadata: { + key: "value", + }, }; const response = await request(app.getHttpServer()) @@ -295,6 +305,8 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat); expect(responseBody.data.user.locale).toEqual(requestBody.locale); expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl); + expect(responseBody.data.user.bio).toEqual(requestBody.bio); + expect(responseBody.data.user.metadata).toEqual(requestBody.metadata); const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; expect(accessToken).toBeDefined(); @@ -333,6 +345,10 @@ describe("OAuth Client Users Endpoints", () => { locale: Locales.FR, name: "Alice Smith", avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + bio: "I am a bio", + metadata: { + key: "value", + }, }; const response = await request(app.getHttpServer()) @@ -356,6 +372,8 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat); expect(responseBody.data.user.locale).toEqual(requestBody.locale); expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl); + expect(responseBody.data.user.bio).toEqual(requestBody.bio); + expect(responseBody.data.user.metadata).toEqual(requestBody.metadata); const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data; expect(accessToken).toBeDefined(); @@ -479,6 +497,10 @@ describe("OAuth Client Users Endpoints", () => { expect(userOne?.name).toEqual(postResponseData.user.name); expect(userTwo?.email).toEqual(postResponseDataTwo.user.email); expect(userTwo?.name).toEqual(postResponseDataTwo.user.name); + expect(userOne?.bio).toEqual(postResponseData.user.bio); + expect(userOne?.metadata).toEqual(postResponseData.user.metadata); + expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio); + expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata); }); it(`/GET: managed user by original email`, async () => { @@ -496,6 +518,8 @@ describe("OAuth Client Users Endpoints", () => { const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email); expect(userOne?.email).toEqual(postResponseData.user.email); expect(userOne?.name).toEqual(postResponseData.user.name); + expect(userOne?.bio).toEqual(postResponseData.user.bio); + expect(userOne?.metadata).toEqual(postResponseData.user.metadata); }); it(`/GET: managed users by original emails`, async () => { @@ -518,6 +542,10 @@ describe("OAuth Client Users Endpoints", () => { expect(userOne?.name).toEqual(postResponseData.user.name); expect(userTwo?.email).toEqual(postResponseDataTwo.user.email); expect(userTwo?.name).toEqual(postResponseDataTwo.user.name); + expect(userOne?.bio).toEqual(postResponseData.user.bio); + expect(userOne?.metadata).toEqual(postResponseData.user.metadata); + expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio); + expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata); }); it(`/GET: managed user by oAuth email`, async () => { @@ -540,6 +568,8 @@ describe("OAuth Client Users Endpoints", () => { const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email); expect(userOne?.email).toEqual(postResponseData.user.email); expect(userOne?.name).toEqual(postResponseData.user.name); + expect(userOne?.bio).toEqual(postResponseData.user.bio); + expect(userOne?.metadata).toEqual(postResponseData.user.metadata); }); it(`should error /GET if managed user email is invalid`, async () => { @@ -576,6 +606,10 @@ describe("OAuth Client Users Endpoints", () => { expect(userOne?.name).toEqual(postResponseData.user.name); expect(userTwo?.email).toEqual(postResponseDataTwo.user.email); expect(userTwo?.name).toEqual(postResponseDataTwo.user.name); + expect(userOne?.bio).toEqual(postResponseData.user.bio); + expect(userOne?.metadata).toEqual(postResponseData.user.metadata); + expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio); + expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata); }); it(`/GET/:id`, async () => { @@ -592,6 +626,9 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.email).toEqual( OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail) ); + expect(responseBody.data.name).toEqual(postResponseData.user.name); + expect(responseBody.data.bio).toEqual(postResponseData.user.bio); + expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata); }); it(`/PUT/:id`, async () => { @@ -612,6 +649,9 @@ describe("OAuth Client Users Endpoints", () => { expect(responseBody.data.email).toEqual( OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail) ); + expect(responseBody.data.name).toEqual(postResponseData.user.name); + expect(responseBody.data.bio).toEqual(postResponseData.user.bio); + expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata); const [emailUser, emailDomain] = responseBody.data.email.split("@"); const [domainName, TLD] = emailDomain.split("."); expect(responseBody.data.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`)); diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts index e0ecd6d7e2..80ea89563b 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller.ts @@ -12,6 +12,7 @@ import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oaut import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto"; import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; @@ -34,6 +35,7 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger"; import { User, MembershipRole } from "@prisma/client"; +import { plainToInstance } from "class-transformer"; import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; @@ -55,7 +57,8 @@ export class OAuthClientUsersController { private readonly userRepository: UsersRepository, private readonly oAuthClientUsersService: OAuthClientUsersService, private readonly oauthRepository: OAuthClientRepository, - private readonly tokensRepository: TokensRepository + private readonly tokensRepository: TokensRepository, + private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService ) {} @Get("/") @@ -70,7 +73,7 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, - data: managedUsers.map((user) => this.getResponseUser(user)), + data: managedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)), }; } @@ -94,7 +97,7 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, data: { - user: this.getResponseUser(user), + user: this.oAuthClientUsersOutputService.getResponseUser(user), ...tokens, }, }; @@ -112,7 +115,7 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, - data: this.getResponseUser(user), + data: this.oAuthClientUsersOutputService.getResponseUser(user), }; } @@ -132,7 +135,7 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, - data: this.getResponseUser(user), + data: this.oAuthClientUsersOutputService.getResponseUser(user), }; } @@ -151,7 +154,7 @@ export class OAuthClientUsersController { return { status: SUCCESS_STATUS, - data: this.getResponseUser(user), + data: this.oAuthClientUsersOutputService.getResponseUser(user), }; } @@ -189,20 +192,4 @@ export class OAuthClientUsersController { return user; } - - private getResponseUser(user: User): ManagedUserOutput { - return { - id: user.id, - email: user.email, - username: user.username, - name: user.name, - timeZone: user.timeZone, - weekStart: user.weekStart, - createdDate: user.createdDate, - timeFormat: user.timeFormat, - defaultScheduleId: user.defaultScheduleId, - locale: user.locale as Locales, - avatarUrl: user.avatarUrl, - }; - } } diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts index 16982d4d83..89fc128c0e 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output.ts @@ -1,40 +1,54 @@ import { Locales } from "@/lib/enums/locales"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform } from "class-transformer"; -import { IsEnum, IsOptional, IsUrl } from "class-validator"; +import { Expose, Transform } from "class-transformer"; +import { IsEnum, IsObject, IsOptional, IsUrl } from "class-validator"; export class ManagedUserOutput { @ApiProperty({ example: 1 }) + @Expose() id!: number; @ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" }) + @Expose() email!: string; @ApiProperty({ example: "alice", nullable: true }) + @Expose() username!: string | null; @ApiProperty({ example: "alice", nullable: true }) + @Expose() name!: string | null; + @ApiProperty({ example: "bio", nullable: true }) + @Expose() + bio!: string | null; + @ApiProperty({ example: "America/New_York" }) + @Expose() timeZone!: string; @ApiProperty({ example: "Sunday" }) + @Expose() weekStart!: string; @ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" }) @Transform(({ value }) => value.toISOString()) + @Expose() createdDate!: Date; @ApiProperty({ type: Number, example: 12, nullable: true }) + @Expose() timeFormat!: number | null; @ApiProperty({ type: Number, example: null, nullable: true }) + @Expose() defaultScheduleId!: number | null; @IsEnum(Locales) @IsOptional() @ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales }) + @Expose() locale?: Locales; @IsUrl() @@ -45,5 +59,21 @@ export class ManagedUserOutput { description: `URL of the user's avatar image`, nullable: true, }) + @Expose() avatarUrl?: string | null; + + @ApiPropertyOptional({ + type: Object, + example: { key: "value" }, + }) + @IsObject() + @IsOptional() + @Expose() + @Transform( + // note(Lauris): added this transform because without it metadata is removed for some reason + ({ obj }: { obj: { metadata: Record | null | undefined } }) => { + return obj.metadata || undefined; + } + ) + metadata?: Record; } diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts index 922ba27412..4d630423be 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.ts @@ -10,6 +10,8 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto"; import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto"; import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service"; import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service"; import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; import { UsersRepository } from "@/modules/users/users.repository"; @@ -51,6 +53,7 @@ export class OAuthClientsController { private readonly logger = new Logger("OAuthClientController"); constructor( + private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService, private readonly oAuthClientsService: OAuthClientsService, private readonly userRepository: UsersRepository, private readonly teamsRepository: OrganizationsRepository @@ -124,7 +127,10 @@ export class OAuthClientsController { limit ?? 50 ); - return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) }; + return { + status: SUCCESS_STATUS, + data: existingManagedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)), + }; } @Patch("/:clientId") @@ -151,18 +157,4 @@ export class OAuthClientsController { const client = await this.oAuthClientsService.deleteOAuthClient(clientId); return { status: SUCCESS_STATUS, data: client }; } - - private getResponseUser(user: User): ManagedUserOutput { - return { - id: user.id, - email: user.email, - username: user.username, - name: user.name, - timeZone: user.timeZone, - weekStart: user.weekStart, - createdDate: user.createdDate, - timeFormat: user.timeFormat, - defaultScheduleId: user.defaultScheduleId, - }; - } } diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index e14176f3a3..462f41e3c6 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -11,6 +11,7 @@ import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/ import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller"; import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthClientsInputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-input.service"; import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service"; @@ -58,8 +59,9 @@ import { JwtService } from "@nestjs/jwt"; OAuthClientsInputService, OAuthClientsOutputService, JwtService, + OAuthClientUsersOutputService, ], controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController], - exports: [OAuthClientRepository], + exports: [OAuthClientRepository, OAuthClientUsersOutputService], }) export class OAuthClientModule {} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users-output.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users-output.service.ts new file mode 100644 index 0000000000..d5e384fca5 --- /dev/null +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users-output.service.ts @@ -0,0 +1,11 @@ +import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output"; +import { Injectable } from "@nestjs/common"; +import { User } from "@prisma/client"; +import { plainToInstance } from "class-transformer"; + +@Injectable() +export class OAuthClientUsersOutputService { + getResponseUser(user: User) { + return plainToInstance(ManagedUserOutput, user, { strategy: "excludeAll" }); + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts index aab4e3370e..140723e34e 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-clients-users.service.ts @@ -43,7 +43,7 @@ export class OAuthClientUsersService { ); } else { const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email); - user = ( + const createdUser = ( await createNewUsersConnectToOrgIfExists({ invitations: [ { @@ -69,15 +69,14 @@ export class OAuthClientUsersService { language: body.locale ?? Locales.EN, }) )[0]; - await this.userRepository.addToOAuthClient(user.id, oAuthClientId); - const updatedUser = await this.userRepository.update(user.id, { + await this.userRepository.addToOAuthClient(createdUser.id, oAuthClientId); + user = await this.userRepository.update(createdUser.id, { name: body.name, locale: body.locale, avatarUrl: body.avatarUrl, + bio: body.bio, + metadata: body.metadata, }); - user.locale = updatedUser.locale; - user.name = updatedUser.name; - user.avatarUrl = updatedUser.avatarUrl; } const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens( diff --git a/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts index a716e4398b..42e3d3832e 100644 --- a/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/event-types/assign-all-team-members.e2e-spec.ts @@ -1,10 +1,5 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; -import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input"; -import { - GetBookingsDataEntry, - GetBookingsOutput_2024_04_15, -} from "@/ee/bookings/2024-04-15/outputs/get-bookings.output"; import { HttpExceptionFilter } from "@/filters/http-exception.filter"; import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; import { Locales } from "@/lib/enums/locales"; @@ -15,7 +10,7 @@ import { import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input"; import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; -import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; +import { OrgTeamMembershipOutputResponseDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; @@ -32,18 +27,10 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository. import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; -import { - CAL_API_VERSION_HEADER, - SUCCESS_STATUS, - VERSION_2024_06_14, - X_CAL_CLIENT_ID, - X_CAL_SECRET_KEY, -} from "@calcom/platform-constants"; +import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; import { ApiSuccessResponse, - CreateEventTypeInput_2024_06_14, CreateTeamEventTypeInput_2024_06_14, - EventTypeOutput_2024_06_14, Host, OrgTeamOutputDto, TeamEventTypeOutput_2024_06_14, @@ -238,7 +225,7 @@ describe("Assign all team members", () => { .set(X_CAL_CLIENT_ID, oAuthClient.id) .expect(201) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: OrgTeamMembershipOutputResponseDto = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); }); }); @@ -257,7 +244,7 @@ describe("Assign all team members", () => { .set(X_CAL_CLIENT_ID, oAuthClient.id) .expect(201) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: OrgTeamMembershipOutputResponseDto = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); }); }); diff --git a/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts index f98fe717f4..967816ad19 100644 --- a/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/event-types/organizations-event-types.e2e-spec.ts @@ -17,7 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository import { randomString } from "test/utils/randomString"; import { withApiAuth } from "test/utils/withApiAuth"; -import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants"; import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, @@ -27,6 +27,7 @@ import { import { ApiSuccessResponse, CreateTeamEventTypeInput_2024_06_14, + EventTypeOutput_2024_06_14, Host, TeamEventTypeOutput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14, @@ -62,6 +63,8 @@ describe("Organizations Event Types Endpoints", () => { let collectiveEventType: TeamEventTypeOutput_2024_06_14; let managedEventType: TeamEventTypeOutput_2024_06_14; + const managedEventTypeSlug = `organizations-event-types-managed-${randomString()}`; + beforeAll(async () => { const moduleRef = await withApiAuth( userEmail, @@ -101,6 +104,7 @@ describe("Organizations Event Types Endpoints", () => { org = await organizationsRepositoryFixture.create({ name: `organizations-event-types-organization-${randomString()}`, isOrganization: true, + slug: `organizations-event-types-organization-${randomString()}`, }); falseTestOrg = await organizationsRepositoryFixture.create({ @@ -135,6 +139,36 @@ describe("Organizations Event Types Endpoints", () => { }, }); + await profileRepositoryFixture.create({ + uid: `usr-${teammate1.id}`, + username: teammate1Email, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: teammate1.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${teammate2.id}`, + username: teammate2Email, + organization: { + connect: { + id: org.id, + }, + }, + user: { + connect: { + id: teammate2.id, + }, + }, + }); + await membershipsRepositoryFixture.create({ role: "ADMIN", user: { connect: { id: userAdmin.id } }, @@ -347,7 +381,7 @@ describe("Organizations Event Types Endpoints", () => { it("should create a managed team event-type", async () => { const body: CreateTeamEventTypeInput_2024_06_14 = { title: "Coding consultation managed", - slug: `organizations-event-types-managed-${randomString()}`, + slug: managedEventTypeSlug, description: "Our team will review your codebase.", lengthInMinutes: 60, locations: [ @@ -387,7 +421,9 @@ describe("Organizations Event Types Endpoints", () => { const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id); expect(teammate1EventTypes.length).toEqual(1); + expect(teammate1EventTypes[0].slug).toEqual(managedEventTypeSlug); expect(teammate2EventTypes.length).toEqual(1); + expect(teammate2EventTypes[0].slug).toEqual(managedEventTypeSlug); expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual( 1 ); @@ -410,6 +446,42 @@ describe("Organizations Event Types Endpoints", () => { }); }); + it("managed team event types should be returned when fetching event types of users", async () => { + return request(app.getHttpServer()) + .get(`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].slug).toEqual(managedEventTypeSlug); + expect(data[0].ownerId).toEqual(teammate1.id); + expect(data[0].id).not.toEqual(managedEventType.id); + }); + }); + + it("managed team event type should be returned when fetching event types of users", async () => { + return request(app.getHttpServer()) + .get( + `/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}&eventSlug=${managedEventTypeSlug}` + ) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].slug).toEqual(managedEventTypeSlug); + expect(data[0].ownerId).toEqual(teammate1.id); + expect(data[0].id).not.toEqual(managedEventType.id); + }); + }); + it("should not get an event-type of team outside org", async () => { return request(app.getHttpServer()) .get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`) diff --git a/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.e2e-spec.ts index 64283ef15e..ff3ab1ce3f 100644 --- a/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.e2e-spec.ts @@ -2,7 +2,13 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input"; import { UpdateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/update-organization-membership.input"; +import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/outputs/create-membership.output"; +import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output"; +import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output"; +import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output"; +import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; @@ -32,7 +38,7 @@ describe("Organizations Memberships Endpoints", () => { let org: Team; let membership: Membership; let membership2: Membership; - let membershipCreatedViaApi: Membership; + let membershipCreatedViaApi: TeamMembershipOutput; const userEmail = `organizations-memberships-admin-${randomString()}@api.com`; const userEmail2 = `organizations-memberships-member-${randomString()}@api.com`; @@ -43,6 +49,11 @@ describe("Organizations Memberships Endpoints", () => { let userToInviteViaApi: User; + const metadata = { + some: "key", + }; + const bio = "This is a bio"; + beforeAll(async () => { const moduleRef = await withApiAuth( userEmail, @@ -58,15 +69,21 @@ describe("Organizations Memberships Endpoints", () => { user = await userRepositoryFixture.create({ email: userEmail, username: userEmail, + bio, + metadata, }); user2 = await userRepositoryFixture.create({ email: userEmail2, username: userEmail2, + bio, + metadata, }); userToInviteViaApi = await userRepositoryFixture.create({ email: invitedUserEmail, username: invitedUserEmail, + bio, + metadata, }); org = await organizationsRepositoryFixture.create({ @@ -104,10 +121,24 @@ describe("Organizations Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/memberships`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: GetAllOrgMemberships = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(membership.id); + expect(responseBody.data[0].userId).toEqual(user.id); + expect(responseBody.data[0].role).toEqual("ADMIN"); + expect(responseBody.data[0].user.bio).toEqual(bio); + expect(responseBody.data[0].user.metadata).toEqual(metadata); + expect(responseBody.data[0].user.email).toEqual(user.email); + expect(responseBody.data[0].user.username).toEqual(user.username); + expect(responseBody.data[0].teamId).toEqual(org.id); expect(responseBody.data[1].id).toEqual(membership2.id); + expect(responseBody.data[1].userId).toEqual(user2.id); + expect(responseBody.data[1].role).toEqual("MEMBER"); + expect(responseBody.data[1].user.bio).toEqual(bio); + expect(responseBody.data[1].user.metadata).toEqual(metadata); + expect(responseBody.data[1].user.email).toEqual(user2.email); + expect(responseBody.data[1].user.username).toEqual(user2.username); + expect(responseBody.data[1].teamId).toEqual(org.id); }); }); @@ -116,10 +147,16 @@ describe("Organizations Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: GetAllOrgMemberships = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(membership2.id); + expect(responseBody.data[0].role).toEqual("MEMBER"); + expect(responseBody.data[0].user.bio).toEqual(bio); + expect(responseBody.data[0].user.metadata).toEqual(metadata); + expect(responseBody.data[0].user.email).toEqual(user2.email); + expect(responseBody.data[0].user.username).toEqual(user2.username); expect(responseBody.data[0].userId).toEqual(user2.id); + expect(responseBody.data[0].teamId).toEqual(org.id); }); }); @@ -132,10 +169,16 @@ describe("Organizations Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/memberships/${membership.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: GetOrgMembership = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membership.id); expect(responseBody.data.userId).toEqual(user.id); + expect(responseBody.data.role).toEqual("ADMIN"); + expect(responseBody.data.user.bio).toEqual(bio); + expect(responseBody.data.user.metadata).toEqual(metadata); + expect(responseBody.data.user.email).toEqual(user.email); + expect(responseBody.data.user.username).toEqual(user.username); + expect(responseBody.data.teamId).toEqual(org.id); }); }); @@ -149,12 +192,16 @@ describe("Organizations Memberships Endpoints", () => { } satisfies CreateOrgMembershipDto) .expect(201) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: CreateOrgMembershipOutput = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.teamId).toEqual(org.id); expect(membershipCreatedViaApi.role).toEqual("MEMBER"); expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id); + expect(membershipCreatedViaApi.user.bio).toEqual(bio); + expect(membershipCreatedViaApi.user.metadata).toEqual(metadata); + expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email); + expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username); }); }); @@ -166,7 +213,7 @@ describe("Organizations Memberships Endpoints", () => { } satisfies UpdateOrgMembershipDto) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: UpdateOrgMembership = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.role).toEqual("OWNER"); @@ -178,7 +225,7 @@ describe("Organizations Memberships Endpoints", () => { .delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: DeleteOrgMembership = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); }); diff --git a/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.ts b/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.ts index 5994ba5c87..1e1306972b 100644 --- a/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.ts +++ b/apps/api/v2/src/modules/organizations/memberships/organizations-membership.controller.ts @@ -18,9 +18,9 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output"; import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output"; import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output"; -import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output"; import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output"; import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { Controller, UseGuards, @@ -71,7 +71,7 @@ export class OrganizationsMembershipsController { return { status: SUCCESS_STATUS, data: memberships.map((membership) => - plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }) + plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }) ), }; } @@ -88,7 +88,7 @@ export class OrganizationsMembershipsController { const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body); return { status: SUCCESS_STATUS, - data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), }; } @@ -105,7 +105,7 @@ export class OrganizationsMembershipsController { const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId); return { status: SUCCESS_STATUS, - data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), }; } @@ -122,7 +122,7 @@ export class OrganizationsMembershipsController { const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId); return { status: SUCCESS_STATUS, - data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), }; } @@ -144,7 +144,7 @@ export class OrganizationsMembershipsController { ); return { status: SUCCESS_STATUS, - data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), }; } } diff --git a/apps/api/v2/src/modules/organizations/memberships/organizations-membership.repository.ts b/apps/api/v2/src/modules/organizations/memberships/organizations-membership.repository.ts index df086820ba..c9f75fa04a 100644 --- a/apps/api/v2/src/modules/organizations/memberships/organizations-membership.repository.ts +++ b/apps/api/v2/src/modules/organizations/memberships/organizations-membership.repository.ts @@ -1,6 +1,7 @@ import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository"; import { Injectable } from "@nestjs/common"; import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input"; @@ -15,6 +16,7 @@ export class OrganizationsMembershipRepository { id: membershipId, teamId: organizationId, }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -24,6 +26,7 @@ export class OrganizationsMembershipRepository { teamId: organizationId, userId, }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -33,6 +36,7 @@ export class OrganizationsMembershipRepository { id: membershipId, teamId: organizationId, }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -41,12 +45,14 @@ export class OrganizationsMembershipRepository { create: { ...data, teamId: organizationId }, update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation }, where: { userId_teamId: { userId: data.userId, teamId: organizationId } }, + include: { user: { select: MembershipUserSelect } }, }); } async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) { return this.dbWrite.prisma.membership.update({ data: { ...data }, where: { id: membershipId, teamId: organizationId }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -55,6 +61,7 @@ export class OrganizationsMembershipRepository { where: { teamId: organizationId, }, + include: { user: { select: MembershipUserSelect } }, skip, take, }); diff --git a/apps/api/v2/src/modules/organizations/memberships/outputs/create-membership.output.ts b/apps/api/v2/src/modules/organizations/memberships/outputs/create-membership.output.ts index c662f75293..0315dea002 100644 --- a/apps/api/v2/src/modules/organizations/memberships/outputs/create-membership.output.ts +++ b/apps/api/v2/src/modules/organizations/memberships/outputs/create-membership.output.ts @@ -1,4 +1,4 @@ -import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; @@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput { status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: OrgMembershipOutputDto, + type: TeamMembershipOutput, }) @IsNotEmptyObject() @ValidateNested() - @Type(() => OrgMembershipOutputDto) - data!: OrgMembershipOutputDto; + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; } diff --git a/apps/api/v2/src/modules/organizations/memberships/outputs/delete-membership.output.ts b/apps/api/v2/src/modules/organizations/memberships/outputs/delete-membership.output.ts index 8dd77523f4..fac912f50c 100644 --- a/apps/api/v2/src/modules/organizations/memberships/outputs/delete-membership.output.ts +++ b/apps/api/v2/src/modules/organizations/memberships/outputs/delete-membership.output.ts @@ -1,4 +1,4 @@ -import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { ApiProperty } from "@nestjs/swagger"; import { IsEnum } from "class-validator"; @@ -9,5 +9,5 @@ export class DeleteOrgMembership { @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; - data!: OrgMembershipOutputDto; + data!: TeamMembershipOutput; } diff --git a/apps/api/v2/src/modules/organizations/memberships/outputs/get-all-memberships.output.ts b/apps/api/v2/src/modules/organizations/memberships/outputs/get-all-memberships.output.ts index f29b477869..897f04da5d 100644 --- a/apps/api/v2/src/modules/organizations/memberships/outputs/get-all-memberships.output.ts +++ b/apps/api/v2/src/modules/organizations/memberships/outputs/get-all-memberships.output.ts @@ -1,4 +1,4 @@ -import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator"; @@ -11,11 +11,11 @@ export class GetAllOrgMemberships { status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: OrgMembershipOutputDto, + type: TeamMembershipOutput, }) @IsNotEmptyObject() @ValidateNested({ each: true }) - @Type(() => OrgMembershipOutputDto) + @Type(() => TeamMembershipOutput) @IsArray() - data!: OrgMembershipOutputDto[]; + data!: TeamMembershipOutput[]; } diff --git a/apps/api/v2/src/modules/organizations/memberships/outputs/get-membership.output.ts b/apps/api/v2/src/modules/organizations/memberships/outputs/get-membership.output.ts index df4326c3ba..a56c64bb77 100644 --- a/apps/api/v2/src/modules/organizations/memberships/outputs/get-membership.output.ts +++ b/apps/api/v2/src/modules/organizations/memberships/outputs/get-membership.output.ts @@ -1,4 +1,4 @@ -import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; @@ -11,10 +11,10 @@ export class GetOrgMembership { status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: OrgMembershipOutputDto, + type: TeamMembershipOutput, }) @IsNotEmptyObject() @ValidateNested() - @Type(() => OrgMembershipOutputDto) - data!: OrgMembershipOutputDto; + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; } diff --git a/apps/api/v2/src/modules/organizations/memberships/outputs/membership.output.ts b/apps/api/v2/src/modules/organizations/memberships/outputs/membership.output.ts deleted file mode 100644 index 1628087dd1..0000000000 --- a/apps/api/v2/src/modules/organizations/memberships/outputs/membership.output.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { MembershipRole } from "@prisma/client"; -import { Expose } from "class-transformer"; -import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; - -export class OrgMembershipOutputDto { - @IsInt() - @Expose() - @ApiProperty() - readonly id!: number; - - @IsInt() - @Expose() - @ApiProperty() - readonly userId!: number; - - @IsInt() - @Expose() - @ApiProperty() - readonly teamId!: number; - - @IsBoolean() - @Expose() - @ApiProperty() - readonly accepted!: boolean; - - @IsString() - @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) - @Expose() - readonly role!: MembershipRole; - - @IsOptional() - @IsBoolean() - @Expose() - @ApiPropertyOptional() - readonly disableImpersonation?: boolean; -} diff --git a/apps/api/v2/src/modules/organizations/memberships/outputs/update-membership.output.ts b/apps/api/v2/src/modules/organizations/memberships/outputs/update-membership.output.ts index 66d35f514c..260b20dc8d 100644 --- a/apps/api/v2/src/modules/organizations/memberships/outputs/update-membership.output.ts +++ b/apps/api/v2/src/modules/organizations/memberships/outputs/update-membership.output.ts @@ -1,4 +1,4 @@ -import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator"; @@ -11,10 +11,10 @@ export class UpdateOrgMembership { status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; @ApiProperty({ - type: OrgMembershipOutputDto, + type: TeamMembershipOutput, }) @IsNotEmptyObject() @ValidateNested() - @Type(() => OrgMembershipOutputDto) - data!: OrgMembershipOutputDto; + @Type(() => TeamMembershipOutput) + data!: TeamMembershipOutput; } diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts index 976201ed86..4cfa31bfff 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts @@ -2,8 +2,8 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input"; -import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; @@ -43,7 +43,7 @@ describe("Organizations Teams Memberships Endpoints", () => { let managedEventType: EventType; let membership: Membership; let membership2: Membership; - let membershipCreatedViaApi: OrgTeamMembershipOutputDto; + let membershipCreatedViaApi: TeamMembershipOutput; const userEmail = `organizations-teams-memberships-admin-${randomString()}@api.com`; const userEmail2 = `organizations-teams-memberships-member-${randomString()}@api.com`; @@ -56,6 +56,11 @@ describe("Organizations Teams Memberships Endpoints", () => { let userToInviteViaApi: User; + const metadata = { + some: "key", + }; + const bio = "This is a bio"; + beforeAll(async () => { const moduleRef = await withApiAuth( userEmail, @@ -75,10 +80,14 @@ describe("Organizations Teams Memberships Endpoints", () => { user = await userRepositoryFixture.create({ email: userEmail, username: userEmail, + bio, + metadata, }); user2 = await userRepositoryFixture.create({ email: userEmail2, username: userEmail2, + bio, + metadata, }); nonOrgUser = await userRepositoryFixture.create({ @@ -89,6 +98,8 @@ describe("Organizations Teams Memberships Endpoints", () => { userToInviteViaApi = await userRepositoryFixture.create({ email: invitedUserEmail, username: invitedUserEmail, + bio, + metadata, }); org = await organizationsRepositoryFixture.create({ @@ -232,11 +243,25 @@ describe("Organizations Teams Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(membership.id); + expect(responseBody.data[0].userId).toEqual(user.id); + expect(responseBody.data[0].role).toEqual("ADMIN"); + expect(responseBody.data[0].user.bio).toEqual(bio); + expect(responseBody.data[0].user.metadata).toEqual(metadata); + expect(responseBody.data[0].user.email).toEqual(user.email); + expect(responseBody.data[0].user.username).toEqual(user.username); expect(responseBody.data[1].id).toEqual(membership2.id); + expect(responseBody.data[1].userId).toEqual(user2.id); + expect(responseBody.data[1].role).toEqual("MEMBER"); + expect(responseBody.data[1].user.bio).toEqual(user2.bio); + expect(responseBody.data[1].user.metadata).toEqual(user2.metadata); + expect(responseBody.data[1].user.email).toEqual(user2.email); + expect(responseBody.data[1].user.username).toEqual(user2.username); expect(responseBody.data.length).toEqual(2); + expect(responseBody.data[0].teamId).toEqual(orgTeam.id); + expect(responseBody.data[1].teamId).toEqual(orgTeam.id); }); }); @@ -251,10 +276,15 @@ describe("Organizations Teams Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(membership2.id); expect(responseBody.data[0].userId).toEqual(user2.id); + expect(responseBody.data[0].role).toEqual("MEMBER"); + expect(responseBody.data[0].user.bio).toEqual(bio); + expect(responseBody.data[0].user.metadata).toEqual(metadata); + expect(responseBody.data[0].user.email).toEqual(user2.email); + expect(responseBody.data[0].user.username).toEqual(user2.username); expect(responseBody.data.length).toEqual(1); }); }); @@ -270,11 +300,16 @@ describe("Organizations Teams Memberships Endpoints", () => { .get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membership.id); expect(responseBody.data.userId).toEqual(user.id); expect(responseBody.data.user.email).toEqual(user.email); + expect(responseBody.data.user.username).toEqual(user.username); + expect(responseBody.data.user.bio).toEqual(bio); + expect(responseBody.data.user.metadata).toEqual(metadata); + expect(responseBody.data.role).toEqual("ADMIN"); + expect(responseBody.data.teamId).toEqual(orgTeam.id); }); }); @@ -305,13 +340,16 @@ describe("Organizations Teams Memberships Endpoints", () => { } satisfies CreateOrgTeamMembershipDto) .expect(201) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id); expect(membershipCreatedViaApi.role).toEqual("MEMBER"); expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id); expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email); + expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username); + expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio); + expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata); userHasCorrectEventTypes(membershipCreatedViaApi.userId); }); }); @@ -347,10 +385,14 @@ describe("Organizations Teams Memberships Endpoints", () => { } satisfies UpdateOrgTeamMembershipDto) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.role).toEqual("OWNER"); + expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email); + expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username); + expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio); + expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata); }); }); @@ -359,7 +401,7 @@ describe("Organizations Teams Memberships Endpoints", () => { .delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); }); diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts index b3deaa0e33..3ca7b6fe8e 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.controller.ts @@ -16,12 +16,12 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input"; import { - OrgTeamMembershipOutputDto, OrgTeamMembershipsOutputResponseDto, OrgTeamMembershipOutputResponseDto, } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service"; import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; import { Controller, UseGuards, @@ -84,7 +84,7 @@ export class OrganizationsTeamsMembershipsController { return { status: SUCCESS_STATUS, data: orgTeamMemberships.map((membership) => - plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }) + plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }) ), }; } @@ -107,7 +107,7 @@ export class OrganizationsTeamsMembershipsController { ); return { status: SUCCESS_STATUS, - data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }), }; } @@ -131,7 +131,7 @@ export class OrganizationsTeamsMembershipsController { return { status: SUCCESS_STATUS, - data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), }; } @@ -168,7 +168,7 @@ export class OrganizationsTeamsMembershipsController { return { status: SUCCESS_STATUS, - data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, updatedMembership, { strategy: "excludeAll" }), }; } @@ -198,7 +198,7 @@ export class OrganizationsTeamsMembershipsController { } return { status: SUCCESS_STATUS, - data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }), + data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }), }; } } diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.repository.ts b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.repository.ts index c2b42e5f5c..0fa4eac1cd 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.repository.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/organizations-teams-memberships.repository.ts @@ -2,6 +2,7 @@ import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/member import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository"; import { Injectable } from "@nestjs/common"; @Injectable() @@ -16,7 +17,7 @@ export class OrganizationsTeamsMembershipsRepository { parentId: organizationId, }, }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, skip, take, }); @@ -31,7 +32,7 @@ export class OrganizationsTeamsMembershipsRepository { parentId: organizationId, }, }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, }); } async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) { @@ -43,6 +44,7 @@ export class OrganizationsTeamsMembershipsRepository { parentId: organizationId, }, }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -61,14 +63,14 @@ export class OrganizationsTeamsMembershipsRepository { parentId: organizationId, }, }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, }); } async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) { return this.dbWrite.prisma.membership.create({ data: { ...data, teamId: teamId }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, }); } } diff --git a/apps/api/v2/src/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output.ts b/apps/api/v2/src/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output.ts index 1404dd1a94..a151a4358d 100644 --- a/apps/api/v2/src/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output.ts +++ b/apps/api/v2/src/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output.ts @@ -1,74 +1,10 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { MembershipRole } from "@prisma/client"; +import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output"; +import { ApiProperty } from "@nestjs/swagger"; import { Expose, Type } from "class-transformer"; -import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator"; +import { IsArray, IsEnum, ValidateNested } from "class-validator"; import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; -export class MembershipUserOutputDto { - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly avatarUrl?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly username?: string; - - @IsOptional() - @IsString() - @Expose() - @ApiPropertyOptional() - readonly name?: string; - - @IsBoolean() - @Expose() - @ApiProperty() - readonly email!: string; -} - -export class OrgTeamMembershipOutputDto { - @IsInt() - @Expose() - @ApiProperty() - readonly id!: number; - - @IsInt() - @Expose() - @ApiProperty() - readonly userId!: number; - - @IsInt() - @Expose() - @ApiProperty() - readonly teamId!: number; - - @IsBoolean() - @Expose() - @ApiProperty() - readonly accepted!: boolean; - - @IsString() - @ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] }) - @Expose() - readonly role!: MembershipRole; - - @IsOptional() - @IsBoolean() - @Expose() - @ApiPropertyOptional() - readonly disableImpersonation?: boolean; - - @ValidateNested() - @Type(() => MembershipUserOutputDto) - @Expose() - @ApiProperty({ type: MembershipUserOutputDto }) - user!: MembershipUserOutputDto; -} - export class OrgTeamMembershipsOutputResponseDto { @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) @@ -76,10 +12,10 @@ export class OrgTeamMembershipsOutputResponseDto { @Expose() @ValidateNested() - @Type(() => OrgTeamMembershipOutputDto) + @Type(() => TeamMembershipOutput) @IsArray() - @ApiProperty({ type: [OrgTeamMembershipOutputDto] }) - data!: OrgTeamMembershipOutputDto[]; + @ApiProperty({ type: [TeamMembershipOutput] }) + data!: TeamMembershipOutput[]; } export class OrgTeamMembershipOutputResponseDto { @@ -89,7 +25,7 @@ export class OrgTeamMembershipOutputResponseDto { @Expose() @ValidateNested() - @Type(() => OrgTeamMembershipOutputDto) - @ApiProperty({ type: OrgTeamMembershipOutputDto }) - data!: OrgTeamMembershipOutputDto; + @Type(() => TeamMembershipOutput) + @ApiProperty({ type: TeamMembershipOutput }) + data!: TeamMembershipOutput; } diff --git a/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts new file mode 100644 index 0000000000..8ea6d60cc4 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller.ts @@ -0,0 +1,48 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; +import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service"; +import { Controller, UseGuards, Get, Query, ParseIntPipe, Param } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GetBookingsInput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types"; +import { User } from "@calcom/prisma/client"; + +@Controller({ + path: "/v2/organizations/:orgId/users/:userId/bookings", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsUserInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Users / Bookings") +export class OrganizationsUsersBookingsController { + constructor(private readonly organizationUsersBookingsService: OrganizationUsersBookingsService) {} + + @Get("/") + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @ApiOperation({ summary: "Get all bookings of an organization user" }) + async getOrganizationUserBookings( + @GetUser() user: User, + @Param("orgId", ParseIntPipe) orgId: number, + @Query() query: GetBookingsInput_2024_08_13 + ): Promise { + const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings( + orgId, + user, + query + ); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts new file mode 100644 index 0000000000..cd3e6d1cc8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/users/bookings/controllers/organizations-users-bookings.e2e-spec.ts @@ -0,0 +1,416 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output"; +import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input"; +import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { User } from "@prisma/client"; +import * as request from "supertest"; +import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; +import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; +import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; +import { + CreateBookingInput_2024_08_13, + BookingOutput_2024_08_13, + RecurringBookingOutput_2024_08_13, + GetBookingsOutput_2024_08_13, + GetSeatedBookingOutput_2024_08_13, +} from "@calcom/platform-types"; +import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; + +describe("Organizations UsersBookings Endpoints 2024-08-13", () => { + describe("Organization User bookings", () => { + let app: INestApplication; + let organization: Team; + let team1: Team; + + let userRepositoryFixture: UserRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; + let schedulesService: SchedulesService_2024_04_15; + let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let oAuthClient: PlatformOAuthClient; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let hostsRepositoryFixture: HostsRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let profileRepositoryFixture: ProfileRepositoryFixture; + + const teamUserEmail = `organizations-users-team-user-${randomString()}@api.com`; + let teamUser: User; + + let team1EventTypeId: number; + + let personalEventTypeId: number; + const personalEventTypeSlug = `organizations-users-bookings-personal-event-type-${randomString()}`; + + let createdPersonalBooking: BookingOutput_2024_08_13; + let createdPersonalBookingUsingUsername: BookingOutput_2024_08_13; + let createdTeamBooking: BookingOutput_2024_08_13; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + teamUserEmail, + Test.createTestingModule({ + imports: [AppModule, OrganizationsTeamsBookingsModule], + }) + ) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef); + schedulesService = moduleRef.get(SchedulesService_2024_04_15); + + organization = await organizationsRepositoryFixture.create({ name: "organizations user bookings" }); + oAuthClient = await createOAuthClient(organization.id); + + team1 = await teamRepositoryFixture.create({ + name: "team 1", + isOrganization: false, + parent: { connect: { id: organization.id } }, + createdByOAuthClient: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + teamUser = await userRepositoryFixture.create({ + email: teamUserEmail, + username: teamUserEmail, + locale: "it", + name: "orgUser1team1", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const personalEvent = await eventTypesRepositoryFixture.create( + { + title: `user-bookings-2024-08-13-event-type-${randomString()}`, + slug: personalEventTypeSlug, + length: 15, + }, + teamUser.id + ); + personalEventTypeId = personalEvent.id; + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(teamUser.id, userSchedule); + + await profileRepositoryFixture.create({ + uid: `usr-${teamUser.id}`, + username: teamUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: teamUser.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: teamUser.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "collective-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + team1EventTypeId = team1EventType.id; + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: teamUser.id, + }, + }, + eventType: { + connect: { + id: team1EventType.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("create bookings", () => { + it("should create a personal booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: personalEventTypeId, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.hosts[0].username).toEqual(teamUser.username); + expect(data.hosts[0].email).toEqual(teamUser.email); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 15, 0)).toISOString()); + expect(data.duration).toEqual(15); + expect(data.eventTypeId).toEqual(personalEventTypeId); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.location).toEqual(body.location); + expect(data.meetingUrl).toEqual(body.location); + expect(data.absentHost).toEqual(false); + expect(data.bookingFieldsResponses.email).toEqual(body.attendee.email); + createdPersonalBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a personal booking using username and event slug and organization slug", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(), + eventTypeSlug: personalEventTypeSlug, + username: teamUser.username!, + organizationSlug: organization.slug!, + attendee: { + name: "Mr Proper", + email: "mr_proper@gmail.com", + timeZone: "Europe/Rome", + language: "it", + }, + location: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.hosts[0].username).toEqual(teamUser.username); + expect(data.hosts[0].email).toEqual(teamUser.email); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 15, 0)).toISOString()); + expect(data.duration).toEqual(15); + expect(data.eventTypeId).toEqual(personalEventTypeId); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.location).toEqual(body.location); + expect(data.meetingUrl).toEqual(body.location); + expect(data.absentHost).toEqual(false); + expect(data.bookingFieldsResponses.email).toEqual(body.attendee.email); + createdPersonalBookingUsingUsername = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a team booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(), + eventTypeId: team1EventTypeId, + attendee: { + name: "alice", + email: "alice@gmail.com", + timeZone: "Europe/Madrid", + language: "es", + }, + meetingUrl: "https://meet.google.com/abc-def-ghi", + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsBooking(responseBody.data)).toBe(true); + + if (responseDataIsBooking(responseBody.data)) { + const data: BookingOutput_2024_08_13 = responseBody.data; + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts.length).toEqual(1); + expect(data.hosts[0].id).toEqual(teamUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 16, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(team1EventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: body.attendee.email, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + createdTeamBooking = data; + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + }); + + describe("get bookings", () => { + it("should get individual and team organization user bookings", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/users/${teamUser.id}/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(200) + .then(async (response) => { + const responseBody: GetBookingsOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + const data: ( + | BookingOutput_2024_08_13 + | RecurringBookingOutput_2024_08_13 + | GetSeatedBookingOutput_2024_08_13 + )[] = responseBody.data; + expect(data.length).toEqual(3); + expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined(); + expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined(); + expect( + data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id) + ).toBeDefined(); + }); + }); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 { + return !Array.isArray(data) && typeof data === "object" && data && "id" in data; + } + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(teamUser.email); + await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/users/bookings/organizations-users-bookings.module.ts b/apps/api/v2/src/modules/organizations/users/bookings/organizations-users-bookings.module.ts new file mode 100644 index 0000000000..5d231f015b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/users/bookings/organizations-users-bookings.module.ts @@ -0,0 +1,24 @@ +import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { OrganizationsUsersBookingsController } from "@/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller"; +import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [ + BookingsModule_2024_08_13, + UsersModule, + PrismaModule, + StripeModule, + RedisModule, + MembershipsModule, + ], + providers: [OrganizationUsersBookingsService, OrganizationsRepository], + controllers: [OrganizationsUsersBookingsController], +}) +export class OrganizationsUsersBookingsModule {} diff --git a/apps/api/v2/src/modules/organizations/users/bookings/services/organization-users-bookings.service.ts b/apps/api/v2/src/modules/organizations/users/bookings/services/organization-users-bookings.service.ts new file mode 100644 index 0000000000..c93214e398 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/users/bookings/services/organization-users-bookings.service.ts @@ -0,0 +1,18 @@ +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +import { Injectable } from "@nestjs/common"; + +import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types"; +import { User } from "@calcom/prisma/client"; + +@Injectable() +export class OrganizationUsersBookingsService { + constructor(private readonly bookingsService: BookingsService_2024_08_13) {} + + async getOrganizationUserBookings( + orgId: number, + user: Pick, + queryParams: GetBookingsInput_2024_08_13 + ) { + return await this.bookingsService.getBookings(queryParams, { orgId, id: user.id, email: user.email }); + } +} diff --git a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts index 90b3bff658..78f63ed2ce 100644 --- a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts +++ b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.controller.ts @@ -90,7 +90,6 @@ export class OrganizationsUsersController { @PlatformPlan("ESSENTIALS") @ApiOperation({ summary: "Create a user" }) async createOrganizationUser( - @Param("orgId", ParseIntPipe) orgId: number, @GetOrg() org: Team, @Body() input: CreateOrganizationUserInput, @GetUser() inviter: UserWithProfile @@ -118,7 +117,6 @@ export class OrganizationsUsersController { async updateOrganizationUser( @Param("orgId", ParseIntPipe) orgId: number, @Param("userId", ParseIntPipe) userId: number, - @GetOrg() org: Team, @Body() input: UpdateOrganizationUserInput ): Promise { const user = await this.organizationsUsersService.updateUser(orgId, userId, input); diff --git a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts index e2cbd64b3d..1ea598049b 100644 --- a/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/users/index/controllers/organizations-users.e2e-spec.ts @@ -4,6 +4,7 @@ import { EmailService } from "@/modules/email/email.service"; import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensModule } from "@/modules/tokens/tokens.module"; +import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; import { NestExpressApplication } from "@nestjs/platform-express"; @@ -22,6 +23,9 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { User, Team, EventType } from "@calcom/prisma/client"; describe("Organizations Users Endpoints", () => { + const bio = "I am a bio"; + const metadata = { foo: "bar" }; + describe("Member role", () => { let app: INestApplication; @@ -56,6 +60,8 @@ describe("Organizations Users Endpoints", () => { email: userEmail, username: userEmail, organization: { connect: { id: org.id } }, + bio, + metadata, }); await profileRepositoryFixture.create({ @@ -170,6 +176,8 @@ describe("Organizations Users Endpoints", () => { email: member.email, username: member.username, organization: { connect: { id: org.id } }, + bio, + metadata, }) ) ); @@ -197,6 +205,8 @@ describe("Organizations Users Endpoints", () => { email: userEmail, username: userEmail, organization: { connect: { id: org.id } }, + bio, + metadata, }); await profileRepositoryFixture.create({ @@ -244,9 +254,31 @@ describe("Organizations Users Endpoints", () => { { userData }, userData.map((u) => u.profile) ); - expect(userData.find((u) => u.profile.username === orgMembersData[0].username)).toBeDefined(); - expect(userData.find((u) => u.profile.username === orgMembersData[1].username)).toBeDefined(); - expect(userData.find((u) => u.profile.username === orgMembersData[2].username)).toBeDefined(); + // Find and verify each member's data + const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username); + const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username); + const member2 = userData.find((u) => u.profile.username === orgMembersData[2].username); + + // Verify member 0 + expect(member0).toBeDefined(); + expect(member0?.email).toBe(orgMembersData[0].email); + expect(member0?.profile.username).toBe(orgMembersData[0].username); + expect(member0?.bio).toBe(bio); + expect(member0?.metadata).toEqual(metadata); + + // Verify member 1 + expect(member1).toBeDefined(); + expect(member1?.email).toBe(orgMembersData[1].email); + expect(member1?.profile.username).toBe(orgMembersData[1].username); + expect(member1?.bio).toBe(bio); + expect(member1?.metadata).toEqual(metadata); + + // Verify member 2 + expect(member2).toBeDefined(); + expect(member2?.email).toBe(orgMembersData[2].email); + expect(member2?.profile.username).toBe(orgMembersData[2].username); + expect(member2?.bio).toBe(bio); + expect(member2?.metadata).toEqual(metadata); expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0); }); @@ -265,8 +297,12 @@ describe("Organizations Users Endpoints", () => { expect(body.status).toBe(SUCCESS_STATUS); expect(userData.length).toBe(1); - expect(userData.filter((user) => user.email === userEmail).length).toBe(1); - expect(userData.find((u) => u.profile.username === user.username)).toBeDefined(); + const foundUser = userData.find((u) => u.email === userEmail); + expect(foundUser).toBeDefined(); + expect(foundUser?.email).toBe(userEmail); + expect(foundUser?.profile.username).toBe(user.username); + expect(foundUser?.bio).toBe(bio); + expect(foundUser?.metadata).toEqual(metadata); }); it("should get users within the specified emails array", async () => { @@ -285,8 +321,20 @@ describe("Organizations Users Endpoints", () => { expect(body.status).toBe(SUCCESS_STATUS); expect(userData.length).toBe(2); - expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1); - expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1); + const adminUser = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === userEmail); + const orgMember = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === orgMemberEmail); + + expect(adminUser).toBeDefined(); + expect(adminUser?.email).toBe(userEmail); + expect(adminUser?.profile.username).toBe(user.username); + expect(adminUser?.bio).toBe(bio); + expect(adminUser?.metadata).toEqual(metadata); + + expect(orgMember).toBeDefined(); + expect(orgMember?.email).toBe(orgMemberEmail); + expect(orgMember?.profile.username).toBe(orgMembersData[0].username); + expect(orgMember?.bio).toBe(bio); + expect(orgMember?.metadata).toEqual(metadata); }); it("should update an org user", async () => { @@ -304,10 +352,10 @@ describe("Organizations Users Endpoints", () => { }); it("should create a new org user", async () => { - const newOrgUser = { + const newOrgUser: CreateUserInput = { email: `organizations-users-new-member-${randomString()}@api.com`, - organizationRole: "MEMBER", - autoAccept: true, + bio, + metadata, }; const emailSpy = jest @@ -315,15 +363,15 @@ describe("Organizations Users Endpoints", () => { .mockImplementation(() => Promise.resolve()); const { body } = await request(app.getHttpServer()) .post(`/v2/organizations/${org.id}/users`) - .send({ - email: newOrgUser.email, - }) + .send(newOrgUser) .set("Content-Type", "application/json") .set("Accept", "application/json"); const userData = body.data; expect(body.status).toBe(SUCCESS_STATUS); expect(userData.email).toBe(newOrgUser.email); + expect(userData.bio).toBe(newOrgUser.bio); + expect(userData.metadata).toEqual(newOrgUser.metadata); expect(emailSpy).toHaveBeenCalledWith({ usernameOrEmail: newOrgUser.email, orgName: org.name, diff --git a/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts b/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts index f26bd71fc8..ff6800c1b5 100644 --- a/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts +++ b/apps/api/v2/src/modules/organizations/users/index/organizations-users.repository.ts @@ -1,9 +1,7 @@ -import { CreateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/create-organization-user.input"; -import { UpdateOrganizationUserInput } from "@/modules/organizations/users/index/inputs/update-organization-user.input"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { Injectable } from "@nestjs/common"; -import { CreationSource } from "@prisma/client"; +import { Prisma } from "@prisma/client"; @Injectable() export class OrganizationsUsersRepository { @@ -69,15 +67,7 @@ export class OrganizationsUsersRepository { }); } - async createOrganizationUser(orgId: number, createUserBody: CreateOrganizationUserInput) { - const createdUser = await this.dbWrite.prisma.user.create({ - data: { ...createUserBody, creationSource: CreationSource.API_V2 }, - }); - - return createdUser; - } - - async updateOrganizationUser(orgId: number, userId: number, updateUserBody: UpdateOrganizationUserInput) { + async updateOrganizationUser(orgId: number, userId: number, updateUserBody: Prisma.UserUpdateInput) { return await this.dbWrite.prisma.user.update({ where: { id: 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 8884a5244b..a658e312b0 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 @@ -117,7 +117,7 @@ export class SlotsController_2024_09_04 { name: "eventTypeSlug", required: false, description: - "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.", + "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.", example: "event-type-slug", }) @ApiQuery({ @@ -126,6 +126,12 @@ export class SlotsController_2024_09_04 { description: "The username of the user to get event types for.", example: "bob", }) + @ApiQuery({ + name: "organizationSlug", + required: false, + description: "The slug of the organization to which user with username belongs.", + example: "org-slug", + }) @ApiQuery({ name: "end", required: true, diff --git a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts index 1e9a1943bb..44e53c818f 100644 --- a/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/event-types/controllers/teams-event-types.controller.e2e-spec.ts @@ -17,7 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository import { randomString } from "test/utils/randomString"; import { withApiAuth } from "test/utils/withApiAuth"; -import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants"; import { BookingWindowPeriodInputTypeEnum_2024_06_14, BookerLayoutsInputEnum_2024_06_14, @@ -27,6 +27,7 @@ import { import { ApiSuccessResponse, CreateTeamEventTypeInput_2024_06_14, + EventTypeOutput_2024_06_14, Host, TeamEventTypeOutput_2024_06_14, UpdateTeamEventTypeInput_2024_06_14, @@ -416,6 +417,40 @@ describe("Organizations Event Types Endpoints", () => { }); }); + it("managed team event types should be returned when fetching event types of users", async () => { + return request(app.getHttpServer()) + .get(`/v2/event-types?username=${teamMember1.username}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].slug).toEqual(managedEventType.slug); + expect(data[0].ownerId).toEqual(teamMember1.id); + expect(data[0].id).not.toEqual(managedEventType.id); + }); + }); + + it("managed team event type should be returned when fetching event types of users", async () => { + return request(app.getHttpServer()) + .get(`/v2/event-types?username=${teamMember1.username}&eventSlug=${managedEventType.slug}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_06_14) + .expect(200) + .then(async (response) => { + const responseBody: ApiSuccessResponse = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + + const data = responseBody.data; + expect(data.length).toEqual(1); + expect(data[0].slug).toEqual(managedEventType.slug); + expect(data[0].ownerId).toEqual(teamMember1.id); + expect(data[0].id).not.toEqual(managedEventType.id); + }); + }); + it("should not get a non existing event-type", async () => { return request(app.getHttpServer()).get(`/v2/teams/${team.id}/event-types/999999`).expect(404); }); diff --git a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts index a180ff20c7..d8291169c5 100644 --- a/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/teams/memberships/controllers/teams-memberships.controller.e2e-spec.ts @@ -1,8 +1,5 @@ import { bootstrap } from "@/app"; import { AppModule } from "@/app.module"; -import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input"; -import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input"; -import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input"; import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; @@ -63,6 +60,11 @@ describe("Teams Memberships Endpoints", () => { let teammateInvitedViaApi: User; + const metadata = { + some: "key", + }; + const bio = "This is a bio"; + beforeAll(async () => { const moduleRef = await withApiAuth( teamAdminEmail, @@ -82,18 +84,27 @@ describe("Teams Memberships Endpoints", () => { teamAdmin = await userRepositoryFixture.create({ email: teamAdminEmail, username: teamAdminEmail, + bio, + metadata, }); + teamMember = await userRepositoryFixture.create({ email: teamMemberEmail, username: teamMemberEmail, + bio, + metadata, }); + nonTeamUser = await userRepositoryFixture.create({ email: nonTeamUserEmail, username: nonTeamUserEmail, }); + teammateInvitedViaApi = await userRepositoryFixture.create({ email: invitedUserEmail, username: invitedUserEmail, + bio, + metadata, }); team = await teamsRepositoryFixture.create({ @@ -185,6 +196,20 @@ describe("Teams Memberships Endpoints", () => { expect(responseBody.data.length).toEqual(2); expect(responseBody.data[0].id).toEqual(teamAdminMembership.id); expect(responseBody.data[1].id).toEqual(teamMemberMembership.id); + expect(responseBody.data[0].teamId).toEqual(team.id); + expect(responseBody.data[1].teamId).toEqual(team.id); + expect(responseBody.data[0].userId).toEqual(teamAdmin.id); + expect(responseBody.data[1].userId).toEqual(teamMember.id); + expect(responseBody.data[0].role).toEqual("ADMIN"); + expect(responseBody.data[1].role).toEqual("MEMBER"); + expect(responseBody.data[0].user.bio).toEqual(teamAdmin.bio); + expect(responseBody.data[1].user.bio).toEqual(teamMember.bio); + expect(responseBody.data[0].user.metadata).toEqual(teamAdmin.metadata); + expect(responseBody.data[1].user.metadata).toEqual(teamMember.metadata); + expect(responseBody.data[0].user.email).toEqual(teamAdmin.email); + expect(responseBody.data[1].user.email).toEqual(teamMember.email); + expect(responseBody.data[0].user.username).toEqual(teamAdmin.username); + expect(responseBody.data[1].user.username).toEqual(teamMember.username); }); }); @@ -201,7 +226,13 @@ describe("Teams Memberships Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data[0].id).toEqual(teamMemberMembership.id); expect(responseBody.data[0].userId).toEqual(teamMember.id); + expect(responseBody.data[0].role).toEqual("MEMBER"); + expect(responseBody.data[0].user.bio).toEqual(teamMember.bio); + expect(responseBody.data[0].user.metadata).toEqual(teamMember.metadata); + expect(responseBody.data[0].user.email).toEqual(teamMember.email); + expect(responseBody.data[0].user.username).toEqual(teamMember.username); expect(responseBody.data.length).toEqual(1); + expect(responseBody.data[0].teamId).toEqual(team.id); }); }); @@ -214,6 +245,11 @@ describe("Teams Memberships Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(teamAdminMembership.id); expect(responseBody.data.userId).toEqual(teamAdmin.id); + expect(responseBody.data.role).toEqual("ADMIN"); + expect(responseBody.data.user.bio).toEqual(teamAdmin.bio); + expect(responseBody.data.user.metadata).toEqual(teamAdmin.metadata); + expect(responseBody.data.user.email).toEqual(teamAdmin.email); + expect(responseBody.data.user.username).toEqual(teamAdmin.username); }); }); @@ -234,6 +270,10 @@ describe("Teams Memberships Endpoints", () => { membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.teamId).toEqual(team.id); expect(membershipCreatedViaApi.role).toEqual("MEMBER"); + expect(membershipCreatedViaApi.user.bio).toEqual(teammateInvitedViaApi.bio); + expect(membershipCreatedViaApi.user.metadata).toEqual(teammateInvitedViaApi.metadata); + expect(membershipCreatedViaApi.user.email).toEqual(teammateInvitedViaApi.email); + expect(membershipCreatedViaApi.user.username).toEqual(teammateInvitedViaApi.username); expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id); userHasCorrectEventTypes(membershipCreatedViaApi.userId); }); @@ -265,6 +305,10 @@ describe("Teams Memberships Endpoints", () => { expect(responseBody.status).toEqual(SUCCESS_STATUS); membershipCreatedViaApi = responseBody.data; expect(membershipCreatedViaApi.role).toEqual("OWNER"); + expect(membershipCreatedViaApi.user.bio).toEqual(teammateInvitedViaApi.bio); + expect(membershipCreatedViaApi.user.metadata).toEqual(teammateInvitedViaApi.metadata); + expect(membershipCreatedViaApi.user.email).toEqual(teammateInvitedViaApi.email); + expect(membershipCreatedViaApi.user.username).toEqual(teammateInvitedViaApi.username); }); }); @@ -273,7 +317,7 @@ describe("Teams Memberships Endpoints", () => { .delete(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`) .expect(200) .then((response) => { - const responseBody: ApiSuccessResponse = response.body; + const responseBody: ApiSuccessResponse = response.body; expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id); }); diff --git a/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts b/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts index 67f19c6282..4616aef746 100644 --- a/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts +++ b/apps/api/v2/src/modules/teams/memberships/outputs/team-membership.output.ts @@ -1,7 +1,53 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { MembershipRole } from "@prisma/client"; -import { Expose } from "class-transformer"; -import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator"; +import { Expose, Transform, Type } from "class-transformer"; +import { IsBoolean, IsInt, IsObject, IsOptional, IsString, ValidateNested } from "class-validator"; + +class MembershipUserOutputDto { + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly avatarUrl?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly username?: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly name?: string; + + @IsBoolean() + @Expose() + @ApiProperty() + readonly email!: string; + + @IsOptional() + @IsString() + @Expose() + @ApiPropertyOptional() + readonly bio?: string; + + @ApiPropertyOptional({ + type: Object, + example: { key: "value" }, + }) + @IsObject() + @IsOptional() + @Expose() + @Transform( + // note(Lauris): added this transform because without it metadata is removed for some reason + ({ obj }: { obj: { metadata: Record | null | undefined } }) => { + return obj.metadata || undefined; + } + ) + metadata?: Record; +} export class TeamMembershipOutput { @IsInt() @@ -34,4 +80,10 @@ export class TeamMembershipOutput { @Expose() @ApiPropertyOptional() readonly disableImpersonation?: boolean; + + @ValidateNested() + @Type(() => MembershipUserOutputDto) + @Expose() + @ApiProperty({ type: MembershipUserOutputDto }) + user!: MembershipUserOutputDto; } diff --git a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts index e7004aa32a..53da740238 100644 --- a/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts +++ b/apps/api/v2/src/modules/teams/memberships/teams-memberships.repository.ts @@ -4,6 +4,17 @@ import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/cr import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input"; import { Injectable } from "@nestjs/common"; +import { Prisma } from "@calcom/prisma/client"; + +export const MembershipUserSelect: Prisma.UserSelect = { + username: true, + email: true, + avatarUrl: true, + name: true, + metadata: true, + bio: true, +}; + @Injectable() export class TeamsMembershipsRepository { constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} @@ -11,7 +22,7 @@ export class TeamsMembershipsRepository { async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) { return this.dbWrite.prisma.membership.create({ data: { ...data, teamId: teamId }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -20,7 +31,7 @@ export class TeamsMembershipsRepository { where: { teamId: teamId, }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, skip, take, }); @@ -32,7 +43,7 @@ export class TeamsMembershipsRepository { id: membershipId, teamId: teamId, }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, }); } @@ -63,7 +74,7 @@ export class TeamsMembershipsRepository { id: membershipId, teamId: teamId, }, - include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } }, + include: { user: { select: MembershipUserSelect } }, }); } } diff --git a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts index 27faa7940b..a274fad6ff 100644 --- a/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/create-managed-user.input.ts @@ -1,7 +1,9 @@ import { Locales } from "@/lib/enums/locales"; import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl } from "class-validator"; +import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl, IsObject } from "class-validator"; + +import { ValidateMetadata } from "@calcom/platform-types"; export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday"; export type TimeFormat = 12 | 24; @@ -51,4 +53,27 @@ export class CreateManagedUserInput { description: `URL of the user's avatar image`, }) avatarUrl?: string; + + @ApiPropertyOptional({ + type: String, + description: "Bio", + example: "I am a bio", + }) + @IsOptional() + @IsString() + bio?: string; + + @ApiPropertyOptional({ + type: Object, + description: + "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + example: { key: "value" }, + }) + @IsObject() + @IsOptional() + @ValidateMetadata({ + message: + "Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + }) + metadata?: Record; } diff --git a/apps/api/v2/src/modules/users/inputs/create-user.input.ts b/apps/api/v2/src/modules/users/inputs/create-user.input.ts index 03c7a27054..7bcd9d504c 100644 --- a/apps/api/v2/src/modules/users/inputs/create-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/create-user.input.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Expose, Transform } from "class-transformer"; import { IsBoolean, @@ -9,8 +9,11 @@ import { IsString, Validate, Min, + IsObject, } from "class-validator"; +import { ValidateMetadata } from "@calcom/platform-types"; + import { AvatarValidator } from "../validators/avatarValidator"; import { LocaleValidator } from "../validators/localeValidator"; import { ThemeValidator } from "../validators/themeValidator"; @@ -58,6 +61,37 @@ export class CreateUserInput { @Expose() brandColor?: string; + @ApiPropertyOptional({ + type: String, + description: "Bio", + example: "I am a bio", + }) + @IsOptional() + @IsString() + @Expose() + bio?: string; + + @ApiPropertyOptional({ + type: Object, + description: + "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + example: { key: "value" }, + }) + @IsObject() + @IsOptional() + @ValidateMetadata({ + message: + "Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + }) + @Expose() + @Transform( + // note(Lauris): added this transform because without it metadata is removed for some reason + ({ obj }: { obj: { metadata: Record | null | undefined } }) => { + return obj.metadata || undefined; + } + ) + metadata?: Record; + @ApiProperty({ type: String, required: false, diff --git a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts index c8643e308d..e1cb0fc064 100644 --- a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts @@ -2,7 +2,9 @@ import { Locales } from "@/lib/enums/locales"; import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone"; import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input"; import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator"; +import { IsEnum, IsIn, IsNumber, IsObject, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator"; + +import { ValidateMetadata } from "@calcom/platform-types"; export class UpdateManagedUserInput { @IsString() @@ -53,4 +55,27 @@ export class UpdateManagedUserInput { description: `URL of the user's avatar image`, }) avatarUrl?: string; + + @ApiPropertyOptional({ + type: String, + description: "Bio", + example: "I am a bio", + }) + @IsOptional() + @IsString() + bio?: string; + + @ApiPropertyOptional({ + type: Object, + description: + "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + example: { key: "value" }, + }) + @IsObject() + @IsOptional() + @ValidateMetadata({ + message: + "Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + }) + metadata?: Record; } diff --git a/apps/api/v2/src/modules/users/outputs/get-users.output.ts b/apps/api/v2/src/modules/users/outputs/get-users.output.ts index fdec53748b..8598fccb65 100644 --- a/apps/api/v2/src/modules/users/outputs/get-users.output.ts +++ b/apps/api/v2/src/modules/users/outputs/get-users.output.ts @@ -1,7 +1,16 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { Type } from "class-transformer"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform, Type } from "class-transformer"; import { Expose } from "class-transformer"; -import { IsBoolean, IsDateString, IsInt, IsString, ValidateNested, IsArray } from "class-validator"; +import { + IsBoolean, + IsDateString, + IsInt, + IsString, + ValidateNested, + IsArray, + IsObject, + IsOptional, +} from "class-validator"; export class GetUserOutput { @IsInt() @@ -223,6 +232,21 @@ export class GetUserOutput { example: 1, }) invitedTo!: number | null; + + @ApiPropertyOptional({ + type: Object, + example: { key: "value" }, + }) + @IsObject() + @IsOptional() + @Expose() + @Transform( + // note(Lauris): added this transform because without it metadata is removed for some reason + ({ obj }: { obj: { metadata: Record | null | undefined } }) => { + return obj.metadata || undefined; + } + ) + metadata?: Record; } export class GetUsersOutput { diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index d3cd4d22b3..9da64e7652 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -4106,14 +4106,6 @@ "schema": { "type": "string" } - }, - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "number" - } } ], "requestBody": { @@ -4283,6 +4275,246 @@ ] } }, + "/v2/organizations/{orgId}/users/{userId}/bookings": { + "get": { + "operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings", + "summary": "Get all bookings of an organization user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + "example": "?status=upcoming,past", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "upcoming", + "recurring", + "past", + "cancelled", + "unconfirmed" + ] + } + } + }, + { + "name": "attendeeEmail", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's email address.", + "example": "example@domain.com", + "schema": { + "type": "string" + } + }, + { + "name": "attendeeName", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's name.", + "example": "John Doe", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeIds", + "required": false, + "in": "query", + "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", + "example": "?eventTypeIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": false, + "in": "query", + "description": "Filter bookings by event type id belonging to the user.", + "example": "?eventTypeId=100", + "schema": { + "type": "string" + } + }, + { + "name": "teamsIds", + "required": false, + "in": "query", + "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", + "example": "?teamIds=50,60", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": false, + "in": "query", + "description": "Filter bookings by team id that user is part of", + "example": "?teamId=50", + "schema": { + "type": "string" + } + }, + { + "name": "afterStart", + "required": false, + "in": "query", + "description": "Filter bookings with start after this date string.", + "example": "?afterStart=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeEnd", + "required": false, + "in": "query", + "description": "Filter bookings with end before this date string.", + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "afterCreatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been created after this date string.", + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeCreatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been created before this date string.", + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "afterUpdatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been updated after this date string.", + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeUpdatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been updated before this date string.", + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortCreated", + "required": false, + "in": "query", + "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", + "example": "?sortCreated=asc OR ?sortCreated=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "sortUpdatedAt", + "required": false, + "in": "query", + "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", + "example": "?sortUpdated=asc OR ?sortUpdated=desc", + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Orgs / Users / Bookings" + ] + } + }, "/v2/organizations/{orgId}/users/{userId}/ooo": { "get": { "operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO", @@ -5367,7 +5599,7 @@ "post": { "operationId": "BookingsController_2024_08_13_createBooking", "summary": "Create a booking", - "description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n ", + "description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n ", "parameters": [ { "name": "cal-api-version", @@ -8476,6 +8708,14 @@ "example": "2050-09-06", "schema": {} }, + { + "name": "organizationSlug", + "required": false, + "in": "query", + "description": "The slug of the organization to which user with username belongs.", + "example": "org-slug", + "schema": {} + }, { "name": "username", "required": false, @@ -8488,7 +8728,7 @@ "name": "eventTypeSlug", "required": false, "in": "query", - "description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.", + "description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.", "example": "event-type-slug", "schema": {} }, @@ -9965,6 +10205,11 @@ "nullable": true, "example": "alice" }, + "bio": { + "type": "string", + "nullable": true, + "example": "bio" + }, "timeZone": { "type": "string", "example": "America/New_York" @@ -10040,6 +10285,12 @@ "nullable": true, "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", "description": "URL of the user's avatar image" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } } }, "required": [ @@ -10047,6 +10298,7 @@ "email", "username", "name", + "bio", "timeZone", "weekStart", "createdDate", @@ -10168,6 +10420,18 @@ "type": "string", "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", "description": "URL of the user's avatar image" + }, + "bio": { + "type": "string", + "description": "Bio", + "example": "I am a bio" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + "example": { + "key": "value" + } } }, "required": [ @@ -10336,6 +10600,18 @@ "type": "string", "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", "description": "URL of the user's avatar image" + }, + "bio": { + "type": "string", + "description": "Bio", + "example": "I am a bio" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + "example": { + "key": "value" + } } } }, @@ -15248,6 +15524,12 @@ "description": "The ID of the user who invited this user", "example": 1 }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, "profile": { "description": "organization user profile, contains user data within the organizaton context", "allOf": [ @@ -15313,6 +15595,18 @@ "description": "Brand color in HEX format", "example": "#FFFFFF" }, + "bio": { + "type": "string", + "description": "Bio", + "example": "I am a bio" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + "example": { + "key": "value" + } + }, "darkBrandColor": { "type": "string", "description": "Dark brand color in HEX format", @@ -15405,7 +15699,36 @@ "type": "object", "properties": {} }, - "OrgMembershipOutputDto": { + "MembershipUserOutputDto": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "username": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + } + }, + "required": [ + "email" + ] + }, + "TeamMembershipOutput": { "type": "object", "properties": { "id": { @@ -15430,6 +15753,9 @@ }, "disableImpersonation": { "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/MembershipUserOutputDto" } }, "required": [ @@ -15437,7 +15763,8 @@ "userId", "teamId", "accepted", - "role" + "role", + "user" ] }, "GetAllOrgMemberships": { @@ -15452,7 +15779,7 @@ ] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": [ @@ -15502,7 +15829,7 @@ ] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": [ @@ -15522,7 +15849,7 @@ ] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": [ @@ -15542,7 +15869,7 @@ ] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": [ @@ -15581,7 +15908,7 @@ ] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": [ @@ -16792,65 +17119,6 @@ "data" ] }, - "MembershipUserOutputDto": { - "type": "object", - "properties": { - "avatarUrl": { - "type": "string" - }, - "username": { - "type": "string" - }, - "name": { - "type": "string" - }, - "email": { - "type": "string" - } - }, - "required": [ - "email" - ] - }, - "OrgTeamMembershipOutputDto": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "userId": { - "type": "number" - }, - "teamId": { - "type": "number" - }, - "accepted": { - "type": "boolean" - }, - "role": { - "type": "string", - "enum": [ - "MEMBER", - "OWNER", - "ADMIN" - ] - }, - "disableImpersonation": { - "type": "boolean" - }, - "user": { - "$ref": "#/components/schemas/MembershipUserOutputDto" - } - }, - "required": [ - "id", - "userId", - "teamId", - "accepted", - "role", - "user" - ] - }, "OrgTeamMembershipsOutputResponseDto": { "type": "object", "properties": { @@ -16865,7 +17133,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } } }, @@ -16886,7 +17154,7 @@ ] }, "data": { - "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": [ @@ -19256,9 +19524,24 @@ }, "eventTypeId": { "type": "number", - "description": "The ID of the event type that is booked.", + "description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", "example": 123 }, + "eventTypeSlug": { + "type": "string", + "description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + "example": "my-event-type" + }, + "username": { + "type": "string", + "description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + "example": "john-doe" + }, + "organizationSlug": { + "type": "string", + "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + "example": "acme-corp" + }, "attendee": { "description": "The attendee's details.", "allOf": [ @@ -19330,7 +19613,6 @@ }, "required": [ "start", - "eventTypeId", "attendee" ] }, @@ -19349,9 +19631,24 @@ }, "eventTypeId": { "type": "number", - "description": "The ID of the event type that is booked.", + "description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", "example": 123 }, + "eventTypeSlug": { + "type": "string", + "description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + "example": "my-event-type" + }, + "username": { + "type": "string", + "description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + "example": "john-doe" + }, + "organizationSlug": { + "type": "string", + "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + "example": "acme-corp" + }, "attendee": { "description": "The attendee's details.", "allOf": [ @@ -19428,7 +19725,6 @@ }, "required": [ "start", - "eventTypeId", "attendee", "instant" ] @@ -19448,9 +19744,24 @@ }, "eventTypeId": { "type": "number", - "description": "The ID of the event type that is booked.", + "description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", "example": 123 }, + "eventTypeSlug": { + "type": "string", + "description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + "example": "my-event-type" + }, + "username": { + "type": "string", + "description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + "example": "john-doe" + }, + "organizationSlug": { + "type": "string", + "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + "example": "acme-corp" + }, "attendee": { "description": "The attendee's details.", "allOf": [ @@ -19527,7 +19838,6 @@ }, "required": [ "start", - "eventTypeId", "attendee" ] }, @@ -21035,41 +21345,6 @@ "userId" ] }, - "TeamMembershipOutput": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "userId": { - "type": "number" - }, - "teamId": { - "type": "number" - }, - "accepted": { - "type": "boolean" - }, - "role": { - "type": "string", - "enum": [ - "MEMBER", - "OWNER", - "ADMIN" - ] - }, - "disableImpersonation": { - "type": "boolean" - } - }, - "required": [ - "id", - "userId", - "teamId", - "accepted", - "role" - ] - }, "CreateTeamMembershipOutput": { "type": "object", "properties": { diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 26718e9d67..634bbfa9f5 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -3951,14 +3951,6 @@ "schema": { "type": "string" } - }, - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "number" - } } ], "requestBody": { @@ -4122,6 +4114,226 @@ "tags": ["Orgs / Users"] } }, + "/v2/organizations/{orgId}/users/{userId}/bookings": { + "get": { + "operationId": "OrganizationsUsersBookingsController_getOrganizationUserBookings", + "summary": "Get all bookings of an organization user", + "parameters": [ + { + "name": "userId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "status", + "required": false, + "in": "query", + "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", + "example": "?status=upcoming,past", + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] + } + } + }, + { + "name": "attendeeEmail", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's email address.", + "example": "example@domain.com", + "schema": { + "type": "string" + } + }, + { + "name": "attendeeName", + "required": false, + "in": "query", + "description": "Filter bookings by the attendee's name.", + "example": "John Doe", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeIds", + "required": false, + "in": "query", + "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", + "example": "?eventTypeIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "eventTypeId", + "required": false, + "in": "query", + "description": "Filter bookings by event type id belonging to the user.", + "example": "?eventTypeId=100", + "schema": { + "type": "string" + } + }, + { + "name": "teamsIds", + "required": false, + "in": "query", + "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", + "example": "?teamIds=50,60", + "schema": { + "type": "string" + } + }, + { + "name": "teamId", + "required": false, + "in": "query", + "description": "Filter bookings by team id that user is part of", + "example": "?teamId=50", + "schema": { + "type": "string" + } + }, + { + "name": "afterStart", + "required": false, + "in": "query", + "description": "Filter bookings with start after this date string.", + "example": "?afterStart=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeEnd", + "required": false, + "in": "query", + "description": "Filter bookings with end before this date string.", + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "afterCreatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been created after this date string.", + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeCreatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been created before this date string.", + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "afterUpdatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been updated after this date string.", + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "beforeUpdatedAt", + "required": false, + "in": "query", + "description": "Filter bookings that have been updated before this date string.", + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", + "schema": { + "type": "string" + } + }, + { + "name": "sortStart", + "required": false, + "in": "query", + "description": "Sort results by their start time in ascending or descending order.", + "example": "?sortStart=asc OR ?sortStart=desc", + "schema": { + "enum": ["asc", "desc"], + "type": "string" + } + }, + { + "name": "sortEnd", + "required": false, + "in": "query", + "description": "Sort results by their end time in ascending or descending order.", + "example": "?sortEnd=asc OR ?sortEnd=desc", + "schema": { + "enum": ["asc", "desc"], + "type": "string" + } + }, + { + "name": "sortCreated", + "required": false, + "in": "query", + "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", + "example": "?sortCreated=asc OR ?sortCreated=desc", + "schema": { + "enum": ["asc", "desc"], + "type": "string" + } + }, + { + "name": "sortUpdatedAt", + "required": false, + "in": "query", + "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", + "example": "?sortUpdated=asc OR ?sortUpdated=desc", + "schema": { + "enum": ["asc", "desc"], + "type": "string" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": ["Orgs / Users / Bookings"] + } + }, "/v2/organizations/{orgId}/users/{userId}/ooo": { "get": { "operationId": "OrganizationsUsersOOOController_getOrganizationUserOOO", @@ -5162,7 +5374,7 @@ "post": { "operationId": "BookingsController_2024_08_13_createBooking", "summary": "Create a booking", - "description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n ", + "description": "\n POST /v2/bookings is used to create regular bookings, recurring bookings and instant bookings. The request bodies for all 3 are almost the same except:\n If eventTypeId in the request body is id of a regular event, then regular booking is created.\n\n If it is an id of a recurring event type, then recurring booking is created.\n\n Meaning that the request bodies are equal but the outcome depends on what kind of event type it is with the goal of making it as seamless for developers as possible.\n\n For team event types it is possible to create instant meeting. To do that just pass `\"instant\": true` to the request body.\n\n The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.\n\n Finally, there are 2 ways to book an event type:\n 1. Provide `eventTypeId` in the request body.\n 2. Provide `eventTypeSlug` and `username` and optionally `organizationSlug` if the user with the username is within an organization.\n ", "parameters": [ { "name": "cal-api-version", @@ -8081,6 +8293,14 @@ "example": "2050-09-06", "schema": {} }, + { + "name": "organizationSlug", + "required": false, + "in": "query", + "description": "The slug of the organization to which user with username belongs.", + "example": "org-slug", + "schema": {} + }, { "name": "username", "required": false, @@ -8093,7 +8313,7 @@ "name": "eventTypeSlug", "required": false, "in": "query", - "description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too.", + "description": "The slug of the event type for which available slots should be checked. If slug is provided then username must be provided too and if relevant organizationSlug too.", "example": "event-type-slug", "schema": {} }, @@ -9510,6 +9730,11 @@ "nullable": true, "example": "alice" }, + "bio": { + "type": "string", + "nullable": true, + "example": "bio" + }, "timeZone": { "type": "string", "example": "America/New_York" @@ -9585,6 +9810,12 @@ "nullable": true, "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", "description": "URL of the user's avatar image" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } } }, "required": [ @@ -9592,6 +9823,7 @@ "email", "username", "name", + "bio", "timeZone", "weekStart", "createdDate", @@ -9696,6 +9928,18 @@ "type": "string", "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", "description": "URL of the user's avatar image" + }, + "bio": { + "type": "string", + "description": "Bio", + "example": "I am a bio" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + "example": { + "key": "value" + } } }, "required": ["email", "name"] @@ -9832,6 +10076,18 @@ "type": "string", "example": "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", "description": "URL of the user's avatar image" + }, + "bio": { + "type": "string", + "description": "Bio", + "example": "I am a bio" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + "example": { + "key": "value" + } } } }, @@ -14101,6 +14357,12 @@ "description": "The ID of the user who invited this user", "example": 1 }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + }, "profile": { "description": "organization user profile, contains user data within the organizaton context", "allOf": [ @@ -14152,6 +14414,18 @@ "description": "Brand color in HEX format", "example": "#FFFFFF" }, + "bio": { + "type": "string", + "description": "Bio", + "example": "I am a bio" + }, + "metadata": { + "type": "object", + "description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and values up to 500 characters.", + "example": { + "key": "value" + } + }, "darkBrandColor": { "type": "string", "description": "Dark brand color in HEX format", @@ -14232,7 +14506,34 @@ "type": "object", "properties": {} }, - "OrgMembershipOutputDto": { + "MembershipUserOutputDto": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "username": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "metadata": { + "type": "object", + "example": { + "key": "value" + } + } + }, + "required": ["email"] + }, + "TeamMembershipOutput": { "type": "object", "properties": { "id": { @@ -14253,9 +14554,12 @@ }, "disableImpersonation": { "type": "boolean" + }, + "user": { + "$ref": "#/components/schemas/MembershipUserOutputDto" } }, - "required": ["id", "userId", "teamId", "accepted", "role"] + "required": ["id", "userId", "teamId", "accepted", "role", "user"] }, "GetAllOrgMemberships": { "type": "object", @@ -14266,7 +14570,7 @@ "enum": ["success", "error"] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": ["status", "data"] @@ -14303,7 +14607,7 @@ "enum": ["success", "error"] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": ["status", "data"] @@ -14317,7 +14621,7 @@ "enum": ["success", "error"] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": ["status", "data"] @@ -14331,7 +14635,7 @@ "enum": ["success", "error"] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": ["status", "data"] @@ -14360,7 +14664,7 @@ "enum": ["success", "error"] }, "data": { - "$ref": "#/components/schemas/OrgMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": ["status", "data"] @@ -15477,52 +15781,6 @@ }, "required": ["status", "data"] }, - "MembershipUserOutputDto": { - "type": "object", - "properties": { - "avatarUrl": { - "type": "string" - }, - "username": { - "type": "string" - }, - "name": { - "type": "string" - }, - "email": { - "type": "string" - } - }, - "required": ["email"] - }, - "OrgTeamMembershipOutputDto": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "userId": { - "type": "number" - }, - "teamId": { - "type": "number" - }, - "accepted": { - "type": "boolean" - }, - "role": { - "type": "string", - "enum": ["MEMBER", "OWNER", "ADMIN"] - }, - "disableImpersonation": { - "type": "boolean" - }, - "user": { - "$ref": "#/components/schemas/MembershipUserOutputDto" - } - }, - "required": ["id", "userId", "teamId", "accepted", "role", "user"] - }, "OrgTeamMembershipsOutputResponseDto": { "type": "object", "properties": { @@ -15534,7 +15792,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } } }, @@ -15549,7 +15807,7 @@ "enum": ["success", "error"] }, "data": { - "$ref": "#/components/schemas/OrgTeamMembershipOutputDto" + "$ref": "#/components/schemas/TeamMembershipOutput" } }, "required": ["status", "data"] @@ -17535,9 +17793,24 @@ }, "eventTypeId": { "type": "number", - "description": "The ID of the event type that is booked.", + "description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", "example": 123 }, + "eventTypeSlug": { + "type": "string", + "description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + "example": "my-event-type" + }, + "username": { + "type": "string", + "description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + "example": "john-doe" + }, + "organizationSlug": { + "type": "string", + "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + "example": "acme-corp" + }, "attendee": { "description": "The attendee's details.", "allOf": [ @@ -17604,7 +17877,7 @@ } } }, - "required": ["start", "eventTypeId", "attendee"] + "required": ["start", "attendee"] }, "CreateInstantBookingInput_2024_08_13": { "type": "object", @@ -17621,9 +17894,24 @@ }, "eventTypeId": { "type": "number", - "description": "The ID of the event type that is booked.", + "description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", "example": 123 }, + "eventTypeSlug": { + "type": "string", + "description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + "example": "my-event-type" + }, + "username": { + "type": "string", + "description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + "example": "john-doe" + }, + "organizationSlug": { + "type": "string", + "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + "example": "acme-corp" + }, "attendee": { "description": "The attendee's details.", "allOf": [ @@ -17695,7 +17983,7 @@ "example": true } }, - "required": ["start", "eventTypeId", "attendee", "instant"] + "required": ["start", "attendee", "instant"] }, "CreateRecurringBookingInput_2024_08_13": { "type": "object", @@ -17712,9 +18000,24 @@ }, "eventTypeId": { "type": "number", - "description": "The ID of the event type that is booked.", + "description": "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", "example": 123 }, + "eventTypeSlug": { + "type": "string", + "description": "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + "example": "my-event-type" + }, + "username": { + "type": "string", + "description": "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + "example": "john-doe" + }, + "organizationSlug": { + "type": "string", + "description": "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + "example": "acme-corp" + }, "attendee": { "description": "The attendee's details.", "allOf": [ @@ -17786,7 +18089,7 @@ "example": 5 } }, - "required": ["start", "eventTypeId", "attendee"] + "required": ["start", "attendee"] }, "BookingHost": { "type": "object", @@ -19175,31 +19478,6 @@ }, "required": ["userId"] }, - "TeamMembershipOutput": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "userId": { - "type": "number" - }, - "teamId": { - "type": "number" - }, - "accepted": { - "type": "boolean" - }, - "role": { - "type": "string", - "enum": ["MEMBER", "OWNER", "ADMIN"] - }, - "disableImpersonation": { - "type": "boolean" - } - }, - "required": ["id", "userId", "teamId", "accepted", "role"] - }, "CreateTeamMembershipOutput": { "type": "object", "properties": { diff --git a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts index 49ddd6a53d..20fc334e86 100644 --- a/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts +++ b/packages/platform/types/bookings/2024-08-13/inputs/create-booking.input.ts @@ -36,6 +36,34 @@ import { } from "./location.input"; import { ValidateMetadata } from "./validators/validate-metadata"; +function RequireEventTypeIdentification(validationOptions?: ValidationOptions) { + return function (object: any) { + registerDecorator({ + name: "requireEventTypeIdentification", + target: object, + propertyName: "eventTypeId or eventTypeSlug + username", + options: validationOptions, + constraints: [], + validator: { + validate(_value: any, args: ValidationArguments) { + const obj = args.object as CreateBookingInput_2024_08_13; + + // Check if eventTypeId is provided + const hasEventTypeId = !!obj?.eventTypeId; + + // Check if we have both eventTypeSlug and username + const hasSlugAndUsername = !!obj?.eventTypeSlug && !!obj?.username; + + return hasEventTypeId || hasSlugAndUsername; + }, + defaultMessage(): string { + return "Either eventTypeId OR (eventTypeSlug + username) must be provided"; + }, + }, + }); + }; +} + function RequireEmailOrPhone(validationOptions?: ValidationOptions) { return function (target: object, propertyName: string) { registerDecorator({ @@ -121,6 +149,7 @@ class Attendee { BookingInputOrganizersDefaultAppLocation_2024_08_13, ValidateBookingLocation_2024_08_13 ) +@RequireEventTypeIdentification() export class CreateBookingInput_2024_08_13 { @ApiProperty({ type: String, @@ -140,13 +169,44 @@ export class CreateBookingInput_2024_08_13 { }) lengthInMinutes?: number; - @ApiProperty({ + @ApiPropertyOptional({ type: Number, - description: "The ID of the event type that is booked.", + description: + "The ID of the event type that is booked. Required unless eventTypeSlug and username are provided as an alternative to identifying the event type.", example: 123, }) + @IsOptional() @IsInt() - eventTypeId!: number; + eventTypeId?: number; + + @ApiPropertyOptional({ + type: String, + description: + "The slug of the event type. Required along with username and optionally organizationSlug if eventTypeId is not provided.", + example: "my-event-type", + }) + @IsOptional() + @IsString() + eventTypeSlug?: string; + + @ApiPropertyOptional({ + type: String, + description: + "The username of the event owner. Required along with eventTypeSlug and optionally organizationSlug if eventTypeId is not provided.", + example: "john-doe", + }) + @IsOptional() + @IsString() + username?: string; + + @ApiPropertyOptional({ + type: String, + description: "The organization slug. Optional, only used when booking with eventTypeSlug + username.", + example: "acme-corp", + }) + @IsOptional() + @IsString() + organizationSlug?: string; @ApiProperty({ type: Attendee,