fix: Seated events and round robin - all round robin hosts are being added to the calendar event (#21317)

* fix: add org email into ics

* fix: Seated events and round robin - all round robin hosts are being added to the calendar event

* added test
This commit is contained in:
Anik Dhabal Babu
2025-05-14 22:52:26 +00:00
committed by GitHub
parent 1b586560f8
commit 44d9c4bcc5
3 changed files with 410 additions and 45 deletions
@@ -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<CalendarEvent>;
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
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({
@@ -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 wont use this result anyway
})),
})),
},
};
});
vi.mock("@calcom/app-store/_utils/calendars/processExternalId", () => ({
default: vi.fn((dc) => `external-${dc?.externalId ?? "id"}`),
}));
const baseUser = (overrides: Record<string, unknown> = {}) => ({
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();
});
});
@@ -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);
});
});