diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 509b83c96e..8c3c5cd733 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1,4 +1,4 @@ -import type { DestinationCalendar } from "@prisma/client"; +import type { DestinationCalendar, User } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports import { cloneDeep } from "lodash"; import short, { uuid } from "short-uuid"; @@ -250,6 +250,80 @@ const buildDryRunEventManager = () => { }; }; +export const buildEventForTeamEventType = async ({ + existingEvent: evt, + users, + organizerUser, + schedulingType, + team, +}: { + existingEvent: Partial; + users: (Pick & { + destinationCalendar: DestinationCalendar | null; + isFixed?: boolean; + })[]; + organizerUser: { email: string }; + schedulingType: SchedulingType | null; + team?: { + id: number; + name: string; + } | null; +}) => { + // not null assertion. + if (!schedulingType) { + throw new Error("Scheduling type is required for team event type"); + } + const teamDestinationCalendars: DestinationCalendar[] = []; + + // Organizer or user owner of this event type it's not listed as a team member. + const teamMemberPromises = users + .filter((user) => { + if (user.email === organizerUser.email) return false; + + // Skip non-fixed users in ROUND_ROBIN team event + if (schedulingType === SchedulingType.ROUND_ROBIN && !user.isFixed) return false; + + return true; + }) + .map(async (user) => { + // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 + // push to teamDestinationCalendars if it's a team event but collective only + if (schedulingType === "COLLECTIVE" && user.destinationCalendar) { + teamDestinationCalendars.push({ + ...user.destinationCalendar, + externalId: processExternalId(user.destinationCalendar), + }); + } + + return { + id: user.id, + email: user.email ?? "", + name: user.name ?? "", + firstName: "", + lastName: "", + timeZone: user.timeZone, + language: { + translate: await getTranslation(user.locale ?? "en", "common"), + locale: user.locale ?? "en", + }, + }; + }); + + const teamMembers = await Promise.all(teamMemberPromises); + + evt = CalendarEventBuilder.fromEvent(evt) + .withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars]) + .build(); + + return CalendarEventBuilder.fromEvent(evt) + .withTeam({ + members: teamMembers, + name: team?.name || "Nameless", + id: team?.id ?? 0, + }) + .build(); +}; + function buildTroubleshooterData({ eventType, }: { @@ -925,36 +999,6 @@ async function handler( log.info("event type locations", eventType.locations); const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); - const teamDestinationCalendars: DestinationCalendar[] = []; - - // Organizer or user owner of this event type it's not listed as a team member. - const teamMemberPromises = users - .filter((user) => user.email !== organizerUser.email) - .map(async (user) => { - // TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120 - // push to teamDestinationCalendars if it's a team event but collective only - if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) { - teamDestinationCalendars.push({ - ...user.destinationCalendar, - externalId: processExternalId(user.destinationCalendar), - }); - } - - return { - id: user.id, - email: user.email ?? "", - name: user.name ?? "", - firstName: "", - lastName: "", - timeZone: user.timeZone, - language: { - translate: await getTranslation(user.locale ?? "en", "common"), - locale: user.locale ?? "en", - }, - }; - }); - const teamMembers = await Promise.all(teamMemberPromises); - const attendeesList = [...invitee, ...guests]; const responses = reqBody.responses || null; @@ -1076,10 +1120,14 @@ async function handler( .build(); } - if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") { - evt = CalendarEventBuilder.fromEvent(evt) - .withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars]) - .build(); + if (isTeamEventType) { + evt = await buildEventForTeamEventType({ + existingEvent: evt, + schedulingType: eventType.schedulingType, + users, + team: eventType.team, + organizerUser, + }); } // data needed for triggering webhooks @@ -1140,16 +1188,6 @@ async function handler( organizerUser.id ); - if (isTeamEventType) { - evt = CalendarEventBuilder.fromEvent(evt) - .withTeam({ - members: teamMembers, - name: eventType.team?.name || "Nameless", - id: eventType.team?.id ?? 0, - }) - .build(); - } - // For seats, if the booking already exists then we want to add the new attendee to the existing booking if (eventType.seatsPerTimeSlot) { const newBooking = await handleSeats({ diff --git a/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts b/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts new file mode 100644 index 0000000000..e87a4eb495 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/buildEventForTeamEventType.test.ts @@ -0,0 +1,144 @@ +// or wherever it's from +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import { SchedulingType } from "@calcom/prisma/enums"; + +import { buildEventForTeamEventType } from "../../handleNewBooking"; + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: vi.fn().mockResolvedValue("translated"), +})); + +const withTeamSpy = vi.fn().mockReturnThis(); +const withDestinationCalendarSpy = vi.fn().mockReturnThis(); + +vi.mock("@calcom/features/CalendarEventBuilder", () => { + return { + CalendarEventBuilder: { + fromEvent: vi.fn().mockImplementation((evt) => ({ + withDestinationCalendar: withDestinationCalendarSpy, + withTeam: withTeamSpy, + build: vi.fn().mockImplementation(() => ({ + destinationCalendar: [], + team: {}, // <- you won’t use this result anyway + })), + })), + }, + }; +}); + +vi.mock("@calcom/app-store/_utils/calendars/processExternalId", () => ({ + default: vi.fn((dc) => `external-${dc?.externalId ?? "id"}`), +})); + +const baseUser = (overrides: Record = {}) => ({ + id: 1, + name: "Alice", + email: "alice@example.com", + timeZone: "Europe/Paris", + locale: "fr", + destinationCalendar: { + id: 123, + integration: "google", + externalId: "ext-123", + primaryEmail: "alice@example.com", + userId: 1, + eventTypeId: null, + credentialId: null, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + }, + isFixed: true, + ...overrides, +}); + +describe("buildEventForTeamEventType", () => { + it("throws if schedulingType is null", async () => { + await expect( + buildEventForTeamEventType({ + existingEvent: {}, + users: [], + organizerUser: { email: "organizer@example.com" }, + schedulingType: null, + }) + ).rejects.toThrow("Scheduling type is required for team event type"); + }); + + it("filters out the organizer", async () => { + const result = await buildEventForTeamEventType({ + existingEvent: {}, + users: [baseUser({ email: "organizer@example.com" })], + organizerUser: { email: "organizer@example.com" }, + schedulingType: SchedulingType.COLLECTIVE, + }); + + const teamArgs = withTeamSpy.mock.calls[0][0]; + const memberEmails = teamArgs.members.map((m: any) => m.email); + + expect(memberEmails).not.toContain("organizer@example.com"); + }); + + it("includes destinationCalendars for COLLECTIVE", async () => { + await buildEventForTeamEventType({ + existingEvent: { destinationCalendar: [] }, + users: [baseUser({ id: 2 })], + organizerUser: { email: "organizer@example.com" }, + schedulingType: SchedulingType.COLLECTIVE, + }); + + const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0]; + + expect(withDestinationCalendarArgs).not.toHaveLength(0); + }); + + it("does not include destinationCalendars for ROUND_ROBIN", async () => { + await buildEventForTeamEventType({ + existingEvent: { destinationCalendar: [] }, + users: [baseUser({ id: 2 })], + organizerUser: { email: "organizer@example.com" }, + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0]; + + expect(withDestinationCalendarArgs).toHaveLength(0); + }); + + it("excludes non-fixed users for ROUND_ROBIN", async () => { + await buildEventForTeamEventType({ + existingEvent: {}, + users: [ + baseUser({ id: 2, isFixed: false, email: "notfixed@example.com" }), + baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }), + ], + organizerUser: { email: "organizer@example.com" }, + schedulingType: SchedulingType.ROUND_ROBIN, + }); + + const teamArgs = withTeamSpy.mock.calls[0][0]; + const memberEmails = teamArgs.members.map((m: any) => m.email); + + expect(memberEmails).toContain("fixed@example.com"); + expect(memberEmails).not.toContain("notfixed@example.com"); + }); + + it("builds a team with fallback name and id", async () => { + await buildEventForTeamEventType({ + existingEvent: {}, + users: [baseUser()], + organizerUser: { email: "organizer@example.com" }, + schedulingType: SchedulingType.COLLECTIVE, + team: null, + }); + + // now inspect what was passed into withTeam() + const teamArgs = withTeamSpy.mock.calls[0][0]; + + expect(teamArgs.name).toBe("Nameless"); + expect(teamArgs.id).toBe(0); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); +}); diff --git a/packages/features/bookings/lib/handleNewBooking/test/team-bookings/seatedRoundRobin.test.ts b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/seatedRoundRobin.test.ts new file mode 100644 index 0000000000..98735de929 --- /dev/null +++ b/packages/features/bookings/lib/handleNewBooking/test/team-bookings/seatedRoundRobin.test.ts @@ -0,0 +1,183 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + +import { + getBooker, + TestData, + getOrganizer, + createBookingScenario, + getGoogleCalendarCredential, + Timezones, + getScenarioData, + mockSuccessfulVideoMeetingCreation, + BookingLocations, + getDate, + getMockBookingAttendee, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest"; +import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +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"; + +describe("Seated Round Robin Events", () => { + setupAndTeardown(); + + test("For second seat booking, organizer remains the same with no team members included", async () => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const EventManager = (await import("@calcom/lib/EventManager")).default; + + const eventManagerSpy = vi.spyOn(EventManager.prototype, "updateCalendarAttendees"); + + const booker = getBooker({ + email: "seat2@example.com", + name: "Seat 2", + }); + + const assignedHost = getOrganizer({ + name: "Assigned Host", + email: "assigned-host@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + }); + + const teamMembers = [ + { + name: "Team Member 1", + username: "team-member-1", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "team-member-1@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + }, + { + name: "Team Member 2", + username: "team-member-2", + timeZone: Timezones["+5:30"], + defaultScheduleId: null, + email: "team-member-2@example.com", + id: 103, + schedules: [TestData.schedules.IstEveningShift], + }, + ]; + + 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: "seated-round-robin-event", + slotInterval: 30, + length: 30, + schedulingType: SchedulingType.ROUND_ROBIN, + users: [ + { id: assignedHost.id }, + { id: teamMembers[0].id }, + { id: teamMembers[1].id }, + ], + hosts: [ + { userId: assignedHost.id, isFixed: false }, + { userId: teamMembers[0].id, isFixed: false }, + { userId: teamMembers[1].id, isFixed: false }, + ], + seatsPerTimeSlot: 3, + seatsShowAttendees: false, + }, + ], + bookings: [ + { + id: bookingId, + uid: bookingUid, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + userId: assignedHost.id, // This is the assigned host for the booking + 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: "Seat 1", + email: "seat1@test.com", + locale: "en", + timeZone: "America/Toronto", + bookingSeat: { + referenceUid: "booking-seat-1", + data: {}, + }, + }), + ], + }, + ], + organizer: assignedHost, + usersApartFromOrganizer: [...teamMembers], + }) + ); + + 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, + }, + }); + + await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(eventManagerSpy).toHaveBeenCalled(); + + const calendarEvent = eventManagerSpy.mock.calls[0][0]; + + expect(calendarEvent.organizer.email).toBe(assignedHost.email); + + expect(calendarEvent.team?.members).toBeDefined(); + expect(calendarEvent.team?.members.length).toBe(0); + + const teamMemberEmails = calendarEvent.team?.members.map((member) => member.email); + expect(teamMemberEmails).not.toContain(teamMembers[0].email); + expect(teamMemberEmails).not.toContain(teamMembers[1].email); + }); +});