diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 257825184d..5ff6cee6ff 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.168", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.171", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 401375b111..cf4d0ace16 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -17,7 +17,8 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; -import { UsersRepository } from "@/modules/users/users.repository"; +import { UsersService } from "@/modules/users/services/users.service"; +import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository"; import { Controller, Post, @@ -36,7 +37,6 @@ import { } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; -import { User } from "@prisma/client"; import { CreationSource } from "@prisma/client"; import { Request } from "express"; import { NextApiRequest } from "next/types"; @@ -104,7 +104,8 @@ export class BookingsController_2024_04_15 { private readonly config: ConfigService, private readonly apiKeyRepository: ApiKeysRepository, private readonly platformBookingsService: PlatformBookingsService, - private readonly usersRepository: UsersRepository + private readonly usersRepository: UsersRepository, + private readonly usersService: UsersService ) {} @Get("/") @@ -114,18 +115,19 @@ export class BookingsController_2024_04_15 { @ApiQuery({ name: "limit", type: "number", required: false }) @ApiQuery({ name: "cursor", type: "number", required: false }) async getBookings( - @GetUser() user: User, + @GetUser() user: UserWithProfile, @Query() queryParams: GetBookingsInput_2024_04_15 ): Promise { const { filters, cursor, limit } = queryParams; const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"]; + const profile = this.usersService.getUserMainProfile(user); const bookings = await getAllUserBookings({ bookingListingByStatus: [bookingListingByStatus], skip: cursor ?? 0, take: limit ?? 10, filters, ctx: { - user: { email: user.email, id: user.id }, + user: { email: user.email, id: user.id, orgId: profile?.organizationId }, prisma: this.prismaReadService.prisma as unknown as PrismaClient, }, }); 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 c25034f760..90d122dd19 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 @@ -12,6 +12,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { UsersService } from "@/modules/users/services/users.service"; import { UserWithProfile } from "@/modules/users/users.repository"; import { Controller, @@ -34,7 +35,6 @@ import { ApiBody, ApiExtraModels, } from "@nestjs/swagger"; -import { User } from "@prisma/client"; import { Request } from "express"; import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; @@ -80,7 +80,10 @@ import { export class BookingsController_2024_08_13 { private readonly logger = new Logger("BookingsController_2024_08_13"); - constructor(private readonly bookingsService: BookingsService_2024_08_13) {} + constructor( + private readonly bookingsService: BookingsService_2024_08_13, + private readonly usersService: UsersService + ) {} @Post("/") @ApiOperation({ @@ -161,9 +164,15 @@ export class BookingsController_2024_08_13 { @ApiOperation({ summary: "Get all bookings" }) async getBookings( @Query() queryParams: GetBookingsInput_2024_08_13, - @GetUser() user: User + @GetUser() user: UserWithProfile ): Promise { - const bookings = await this.bookingsService.getBookings(queryParams, user); + const profile = this.usersService.getUserMainProfile(user); + + const bookings = await this.bookingsService.getBookings(queryParams, { + email: user.email, + id: user.id, + orgId: profile?.organizationId, + }); return { status: SUCCESS_STATUS, diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts index 4859cc8c50..6e5135a822 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/team-bookings.e2e-spec.ts @@ -591,18 +591,7 @@ describe("Bookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`) .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(0); - }); + .expect(400); }); it("should get bookings by teamIds", async () => { 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 1475eaedda..7bdc5e19ef 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 @@ -265,7 +265,11 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputRecurringBookings(ids); } - async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) { + async getBookings( + queryParams: GetBookingsInput_2024_08_13, + user: { email: string; id: number; orgId?: number }, + userIds?: number[] + ) { if (queryParams.attendeeEmail) { queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user); } @@ -274,7 +278,10 @@ export class BookingsService_2024_08_13 { bookingListingByStatus: queryParams.status || [], skip: queryParams.skip ?? 0, take: queryParams.take ?? 100, - filters: this.inputService.transformGetBookingsFilters(queryParams), + filters: { + ...this.inputService.transformGetBookingsFilters(queryParams), + ...(userIds?.length ? { userIds } : {}), + }, ctx: { user, prisma: this.prismaReadService.prisma as unknown as PrismaClient, diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index ce785ff5a5..64e76f5bec 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -4,6 +4,7 @@ import { BillingModule } from "@/modules/billing/billing.module"; import { ConferencingModule } from "@/modules/conferencing/conferencing.module"; import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module"; 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 { RouterModule } from "@/modules/router/router.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; @@ -27,6 +28,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; StripeModule, ConferencingModule, OrganizationsTeamsBookingsModule, + OrganizationsBookingsModule, RouterModule, ], }) diff --git a/apps/api/v2/src/modules/organizations/bookings/inputs/get-org-bookings.input.ts b/apps/api/v2/src/modules/organizations/bookings/inputs/get-org-bookings.input.ts new file mode 100644 index 0000000000..7aa1a9ed44 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/bookings/inputs/get-org-bookings.input.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { ArrayMinSize, IsArray, IsNumber, IsOptional } from "class-validator"; + +import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types"; + +export class GetOrganizationsBookingsInput extends GetBookingsInput_2024_08_13 { + @IsArray() + @IsOptional() + @Transform(({ value }) => { + if (typeof value === "string") { + return value.split(",").map((userId: string) => parseInt(userId)); + } + return value; + }) + @IsNumber({}, { each: true }) + @ArrayMinSize(1, { message: "userIds must contain at least 1 user id" }) + @ApiProperty({ + type: String, + required: false, + description: "Filter bookings by ids of users within your organization.", + example: "?userIds=100,200", + }) + userIds?: number[]; +} diff --git a/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.e2e-spec.ts new file mode 100644 index 0000000000..a27296a5a8 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.e2e-spec.ts @@ -0,0 +1,703 @@ +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 { withApiAuth } from "test/utils/withApiAuth"; + +import { + CAL_API_VERSION_HEADER, + SUCCESS_STATUS, + VERSION_2024_08_13, + X_CAL_CLIENT_ID, + X_CAL_SECRET_KEY, +} 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 Bookings Endpoints 2024-08-13", () => { + describe("Organization bookings", () => { + let app: INestApplication; + let organization: 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 orgUserEmail = "org-user-1-bookings@api.com"; + const orgUserEmail2 = "org-user-2-bookings@api.com"; + const nonOrgUserEmail1 = "non-org-user-1-bookings@api.com"; + let orgUser: User; + let orgUser2: User; + let nonOrgUser1: User; + let team1: Team; + + let orgEventTypeId: number; + let orgEventTypeId2: number; + let nonOrgEventTypeId: number; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + orgUserEmail2, + 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: "organization bookings" }); + team1 = await teamRepositoryFixture.create({ + name: "team orgs booking 1", + isOrganization: false, + parent: { connect: { id: organization.id } }, + }); + oAuthClient = await createOAuthClient(organization.id); + + nonOrgUser1 = await userRepositoryFixture.create({ + email: nonOrgUserEmail1, + locale: "it", + name: "NonOrgUser1Bookings", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + orgUser = await userRepositoryFixture.create({ + email: orgUserEmail, + locale: "it", + name: "orgUser1Bookings", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + orgUser2 = await userRepositoryFixture.create({ + email: orgUserEmail2, + locale: "es", + name: "orgUser2Bookings", + platformOAuthClients: { + connect: { + id: oAuthClient.id, + }, + }, + }); + + const userSchedule: CreateScheduleInput_2024_04_15 = { + name: "working time", + timeZone: "Europe/Rome", + isDefault: true, + }; + await schedulesService.createUserSchedule(orgUser.id, userSchedule); + await schedulesService.createUserSchedule(orgUser2.id, userSchedule); + await schedulesService.createUserSchedule(nonOrgUser1.id, userSchedule); + + const orgEventType = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "COLLECTIVE", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "org-bookings-collective-event-type", + length: 60, + assignAllTeamMembers: true, + bookingFields: [], + locations: [], + }); + + const orgEventType2 = await eventTypesRepositoryFixture.createTeamEventType({ + schedulingType: "ROUND_ROBIN", + team: { + connect: { id: team1.id }, + }, + title: "Collective Event Type", + slug: "org-bookings-round-robin-event-type", + length: 60, + assignAllTeamMembers: false, + bookingFields: [], + locations: [], + }); + + orgEventTypeId2 = orgEventType2.id; + + await profileRepositoryFixture.create({ + uid: `usr-${orgUser.id}`, + username: orgUserEmail, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: orgUser.id, + }, + }, + }); + + await profileRepositoryFixture.create({ + uid: `usr-${orgUser2.id}`, + username: orgUserEmail2, + organization: { + connect: { + id: organization.id, + }, + }, + user: { + connect: { + id: orgUser2.id, + }, + }, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: orgUser.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: orgUser2.id } }, + team: { connect: { id: organization.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: orgUser.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + await membershipsRepositoryFixture.create({ + role: "OWNER", + user: { connect: { id: orgUser2.id } }, + team: { connect: { id: team1.id } }, + accepted: true, + }); + + const nonOrgEventType = await eventTypesRepositoryFixture.create( + { + title: "Non Org Event Type", + slug: "non-org-event-type", + length: 60, + bookingFields: [], + locations: [], + }, + nonOrgUser1.id + ); + + orgEventTypeId = orgEventType.id; + nonOrgEventTypeId = nonOrgEventType.id; + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: orgUser.id, + }, + }, + eventType: { + connect: { + id: orgEventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: true, + user: { + connect: { + id: orgUser2.id, + }, + }, + eventType: { + connect: { + id: orgEventType.id, + }, + }, + }); + + await hostsRepositoryFixture.create({ + isFixed: false, + user: { + connect: { + id: orgUser2.id, + }, + }, + eventType: { + connect: { + id: orgEventType2.id, + }, + }, + }); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + describe("create organization bookings", () => { + it("should create an collective organization booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)).toISOString(), + eventTypeId: orgEventTypeId, + 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(orgUser.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(orgEventTypeId); + expect(data.attendees.length).toEqual(2); + 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); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a round robin organization booking", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(), + eventTypeId: orgEventTypeId2, + 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(orgUser2.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 10, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(orgEventTypeId2); + 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); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a non organization booking for org-user-1", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(), + eventTypeId: nonOrgEventTypeId, + attendee: { + name: orgUser.name ?? "", + email: orgUserEmail, + timeZone: orgUser.timeZone ?? "Europe/Madrid", + language: "en", + }, + 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(nonOrgUser1.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(nonOrgEventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: orgUserEmail, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + + it("should create a non organization booking for org-user-2", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(), + eventTypeId: nonOrgEventTypeId, + attendee: { + name: orgUser2.name ?? "", + email: orgUserEmail2, + timeZone: orgUser2.timeZone ?? "Europe/Madrid", + language: "en", + }, + 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(nonOrgUser1.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 11, 14, 0, 0)).toISOString()); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(nonOrgEventTypeId); + expect(data.attendees.length).toEqual(1); + expect(data.attendees[0]).toEqual({ + name: body.attendee.name, + email: orgUserEmail2, + timeZone: body.attendee.timeZone, + language: body.attendee.language, + absent: false, + }); + expect(data.meetingUrl).toEqual(body.meetingUrl); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected booking but received array of possibly recurring bookings" + ); + } + }); + }); + }); + + describe("get organization bookings", () => { + it("should get bookings by organizationId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .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(4); + expect(data.map((booking) => booking.eventTypeId).sort()).toEqual( + [orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort() + ); + }); + }); + + it("should get bookings by organizationId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .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(2); + expect(data.map((booking) => booking.eventTypeId).sort()).toEqual( + [orgEventTypeId, nonOrgEventTypeId].sort() + ); + }); + }); + + it("should get bookings by organizationId and userIds", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id},${orgUser2.id}&skip=0&take=250` + ) + .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(4); + expect(data.map((booking) => booking.eventTypeId).sort()).toEqual( + [orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort() + ); + }); + }); + + it("should get bookings by organizationId and userId", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser2.id}`) + .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.map((booking) => booking.eventTypeId).sort()).toEqual( + [orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId].sort() + ); + }); + }); + + it("should fail to get bookings by organizationId and Id of a user that does not exist", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings?userIds=972930`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(400); + }); + + it("should fail to get bookings by organizationId and Id of a user that does not belong to the org", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings?userIds=${nonOrgUser1.id}`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(403); + }); + + it("should get bookings by organizationId and non org event-type id", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${nonOrgEventTypeId}`) + .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(2); + expect(data.map((booking) => booking.eventTypeId).sort()).toEqual( + [nonOrgEventTypeId, nonOrgEventTypeId].sort() + ); + }); + }); + + it("should get bookings by organizationId and org event-type id", async () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId}`) + .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(1); + expect(data.map((booking) => booking.eventTypeId).sort()).toEqual([orgEventTypeId].sort()); + }); + }); + + it("should get bookings by organizationId and org + non org event-type ids", async () => { + return request(app.getHttpServer()) + .get( + `/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId2},${nonOrgEventTypeId}` + ) + .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.map((booking) => booking.eventTypeId).sort()).toEqual( + [orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort() + ); + }); + }); + }); + + 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(orgUser.email); + await userRepositoryFixture.deleteByEmail(orgUser2.email); + await userRepositoryFixture.deleteByEmail(nonOrgUser1.email); + await bookingsRepositoryFixture.deleteAllBookings(orgUser.id, orgUser.email); + await bookingsRepositoryFixture.deleteAllBookings(orgUser2.id, orgUser2.email); + await bookingsRepositoryFixture.deleteAllBookings(nonOrgUser1.id, nonOrgUser1.email); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts b/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts new file mode 100644 index 0000000000..3a781b107f --- /dev/null +++ b/apps/api/v2/src/modules/organizations/bookings/organizations-bookings.controller.ts @@ -0,0 +1,56 @@ +import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service"; +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 { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common"; +import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types"; + +import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input"; + +@Controller({ + path: "/v2/organizations/:orgId/bookings", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Orgs / Bookings") +export class OrganizationsBookingsController { + constructor( + private readonly bookingsService: BookingsService_2024_08_13, + private readonly orgUsersService: OrganizationsUsersService + ) {} + + @Get("/") + @ApiOperation({ summary: "Get organization team bookings" }) + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @HttpCode(HttpStatus.OK) + async getAllOrgTeamBookings( + @Query() queryParams: GetOrganizationsBookingsInput, + @Param("orgId", ParseIntPipe) orgId: number, + @GetUser() user: UserWithProfile + ): Promise { + const { userIds, ...restParams } = queryParams; + + const bookings = await this.bookingsService.getBookings( + { ...restParams }, + { email: user.email, id: user.id, orgId }, + userIds + ); + + return { + status: SUCCESS_STATUS, + data: bookings, + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/bookings/organizations.bookings.module.ts b/apps/api/v2/src/modules/organizations/bookings/organizations.bookings.module.ts new file mode 100644 index 0000000000..6b74863771 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/bookings/organizations.bookings.module.ts @@ -0,0 +1,23 @@ +import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module"; +import { MembershipsModule } from "@/modules/memberships/memberships.module"; +import { OrganizationsUsersRepository } from "@/modules/organizations//users/index/organizations-users.repository"; +import { OrganizationsBookingsController } from "@/modules/organizations/bookings/organizations-bookings.controller"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository"; +import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisModule } from "@/modules/redis/redis.module"; +import { StripeModule } from "@/modules/stripe/stripe.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule], + providers: [ + OrganizationsRepository, + OrganizationsTeamsRepository, + OrganizationsUsersService, + OrganizationsUsersRepository, + ], + controllers: [OrganizationsBookingsController], +}) +export class OrganizationsBookingsModule {} diff --git a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts index 95f0f7f3c3..bcb6e2e9cd 100644 --- a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.e2e-spec.ts @@ -313,18 +313,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => { return request(app.getHttpServer()) .get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?eventTypeId=90909`) .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(0); - }); + .expect(400); }); it("should not get bookings by non existing teamId", async () => { diff --git a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts index d73776b29d..20072f7568 100644 --- a/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts +++ b/apps/api/v2/src/modules/organizations/teams/bookings/organizations-teams-bookings.controller.ts @@ -43,9 +43,13 @@ export class OrganizationsTeamsBookingsController { async getAllOrgTeamBookings( @Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13, @Param("teamId", ParseIntPipe) teamId: number, + @Param("orgId", ParseIntPipe) orgId: number, @GetUser() user: UserWithProfile ): Promise { - const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user); + const bookings = await this.bookingsService.getBookings( + { ...queryParams, teamId }, + { email: user.email, id: user.id, orgId } + ); return { status: SUCCESS_STATUS, 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 7727fc77d6..f26bd71fc8 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 @@ -37,6 +37,22 @@ export class OrganizationsUsersRepository { }); } + async getOrganizationUsersByIds(orgId: number, userIds: number[]) { + return await this.dbRead.prisma.user.findMany({ + where: { + profiles: { + some: { + organizationId: orgId, + userId: { in: userIds }, + }, + }, + }, + include: { + profiles: true, + }, + }); + } + async getOrganizationUserByEmail(orgId: number, email: string) { return await this.dbRead.prisma.user.findFirst({ where: { diff --git a/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts b/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts index 4c4e9c7afa..1684b2239c 100644 --- a/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts +++ b/apps/api/v2/src/modules/organizations/users/index/services/organizations-users-service.ts @@ -1,10 +1,9 @@ import { EmailService } from "@/modules/email/email.service"; -import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/services/organizations-teams.service"; 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 { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository"; import { CreateUserInput } from "@/modules/users/inputs/create-user.input"; -import { Injectable, ConflictException } from "@nestjs/common"; +import { Injectable, ConflictException, ForbiddenException } from "@nestjs/common"; import { Team, CreationSource } from "@prisma/client"; import { plainToInstance } from "class-transformer"; @@ -14,7 +13,6 @@ import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries"; export class OrganizationsUsersService { constructor( private readonly organizationsUsersRepository: OrganizationsUsersRepository, - private readonly organizationsTeamsService: OrganizationsTeamsService, private readonly emailService: EmailService ) {} @@ -118,4 +116,14 @@ export class OrganizationsUsersService { if (isUsernameTaken) throw new ConflictException("Username is already taken"); } + + async getUsersByIds(orgId: number, userIds: number[]) { + const orgUsers = await this.organizationsUsersRepository.getOrganizationUsersByIds(orgId, userIds); + + if (!orgUsers?.length) { + throw new ForbiddenException("Provided user ids does not belong to the organization."); + } + + return orgUsers; + } } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index a8c8eabe9c..d3cd4d22b3 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1270,6 +1270,263 @@ ] } }, + "/v2/organizations/{orgId}/bookings": { + "get": { + "operationId": "OrganizationsBookingsController_getAllOrgTeamBookings", + "summary": "Get organization team bookings", + "parameters": [ + { + "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" + } + }, + { + "name": "userIds", + "required": false, + "in": "query", + "description": "Filter bookings by ids of users within your organization.", + "example": "?userIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" + } + } + } + } + }, + "tags": [ + "Orgs / Bookings" + ] + } + }, "/v2/organizations/{orgId}/delegation-credentials": { "post": { "operationId": "OrganizationsDelegationCredentialController_createDelegationCredential", @@ -2706,6 +2963,14 @@ "schema": { "type": "number" } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } } ], "responses": { diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 77ee5e8f9e..7fb39c2ce0 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -69,8 +69,11 @@ test.describe("Bookings", () => { test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => { const firstUser = await users.create(); await firstUser.apiLogin(); + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); await page.goto(`/bookings/upcoming`); - await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())); + await bookingsGetResponse; await page.locator('[data-testid="add-filter-button"]').click(); await page.locator('[data-testid="add-filter-item-dateRange"]').click(); @@ -233,8 +236,11 @@ test.describe("Bookings", () => { test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => { const firstUser = await users.create(); await firstUser.apiLogin(); + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); await page.goto(`/bookings/past`); - await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())); + await bookingsGetResponse; await page.locator('[data-testid="add-filter-button"]').click(); await page.locator('[data-testid="add-filter-item-dateRange"]').click(); @@ -447,8 +453,11 @@ test.describe("Bookings", () => { const anotherUser = teamMatesObj.find((m) => m.name !== host.user.name)?.name; await owner.apiLogin(); + const bookingsGetResponse1 = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); await page.goto("/bookings/upcoming"); - await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())); + await bookingsGetResponse1; await page.locator('[data-testid="add-filter-button"]').click(); await page.locator('[data-testid="add-filter-item-userId"]').click(); diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 16d48686b6..26718e9d67 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -1220,6 +1220,243 @@ "tags": ["Orgs / Attributes / Options"] } }, + "/v2/organizations/{orgId}/bookings": { + "get": { + "operationId": "OrganizationsBookingsController_getAllOrgTeamBookings", + "summary": "Get organization team bookings", + "parameters": [ + { + "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" + } + }, + { + "name": "userIds", + "required": false, + "in": "query", + "description": "Filter bookings by ids of users within your organization.", + "example": "?userIds=100,200", + "schema": { + "type": "string" + } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBookingsOutput_2024_08_13" + } + } + } + } + }, + "tags": ["Orgs / Bookings"] + } + }, "/v2/organizations/{orgId}/delegation-credentials": { "post": { "operationId": "OrganizationsDelegationCredentialController_createDelegationCredential", @@ -2603,6 +2840,14 @@ "schema": { "type": "number" } + }, + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } } ], "responses": { diff --git a/packages/lib/bookings/getAllUserBookings.ts b/packages/lib/bookings/getAllUserBookings.ts index 3125ac6320..0dbd39382a 100644 --- a/packages/lib/bookings/getAllUserBookings.ts +++ b/packages/lib/bookings/getAllUserBookings.ts @@ -13,7 +13,7 @@ type SortOptions = { }; type GetOptions = { ctx: { - user: { id: number; email: string }; + user: { id: number; email: string; orgId?: number | null }; prisma: PrismaClient; }; bookingListingByStatus: InputByStatus[]; @@ -76,7 +76,6 @@ const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, const orderBy = getOrderBy(bookingListingByStatus, sort); const combinedFilters = bookingListingByStatus.map((status) => bookingListingFilters[status]); - const { bookings, recurringInfo, totalCount } = await getBookings({ user, prisma, diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index 7ba4646506..052c2fd9ea 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -13,6 +13,8 @@ import type { Prisma } from "@calcom/prisma/client"; import { type BookingStatus } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; +import { TRPCError } from "@trpc/server"; + import type { TrpcSessionUser } from "../../../types"; import type { TGetInputSchema } from "./get.schema"; @@ -36,7 +38,10 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { const bookingListingByStatus = [input.filters.status || defaultStatus]; const { bookings, recurringInfo, totalCount } = await getAllUserBookings({ - ctx: { user: { id: user.id, email: user.email }, prisma: prisma }, + ctx: { + user: { id: user.id, email: user.email, orgId: user?.profile?.organizationId }, + prisma: prisma, + }, bookingListingByStatus: bookingListingByStatus, take, skip, @@ -59,7 +64,7 @@ export async function getBookings({ take, skip, }: { - user: { id: number; email: string }; + user: { id: number; email: string; orgId?: number | null }; filters: TGetInputSchema["filters"]; prisma: PrismaClient; passedBookingsStatusFilter: Prisma.BookingWhereInput; @@ -175,190 +180,165 @@ export async function getBookings({ eventTypeIdsFromTeamIdsFilter, attendeeEmailsFromUserIdsFilter, eventTypeIdsFromEventTypeIdsFilter, - eventTypeIdsWhereUserIsAdminOrOwener, - userIdsWhereUserIsOrgAdminOrOwener, + eventTypeIdsWhereUserIsAdminOrOwner, + userIdsAndEmailsWhereUserIsAdminOrOwner, ] = await Promise.all([ getEventTypeIdsFromTeamIdsFilter(prisma, filters?.teamIds), getAttendeeEmailsFromUserIdsFilter(prisma, user.email, filters?.userIds), getEventTypeIdsFromEventTypeIdsFilter(prisma, filters?.eventTypeIds), getEventTypeIdsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner), - getUserIdsWhereUserIsOrgAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner), + getUserIdsAndEmailsWhereUserIsAdminOrOwner(prisma, membershipConditionWhereUserIsAdminOwner, user.orgId), ]); - const whereClause = { - OR: [ - { - userId: user.id, - }, - { + // If user is organization owner/admin, contains organization members emails and ids (organization plan) + // If user is only team owner/admin, contain team members emails and ids (teams plan) + const [userIdsWhereUserIsAdminOrOwner, userEmailsWhereUserIsAdminOrOwner] = + userIdsAndEmailsWhereUserIsAdminOrOwner; + const orConditions = []; + + // If userIds filter is provided + if (!!filters?.userIds && filters.userIds.length > 0) { + const areUserIdsWithinUserOrgOrTeam = filters.userIds.every((userId) => + userIdsWhereUserIsAdminOrOwner.includes(userId) + ); + + // Scope depends on `user.orgId`: + // - Throw an error if trying to filter by usersIds that are not within your ORG + // - Throw an error if trying to filter by usersIds that are not within your TEAM + if (!areUserIdsWithinUserOrgOrTeam) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permissions to fetch bookings for specified userIds", + }); + } + + // Filtered view: Booking must match one of the specified users or their attendees + const usersFilter = { in: [...filters.userIds] }; + const attendeesEmailFilter = { in: attendeeEmailsFromUserIdsFilter }; + + // 1. Booking created by one of the filtered users + orConditions.push({ userId: usersFilter }); + // 2. Attendee email matches one of the filtered users' emails + orConditions.push({ attendees: { some: { email: attendeesEmailFilter } } }); + // 3. Seat reference attendee email matches one of the filtered users' emails + orConditions.push({ seatsReferences: { some: { attendee: { email: attendeesEmailFilter } } } }); + } else { + // Filter by emails for auth user. + const userEmailFilter = { equals: user.email }; + // Auth user is ORG_OWNER/ADMIN or TEAM_OWNER/ADMIN, filter by emails of members of the organization or team + const userEmailsFilterWhereUserIsOrgAdminOrOwner = userEmailsWhereUserIsAdminOrOwner?.length + ? { in: userEmailsWhereUserIsAdminOrOwner } + : undefined; + + // 1. Current user created bookings + orConditions.push({ userId: { equals: user.id } }); + // 2. Current user is an attendee + orConditions.push({ attendees: { some: { email: userEmailFilter } } }); + // 3. Current user is an attendee via seats reference + orConditions.push({ seatsReferences: { some: { attendee: { email: userEmailFilter } } } }); + // 4. Scope depends on `user.orgId`: + // - If Current user is ORG_OWNER/ADMIN so we get bookings where organization members are attendees + // - If Current user is TEAM_OWNER/ADMIN so we get bookings where team members are attendees + userEmailsFilterWhereUserIsOrgAdminOrOwner && + orConditions.push({ attendees: { some: { email: userEmailsFilterWhereUserIsOrgAdminOrOwner } } }); + // 5. Scope depends on `user.orgId`: + // - If Current user is ORG_OWNER/ADMIN so we get bookings where organization members are attendees via seatsReference + // - If Current user is TEAM_OWNER/ADMIN so we get bookings where team members are attendees via seatsReference + userEmailsFilterWhereUserIsOrgAdminOrOwner && + orConditions.push({ + seatsReferences: { some: { attendee: { email: userEmailsFilterWhereUserIsOrgAdminOrOwner } } }, + }); + // 6. Scope depends on `user.orgId`: + // - If Current user is ORG_OWNER/ADMIN, get booking created for an event type within the organization + // - If Current user is TEAM_OWNER/ADMIN, get bookings created for an event type within the team + eventTypeIdsWhereUserIsAdminOrOwner?.length && + orConditions.push({ eventTypeId: { in: eventTypeIdsWhereUserIsAdminOrOwner } }); + // 7. Scope depends on `user.orgId`: + // - If Current user is ORG_OWNER/ADMIN, get bookings created by users within the same organization + // - If Current user is TEAM_OWNER/ADMIN, get bookings created by users within the same organization + userIdsWhereUserIsAdminOrOwner?.length && + orConditions.push({ userId: { in: userIdsWhereUserIsAdminOrOwner } }); + } + + const andConditions = []; + + // 1. Apply mandatory status filter + andConditions.push(passedBookingsStatusFilter); + + // 2. Filter by Event Type IDs derived from Team IDs (if provided) + if (eventTypeIdsFromTeamIdsFilter && eventTypeIdsFromTeamIdsFilter.length > 0) { + andConditions.push({ eventTypeId: { in: eventTypeIdsFromTeamIdsFilter } }); + } + + // 3. Filter by specific Event Type IDs (if provided) + // If both teamIds filter and eventTypeIds filter are provided, filter 2. ensures the event-types are within the teams + if (eventTypeIdsFromEventTypeIdsFilter && eventTypeIdsFromEventTypeIdsFilter.length > 0) { + andConditions.push({ eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter } }); + } + + // 4. Filter by Attendee Email (if provided) + if (filters?.attendeeEmail) { + if (typeof filters.attendeeEmail === "string") { + // Simple string match (exact) + andConditions.push({ attendees: { some: { email: filters.attendeeEmail.trim() } } }); + } else if (isTextFilterValue(filters.attendeeEmail)) { + // Complex text filter (contains, startsWith, etc.) using makeWhereClause + andConditions.push({ attendees: { - some: { - email: user.email, - }, + some: makeWhereClause({ + columnName: "email", + filterValue: filters.attendeeEmail, + }), }, - }, - { - eventTypeId: { - in: eventTypeIdsWhereUserIsAdminOrOwener, - }, - }, - { - userId: { - in: userIdsWhereUserIsOrgAdminOrOwener, - }, - }, - { - seatsReferences: { - some: { - attendee: { - email: user.email, - }, - }, - }, - }, - ], - AND: [ - passedBookingsStatusFilter, - ...(eventTypeIdsFromTeamIdsFilter - ? [ - { - eventTypeId: { - in: eventTypeIdsFromTeamIdsFilter, - }, - }, - ] - : []), - ...(filters?.userIds && filters.userIds.length > 0 - ? [ - { - OR: [ - { - userId: { - in: filters.userIds, - }, - }, - ...(attendeeEmailsFromUserIdsFilter?.length - ? [ - { - attendees: { - some: { - email: { - in: attendeeEmailsFromUserIdsFilter, - }, - }, - }, - }, - ] - : []), - ], - }, - ] - : []), - ...(eventTypeIdsFromEventTypeIdsFilter - ? [ - { - eventTypeId: { in: eventTypeIdsFromEventTypeIdsFilter }, - }, - ] - : []), + }); + } + } - ...(typeof filters?.attendeeEmail === "string" - ? [ - { - attendees: { some: { email: filters.attendeeEmail.trim() } }, - }, - ] - : []), - ...(isTextFilterValue(filters?.attendeeEmail) - ? [ - { - attendees: { - some: makeWhereClause({ - columnName: "email", - filterValue: filters.attendeeEmail, - }), - }, - }, - ] - : []), + // 5. Filter by Attendee Name (if provided) + if (filters?.attendeeName) { + if (typeof filters.attendeeName === "string") { + // Simple string match (exact) + andConditions.push({ attendees: { some: { name: filters.attendeeName.trim() } } }); + } else if (isTextFilterValue(filters.attendeeName)) { + // Complex text filter (contains, startsWith, etc.) using makeWhereClause + andConditions.push({ + attendees: { + some: makeWhereClause({ + columnName: "name", + filterValue: filters.attendeeName, + }), + }, + }); + } + } - ...(typeof filters?.attendeeName === "string" - ? [ - { - attendees: { some: { name: filters.attendeeName.trim() } }, - }, - ] - : []), - ...(isTextFilterValue(filters?.attendeeName) - ? [ - { - attendees: { - some: makeWhereClause({ - columnName: "name", - filterValue: filters.attendeeName, - }), - }, - }, - ] - : []), + // 6. Date Range Filters + if (filters?.afterStartDate) { + andConditions.push({ startTime: { gte: dayjs.utc(filters.afterStartDate).toDate() } }); + } + if (filters?.beforeEndDate) { + andConditions.push({ endTime: { lte: dayjs.utc(filters.beforeEndDate).toDate() } }); + } + if (filters?.afterUpdatedDate) { + andConditions.push({ updatedAt: { gte: dayjs.utc(filters.afterUpdatedDate).toDate() } }); + } + if (filters?.beforeUpdatedDate) { + andConditions.push({ updatedAt: { lte: dayjs.utc(filters.beforeUpdatedDate).toDate() } }); + } + if (filters?.afterCreatedDate) { + andConditions.push({ createdAt: { gte: dayjs.utc(filters.afterCreatedDate).toDate() } }); + } + if (filters?.beforeCreatedDate) { + andConditions.push({ createdAt: { lte: dayjs.utc(filters.beforeCreatedDate).toDate() } }); + } - ...(filters?.afterStartDate - ? [ - { - startTime: { - gte: dayjs.utc(filters.afterStartDate).toDate(), - }, - }, - ] - : []), - ...(filters?.beforeEndDate - ? [ - { - endTime: { - lte: dayjs.utc(filters.beforeEndDate).toDate(), - }, - }, - ] - : []), - ...(filters?.afterUpdatedDate - ? [ - { - updatedAt: { - gte: dayjs.utc(filters.afterUpdatedDate).toDate(), - }, - }, - ] - : []), - ...(filters?.beforeUpdatedDate - ? [ - { - updatedAt: { - lte: dayjs.utc(filters.beforeUpdatedDate).toDate(), - }, - }, - ] - : []), - ...(filters?.afterCreatedDate - ? [ - { - createdAt: { - gte: dayjs.utc(filters.afterCreatedDate).toDate(), - }, - }, - ] - : []), - ...(filters?.beforeCreatedDate - ? [ - { - createdAt: { - lte: dayjs.utc(filters.beforeCreatedDate).toDate(), - }, - }, - ] - : []), - ], + const whereClause = { + OR: orConditions, + AND: andConditions, }; + log.info(`Get bookings where clause for user ${user.id}`, JSON.stringify(whereClause)); + const [plainBookings, totalCount] = await Promise.all([ prisma.booking.findMany({ where: whereClause, @@ -439,7 +419,6 @@ export async function getBookings({ // Now enrich bookings with relation data. We could have queried the relation data along with the bookings, but that would cause unnecessary queries to the database. // Because Prisma is also going to query the select relation data sequentially, we are fine querying it separately here as it would be just 1 query instead of 4 - log.info( `fetching all bookings for ${user.id}`, safeStringify({ @@ -530,8 +509,14 @@ async function getAttendeeEmailsFromUserIdsFilter( email: true, }, }) - // Include booking if current user is an attendee, regardless of user ID filter - .then((users) => users.map((user) => user.email).concat([userEmail])); + .then((users) => users.map((user) => user.email)); + + if (!attendeeEmailsFromUserIdsFilter || attendeeEmailsFromUserIdsFilter?.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The requested users do not exist.", + }); + } return attendeeEmailsFromUserIdsFilter; } @@ -568,7 +553,16 @@ async function getEventTypeIdsFromEventTypeIdsFilter(prisma: PrismaClient, event .then((eventTypes) => eventTypes.map((eventType) => eventType.id)), ]); - return Array.from(new Set([...directEventTypeIds, ...parentEventTypeIds])); + const eventTypeIdsFromDb = Array.from(new Set([...directEventTypeIds, ...parentEventTypeIds])); + + if (eventTypeIdsFromDb?.length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The requested event-types do not exist.", + }); + } + + return eventTypeIdsFromDb; } async function getEventTypeIdsWhereUserIsAdminOrOwner( @@ -608,25 +602,42 @@ async function getEventTypeIdsWhereUserIsAdminOrOwner( return Array.from(new Set([...directTeamEventTypeIds, ...parentTeamEventTypeIds])); } -async function getUserIdsWhereUserIsOrgAdminOrOwner( +/** + * Gets [IDs, Emails] of members where the auth user is admin/owner. + * Scope depends on `orgId`: + * - If set (number): Fetches members of that specific organization (`isOrganization: true`). + * - If unset (null/undefined): Fetches members of all teams (`isOrganization: false`) + * where the auth user meets the `membershipCondition`. + * + * @param prisma The Prisma client. + * @param membershipCondition Filter defining the auth user's required role (e.g., OWNER/ADMIN) + * to identify the target orgs/teams. + * @param orgId Optional ID to target a specific org; absence targets teams. + * @returns {Promise<[number[], string[]]>} [UserIDs, UserEmails] for members in the determined scope. + */ +async function getUserIdsAndEmailsWhereUserIsAdminOrOwner( prisma: PrismaClient, - membershipCondition: PrismaClientType.MembershipListRelationFilter -) { - return ( - await prisma.user.findMany({ - where: { - teams: { - some: { - team: { - isOrganization: true, - members: membershipCondition, - }, - }, + membershipCondition: PrismaClientType.MembershipListRelationFilter, + orgId?: number | null +): Promise<[number[], string[]]> { + const users = await prisma.user.findMany({ + where: { + teams: { + some: { + team: orgId + ? { + isOrganization: true, + members: membershipCondition, + id: orgId, + } + : { isOrganization: false, members: membershipCondition, parentId: null }, }, }, - select: { - id: true, - }, - }) - ).map((user) => user.id); + }, + select: { + id: true, + email: true, + }, + }); + return [users.map((user) => user.id), users.map((user) => user.email)]; }