Skip to content

Commit 44d9c4b

Browse files
authored
fix: Seated events and round robin - all round robin hosts are being added to the calendar event (calcom#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
1 parent 1b58656 commit 44d9c4b

3 files changed

Lines changed: 410 additions & 45 deletions

File tree

packages/features/bookings/lib/handleNewBooking.ts

Lines changed: 83 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { DestinationCalendar } from "@prisma/client";
1+
import type { DestinationCalendar, User } from "@prisma/client";
22
// eslint-disable-next-line no-restricted-imports
33
import { cloneDeep } from "lodash";
44
import short, { uuid } from "short-uuid";
@@ -250,6 +250,80 @@ const buildDryRunEventManager = () => {
250250
};
251251
};
252252

253+
export const buildEventForTeamEventType = async ({
254+
existingEvent: evt,
255+
users,
256+
organizerUser,
257+
schedulingType,
258+
team,
259+
}: {
260+
existingEvent: Partial<CalendarEvent>;
261+
users: (Pick<User, "id" | "name" | "timeZone" | "locale" | "email"> & {
262+
destinationCalendar: DestinationCalendar | null;
263+
isFixed?: boolean;
264+
})[];
265+
organizerUser: { email: string };
266+
schedulingType: SchedulingType | null;
267+
team?: {
268+
id: number;
269+
name: string;
270+
} | null;
271+
}) => {
272+
// not null assertion.
273+
if (!schedulingType) {
274+
throw new Error("Scheduling type is required for team event type");
275+
}
276+
const teamDestinationCalendars: DestinationCalendar[] = [];
277+
278+
// Organizer or user owner of this event type it's not listed as a team member.
279+
const teamMemberPromises = users
280+
.filter((user) => {
281+
if (user.email === organizerUser.email) return false;
282+
283+
// Skip non-fixed users in ROUND_ROBIN team event
284+
if (schedulingType === SchedulingType.ROUND_ROBIN && !user.isFixed) return false;
285+
286+
return true;
287+
})
288+
.map(async (user) => {
289+
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
290+
// push to teamDestinationCalendars if it's a team event but collective only
291+
if (schedulingType === "COLLECTIVE" && user.destinationCalendar) {
292+
teamDestinationCalendars.push({
293+
...user.destinationCalendar,
294+
externalId: processExternalId(user.destinationCalendar),
295+
});
296+
}
297+
298+
return {
299+
id: user.id,
300+
email: user.email ?? "",
301+
name: user.name ?? "",
302+
firstName: "",
303+
lastName: "",
304+
timeZone: user.timeZone,
305+
language: {
306+
translate: await getTranslation(user.locale ?? "en", "common"),
307+
locale: user.locale ?? "en",
308+
},
309+
};
310+
});
311+
312+
const teamMembers = await Promise.all(teamMemberPromises);
313+
314+
evt = CalendarEventBuilder.fromEvent(evt)
315+
.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
316+
.build();
317+
318+
return CalendarEventBuilder.fromEvent(evt)
319+
.withTeam({
320+
members: teamMembers,
321+
name: team?.name || "Nameless",
322+
id: team?.id ?? 0,
323+
})
324+
.build();
325+
};
326+
253327
function buildTroubleshooterData({
254328
eventType,
255329
}: {
@@ -925,36 +999,6 @@ async function handler(
925999
log.info("event type locations", eventType.locations);
9261000

9271001
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
928-
const teamDestinationCalendars: DestinationCalendar[] = [];
929-
930-
// Organizer or user owner of this event type it's not listed as a team member.
931-
const teamMemberPromises = users
932-
.filter((user) => user.email !== organizerUser.email)
933-
.map(async (user) => {
934-
// TODO: Add back once EventManager tests are ready https://github.com/calcom/cal.com/pull/14610#discussion_r1567817120
935-
// push to teamDestinationCalendars if it's a team event but collective only
936-
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) {
937-
teamDestinationCalendars.push({
938-
...user.destinationCalendar,
939-
externalId: processExternalId(user.destinationCalendar),
940-
});
941-
}
942-
943-
return {
944-
id: user.id,
945-
email: user.email ?? "",
946-
name: user.name ?? "",
947-
firstName: "",
948-
lastName: "",
949-
timeZone: user.timeZone,
950-
language: {
951-
translate: await getTranslation(user.locale ?? "en", "common"),
952-
locale: user.locale ?? "en",
953-
},
954-
};
955-
});
956-
const teamMembers = await Promise.all(teamMemberPromises);
957-
9581002
const attendeesList = [...invitee, ...guests];
9591003

9601004
const responses = reqBody.responses || null;
@@ -1076,10 +1120,14 @@ async function handler(
10761120
.build();
10771121
}
10781122

1079-
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") {
1080-
evt = CalendarEventBuilder.fromEvent(evt)
1081-
.withDestinationCalendar([...(evt.destinationCalendar ?? []), ...teamDestinationCalendars])
1082-
.build();
1123+
if (isTeamEventType) {
1124+
evt = await buildEventForTeamEventType({
1125+
existingEvent: evt,
1126+
schedulingType: eventType.schedulingType,
1127+
users,
1128+
team: eventType.team,
1129+
organizerUser,
1130+
});
10831131
}
10841132

10851133
// data needed for triggering webhooks
@@ -1140,16 +1188,6 @@ async function handler(
11401188
organizerUser.id
11411189
);
11421190

1143-
if (isTeamEventType) {
1144-
evt = CalendarEventBuilder.fromEvent(evt)
1145-
.withTeam({
1146-
members: teamMembers,
1147-
name: eventType.team?.name || "Nameless",
1148-
id: eventType.team?.id ?? 0,
1149-
})
1150-
.build();
1151-
}
1152-
11531191
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
11541192
if (eventType.seatsPerTimeSlot) {
11551193
const newBooking = await handleSeats({
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// or wherever it's from
2+
import { vi, describe, it, expect, beforeEach } from "vitest";
3+
4+
import { SchedulingType } from "@calcom/prisma/enums";
5+
6+
import { buildEventForTeamEventType } from "../../handleNewBooking";
7+
8+
vi.mock("@calcom/lib/server/i18n", () => ({
9+
getTranslation: vi.fn().mockResolvedValue("translated"),
10+
}));
11+
12+
const withTeamSpy = vi.fn().mockReturnThis();
13+
const withDestinationCalendarSpy = vi.fn().mockReturnThis();
14+
15+
vi.mock("@calcom/features/CalendarEventBuilder", () => {
16+
return {
17+
CalendarEventBuilder: {
18+
fromEvent: vi.fn().mockImplementation((evt) => ({
19+
withDestinationCalendar: withDestinationCalendarSpy,
20+
withTeam: withTeamSpy,
21+
build: vi.fn().mockImplementation(() => ({
22+
destinationCalendar: [],
23+
team: {}, // <- you won’t use this result anyway
24+
})),
25+
})),
26+
},
27+
};
28+
});
29+
30+
vi.mock("@calcom/app-store/_utils/calendars/processExternalId", () => ({
31+
default: vi.fn((dc) => `external-${dc?.externalId ?? "id"}`),
32+
}));
33+
34+
const baseUser = (overrides: Record<string, unknown> = {}) => ({
35+
id: 1,
36+
name: "Alice",
37+
email: "alice@example.com",
38+
timeZone: "Europe/Paris",
39+
locale: "fr",
40+
destinationCalendar: {
41+
id: 123,
42+
integration: "google",
43+
externalId: "ext-123",
44+
primaryEmail: "alice@example.com",
45+
userId: 1,
46+
eventTypeId: null,
47+
credentialId: null,
48+
delegationCredentialId: null,
49+
domainWideDelegationCredentialId: null,
50+
},
51+
isFixed: true,
52+
...overrides,
53+
});
54+
55+
describe("buildEventForTeamEventType", () => {
56+
it("throws if schedulingType is null", async () => {
57+
await expect(
58+
buildEventForTeamEventType({
59+
existingEvent: {},
60+
users: [],
61+
organizerUser: { email: "organizer@example.com" },
62+
schedulingType: null,
63+
})
64+
).rejects.toThrow("Scheduling type is required for team event type");
65+
});
66+
67+
it("filters out the organizer", async () => {
68+
const result = await buildEventForTeamEventType({
69+
existingEvent: {},
70+
users: [baseUser({ email: "organizer@example.com" })],
71+
organizerUser: { email: "organizer@example.com" },
72+
schedulingType: SchedulingType.COLLECTIVE,
73+
});
74+
75+
const teamArgs = withTeamSpy.mock.calls[0][0];
76+
const memberEmails = teamArgs.members.map((m: any) => m.email);
77+
78+
expect(memberEmails).not.toContain("organizer@example.com");
79+
});
80+
81+
it("includes destinationCalendars for COLLECTIVE", async () => {
82+
await buildEventForTeamEventType({
83+
existingEvent: { destinationCalendar: [] },
84+
users: [baseUser({ id: 2 })],
85+
organizerUser: { email: "organizer@example.com" },
86+
schedulingType: SchedulingType.COLLECTIVE,
87+
});
88+
89+
const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0];
90+
91+
expect(withDestinationCalendarArgs).not.toHaveLength(0);
92+
});
93+
94+
it("does not include destinationCalendars for ROUND_ROBIN", async () => {
95+
await buildEventForTeamEventType({
96+
existingEvent: { destinationCalendar: [] },
97+
users: [baseUser({ id: 2 })],
98+
organizerUser: { email: "organizer@example.com" },
99+
schedulingType: SchedulingType.ROUND_ROBIN,
100+
});
101+
102+
const withDestinationCalendarArgs = withDestinationCalendarSpy.mock.calls[0][0];
103+
104+
expect(withDestinationCalendarArgs).toHaveLength(0);
105+
});
106+
107+
it("excludes non-fixed users for ROUND_ROBIN", async () => {
108+
await buildEventForTeamEventType({
109+
existingEvent: {},
110+
users: [
111+
baseUser({ id: 2, isFixed: false, email: "notfixed@example.com" }),
112+
baseUser({ id: 3, isFixed: true, email: "fixed@example.com" }),
113+
],
114+
organizerUser: { email: "organizer@example.com" },
115+
schedulingType: SchedulingType.ROUND_ROBIN,
116+
});
117+
118+
const teamArgs = withTeamSpy.mock.calls[0][0];
119+
const memberEmails = teamArgs.members.map((m: any) => m.email);
120+
121+
expect(memberEmails).toContain("fixed@example.com");
122+
expect(memberEmails).not.toContain("notfixed@example.com");
123+
});
124+
125+
it("builds a team with fallback name and id", async () => {
126+
await buildEventForTeamEventType({
127+
existingEvent: {},
128+
users: [baseUser()],
129+
organizerUser: { email: "organizer@example.com" },
130+
schedulingType: SchedulingType.COLLECTIVE,
131+
team: null,
132+
});
133+
134+
// now inspect what was passed into withTeam()
135+
const teamArgs = withTeamSpy.mock.calls[0][0];
136+
137+
expect(teamArgs.name).toBe("Nameless");
138+
expect(teamArgs.id).toBe(0);
139+
});
140+
141+
beforeEach(() => {
142+
vi.clearAllMocks();
143+
});
144+
});

0 commit comments

Comments
 (0)