diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index 7b19d2848e..1577b086b2 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -47,7 +47,10 @@ const createNewSeat = async ( evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] }; - if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) { + if ( + eventType.seatsPerTimeSlot && + eventType.seatsPerTimeSlot <= seatedBooking.attendees.filter((attendee) => !!attendee.bookingSeat).length + ) { throw new HttpError({ statusCode: 409, message: ErrorCode.BookingSeatsFull }); } diff --git a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts index c19fa48758..9acbf31cd9 100644 --- a/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts +++ b/packages/features/bookings/lib/handleSeats/test/handleSeats.test.ts @@ -5,6 +5,8 @@ import { TestData, getOrganizer, createBookingScenario, + getGoogleCalendarCredential, + Timezones, getScenarioData, mockSuccessfulVideoMeetingCreation, BookingLocations, @@ -20,6 +22,7 @@ import { describe, test, vi, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import { SchedulingType } from "@calcom/prisma/enums"; import { BookingStatus } from "@calcom/prisma/enums"; import * as handleSeatsModule from "../handleSeats"; @@ -873,6 +876,186 @@ describe("handleSeats", () => { await expect(() => handleNewBooking(req)).rejects.toThrowError(ErrorCode.BookingSeatsFull); }); + + test("Verify Seat Availability Calculation Based on Booked Seats, Not Total Attendees", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + + const booker = getBooker({ + email: "seat2@example.com", + name: "Seat 2", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + defaultScheduleId: null, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "organizer@google-calendar.com", + }, + }); + + const otherTeamMembers = [ + { + name: "Other Team Member 1", + username: "other-team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "other-team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "other-team-member-1@google-calendar.com", + }, + }, + ]; + + const bookingId = 1; + const bookingUid = "abc123"; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const bookingStartTime = `${plus1DateString}T04:00:00Z`; + const bookingEndTime = `${plus1DateString}T04:30:00Z`; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "collective-team-seated-event", + slotInterval: 30, + length: 30, + schedulingType: SchedulingType.COLLECTIVE, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + destinationCalendar: { + integration: TestData.apps["google-calendar"].type, + externalId: "event-type-1@google-calendar.com", + }, + seatsPerTimeSlot: 2, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: bookingId, + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: "Other Team Member 1", + email: "other-team-member-1@example.com", + locale: "en", + timeZone: "America/Toronto", + }), + getMockBookingAttendee({ + id: 2, + name: "Seat 1", + email: "seat1@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + ], + }, + ], + organizer, + usersApartFromOrganizer: otherTeamMembers, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-1`, + }, + }); + + const reqBookingUser = "seatedAttendee"; + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + bookingUid: bookingUid, + user: reqBookingUser, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + await handleNewBooking(req); + + const newAttendee = await prismaMock.attendee.findFirst({ + where: { + email: booker.email, + bookingId: bookingId, + }, + include: { + bookingSeat: true, + }, + }); + + // Check for the existence of the new attendee with booking seat + expect(newAttendee?.bookingSeat).toEqual( + expect.objectContaining({ + referenceUid: expect.any(String), + data: expect.any(Object), + bookingId: bookingId, + }) + ); + + // Verify that the booking seat count is now 2 out of 2 + const bookingSeatCount = await prismaMock.bookingSeat.count({ + where: { + bookingId: bookingId, + }, + }); + + expect(bookingSeatCount).toBe(2); + }); }); describe("Rescheduling a booking", () => {