diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index a2e5271062..22944b93cb 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.314", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.315", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts index 6f35d10c39..92c542f69a 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/managed-user-bookings.e2e-spec.ts @@ -15,11 +15,11 @@ import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import { PlatformOAuthClient, Team, 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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; -import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.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"; @@ -36,6 +36,7 @@ import { CreateBookingInput_2024_08_13, CreateEventTypeInput_2024_06_14, EventTypeOutput_2024_06_14, + GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13, } from "@calcom/platform-types"; @@ -50,9 +51,9 @@ describe("Managed user bookings 2024-08-13", () => { let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; let teamRepositoryFixture: TeamRepositoryFixture; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; - let schedulesRepositoryFixture: SchedulesRepositoryFixture; let profilesRepositoryFixture: ProfileRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; + let bookingsRepositoryFixture: BookingsRepositoryFixture; const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`; let platformAdmin: User; @@ -67,6 +68,10 @@ describe("Managed user bookings 2024-08-13", () => { let thirdManagedUser: CreateManagedUserData; let firstManagedUserEventTypeId: number; + let eventTypeRequiresConfirmationId: number; + + const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`; + let orgAdminManagedUser: CreateManagedUserData; let firstManagedUserBookingsCount = 0; let secondManagedUserBookingsCount = 0; @@ -85,9 +90,9 @@ describe("Managed user bookings 2024-08-13", () => { userRepositoryFixture = new UserRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); - schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef); profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail }); @@ -95,6 +100,13 @@ describe("Managed user bookings 2024-08-13", () => { name: `oauth-client-users-organization-${randomString()}`, isPlatform: true, isOrganization: true, + platformBilling: { + create: { + customerId: "cus_999", + plan: "SCALE", + subscriptionId: "sub_999", + }, + }, }); oAuthClient = await createOAuthClient(organization.id); @@ -243,6 +255,59 @@ describe("Managed user bookings 2024-08-13", () => { thirdManagedUser = responseBody.data; }); + it(`should create org admin managed user`, async () => { + const requestBody: CreateManagedUserInput = { + email: orgAdminManagedUserEmail, + timeZone: managedUsersTimeZone, + weekStart: "Monday", + timeFormat: 24, + locale: Locales.FR, + name: "Org Admin Smith", + avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png", + }; + + const response = await request(app.getHttpServer()) + .post(`/api/v2/oauth-clients/${oAuthClient.id}/users`) + .set("x-cal-secret-key", oAuthClient.secret) + .send(requestBody) + .expect(201); + + const responseBody: CreateManagedUserOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.user.email).toEqual( + OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email) + ); + expect(responseBody.data.accessToken).toBeDefined(); + expect(responseBody.data.refreshToken).toBeDefined(); + + orgAdminManagedUser = responseBody.data; + + await request(app.getHttpServer()) + .post(`/v2/organizations/${organization.id}/memberships`) + .set("x-cal-client-id", oAuthClient.id) + .set("x-cal-secret-key", oAuthClient.secret) + .send({ + userId: orgAdminManagedUser.user.id, + role: "ADMIN", + accepted: true, + }) + .expect(201); + }); + + it("should create an event type requiring confirmation for first managed user", async () => { + const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create( + { + title: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`, + slug: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`, + length: 60, + requiresConfirmation: true, + }, + firstManagedUser.user.id + ); + eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id; + }); + describe("bookings using original emails", () => { it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => { const body: CreateBookingInput_2024_08_13 = { @@ -543,10 +608,180 @@ describe("Managed user bookings 2024-08-13", () => { }); }); + describe("booking confirmation by org admin", () => { + it("should allow org admin managed user to confirm booking using access token", async () => { + const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: firstManagedUser.user.id, + }, + }, + startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)), + endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)), + title: "Booking requiring confirmation", + uid: `booking-requiring-confirmation-${randomString()}`, + eventType: { + connect: { + id: eventTypeRequiresConfirmationId, + }, + }, + location: "https://meet.google.com/abc-def-ghi", + customInputs: {}, + metadata: {}, + status: "PENDING", + responses: { + name: secondManagedUser.user.name, + email: secondManagedUserEmail, + }, + attendees: { + create: { + email: secondManagedUserEmail, + name: secondManagedUser.user.name!, + locale: secondManagedUser.user.locale, + timeZone: secondManagedUser.user.timeZone, + }, + }, + }); + + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingRequiringConfirmation.uid}/confirm`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`) + .expect(200); + + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const bookingData = responseBody.data as BookingOutput_2024_08_13; + expect(bookingData.status).toEqual("accepted"); + expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid); + + const confirmedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid); + expect(confirmedBooking?.status).toEqual("ACCEPTED"); + }); + + it("should allow org admin managed user to reject booking using access token", async () => { + const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: firstManagedUser.user.id, + }, + }, + startTime: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)), + endTime: new Date(Date.UTC(2030, 0, 9, 16, 0, 0)), + title: "Booking requiring rejection", + uid: `booking-requiring-rejection-${randomString()}`, + eventType: { + connect: { + id: eventTypeRequiresConfirmationId, + }, + }, + location: "https://meet.google.com/abc-def-ghi", + customInputs: {}, + metadata: {}, + status: "PENDING", + responses: { + name: secondManagedUser.user.name, + email: secondManagedUserEmail, + }, + attendees: { + create: { + email: secondManagedUserEmail, + name: secondManagedUser.user.name!, + locale: secondManagedUser.user.locale, + timeZone: secondManagedUser.user.timeZone, + }, + }, + }); + + const response = await request(app.getHttpServer()) + .post(`/v2/bookings/${bookingRequiringConfirmation.uid}/decline`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`) + .expect(200); + + const responseBody: GetBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + + const bookingData = responseBody.data as BookingOutput_2024_08_13; + expect(bookingData.status).toEqual("rejected"); + expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid); + + const rejectedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid); + expect(rejectedBooking?.status).toEqual("REJECTED"); + }); + + it("should return unauthorized when org admin tries to confirm regular user's booking", async () => { + // Create regular user (not managed) + const regularUser = await userRepositoryFixture.create({ + email: `managed-user-bookings-2024-08-13-regular-user-${randomString()}@api.com`, + name: "Regular User", + }); + + // Create event type requiring confirmation for regular user + const regularUserEventType = await eventTypesRepositoryFixture.create( + { + title: `regular-user-event-type-requires-confirmation-${randomString()}`, + slug: `regular-user-event-type-requires-confirmation-${randomString()}`, + length: 60, + requiresConfirmation: true, + }, + regularUser.id + ); + + // Create booking for regular user + const regularUserBooking = await bookingsRepositoryFixture.create({ + user: { + connect: { + id: regularUser.id, + }, + }, + startTime: new Date(Date.UTC(2030, 0, 9, 17, 0, 0)), + endTime: new Date(Date.UTC(2030, 0, 9, 18, 0, 0)), + title: "Regular user booking requiring confirmation", + uid: `regular-user-booking-${randomString()}`, + eventType: { + connect: { + id: regularUserEventType.id, + }, + }, + location: "https://meet.google.com/abc-def-ghi", + customInputs: {}, + metadata: {}, + status: "PENDING", + responses: { + name: "External Attendee", + email: "external@example.com", + }, + attendees: { + create: { + email: "external@example.com", + name: "External Attendee", + locale: "en", + timeZone: "Europe/Rome", + }, + }, + }); + + // Org admin should not be able to confirm regular user's booking + await request(app.getHttpServer()) + .post(`/v2/bookings/${regularUserBooking.uid}/confirm`) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`) + .expect(401); + + // Clean up + await userRepositoryFixture.delete(regularUser.id); + }); + }); + afterAll(async () => { await userRepositoryFixture.delete(firstManagedUser.user.id); await userRepositoryFixture.delete(secondManagedUser.user.id); await userRepositoryFixture.delete(thirdManagedUser.user.id); + await userRepositoryFixture.delete(orgAdminManagedUser.user.id); await userRepositoryFixture.delete(platformAdmin.id); await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 4656645365..de6c81bd5f 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -440,5 +440,63 @@ const checkIfUserIsAuthorizedToConfirmBooking = async ({ if (membership) return; } + if (bookingUserId && (await isLoggedInUserOrgAdminOfBookingUser(loggedInUserId, bookingUserId))) { + return; + } + throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not authorized to confirm this booking" }); }; + +async function isLoggedInUserOrgAdminOfBookingUser(loggedInUserId: number, bookingUserId: number) { + const orgIdsWhereLoggedInUserAdmin = await getOrgIdsWhereAdmin(loggedInUserId); + + if (orgIdsWhereLoggedInUserAdmin.length === 0) { + return false; + } + + const bookingUserOrgMembership = await prisma.membership.findFirst({ + where: { + userId: bookingUserId, + teamId: { + in: orgIdsWhereLoggedInUserAdmin, + }, + team: { + parentId: null, + }, + }, + }); + + if (bookingUserOrgMembership) return true; + + const bookingUserOrgTeamMembership = await prisma.membership.findFirst({ + where: { + userId: bookingUserId, + team: { + parentId: { + in: orgIdsWhereLoggedInUserAdmin, + }, + }, + }, + }); + + return !!bookingUserOrgTeamMembership; +} + +async function getOrgIdsWhereAdmin(loggedInUserId: number) { + const loggedInUserOrgMemberships = await prisma.membership.findMany({ + where: { + userId: loggedInUserId, + role: { + in: [MembershipRole.OWNER, MembershipRole.ADMIN], + }, + team: { + parentId: null, + }, + }, + select: { + teamId: true, + }, + }); + + return loggedInUserOrgMemberships.map((m) => m.teamId); +} diff --git a/yarn.lock b/yarn.lock index a89cadc380..f74708179a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,7 +2500,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.314" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.315" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3559,13 +3559,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.314": - version: 0.0.314 - resolution: "@calcom/platform-libraries@npm:0.0.314" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.315": + version: 0.0.315 + resolution: "@calcom/platform-libraries@npm:0.0.315" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: b59eb591273334ed7d7d07a8f0518eb8cb40255883dff49626f85d3bd804db4da357bf77cd4f7b14ae0743d0a699647232b39956af9123bdc5e7b2458cab30eb + checksum: e1b30e06df79a6330503e3c0d8cd1925e669506ac3bb45ace578632bafb2fe0e1d3c0d9d45e629e6cea803715593cd76af4e9636f280625780bbb2c18c91b23b languageName: node linkType: hard