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:
@@ -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({
|
||||
|
||||
+144
@@ -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<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();
|
||||
});
|
||||
});
|
||||
+183
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user