Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,8 @@
"starting": "Starting",
"disable_guests": "Disable Guests",
"disable_guests_description": "Disable adding additional guests while booking.",
"optional_guest_team_members": "Add team members as optional guests",
"optional_guest_team_members_description": "Adding team members as an optional guest will always send an optional invite, but not check their availability.",
"private_link": "Generate private link",
"enable_private_url": "Enable Private URL",
"private_link_label": "Private link",
Expand Down
12 changes: 12 additions & 0 deletions packages/app-store/exchange2013calendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export default class ExchangeCalendarService implements Calendar {
});
}

if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}

await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy);

return {
Expand Down Expand Up @@ -117,6 +123,12 @@ export default class ExchangeCalendarService implements Calendar {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
appointment.OptionalAttendees.Clear();
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
appointment.Update(
ConflictResolutionMode.AlwaysOverwrite,
SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy
Expand Down
12 changes: 12 additions & 0 deletions packages/app-store/exchange2016calendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ export default class ExchangeCalendarService implements Calendar {
});
}

if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
Comment on lines +84 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add email validation to prevent API errors.

Consider filtering out team members without email addresses before adding them as attendees to prevent potential Exchange API errors.

      if (event.optionalGuestTeamMembers) {
-        event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
+        event.optionalGuestTeamMembers.filter(member => !!member.email).forEach((member: { email: string }) => {
          appointment.OptionalAttendees.Add(new Attendee(member.email));
        });
      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers
.filter(member => !!member.email)
.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
🤖 Prompt for AI Agents
In packages/app-store/exchange2016calendar/lib/CalendarService.ts around lines
84 to 88, the code adds optional guest team members as attendees without
validating their email addresses, which can cause API errors. Before adding each
member, check if the email property exists and is a valid non-empty string; only
add attendees with valid emails to prevent errors from the Exchange API.


await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy);

return {
Expand Down Expand Up @@ -118,6 +124,12 @@ export default class ExchangeCalendarService implements Calendar {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}
appointment.OptionalAttendees.Clear();
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
Comment on lines +127 to +132
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add email validation for consistency.

Apply the same email filtering logic here as suggested for the createEvent method.

      appointment.OptionalAttendees.Clear();
      if (event.optionalGuestTeamMembers) {
-        event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
+        event.optionalGuestTeamMembers.filter(member => !!member.email).forEach((member: { email: string }) => {
          appointment.OptionalAttendees.Add(new Attendee(member.email));
        });
      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
appointment.OptionalAttendees.Clear();
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
appointment.OptionalAttendees.Clear();
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers
.filter(member => !!member.email)
.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}
🤖 Prompt for AI Agents
In packages/app-store/exchange2016calendar/lib/CalendarService.ts around lines
127 to 132, the code adds optional attendees without validating their email
addresses. To fix this, apply the same email validation logic used in the
createEvent method before adding each optional attendee. This ensures only valid
emails are added to appointment.OptionalAttendees.

appointment.Update(
ConflictResolutionMode.AlwaysOverwrite,
SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy
Expand Down
15 changes: 15 additions & 0 deletions packages/app-store/exchangecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export default class ExchangeCalendarService implements Calendar {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}

if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}

return appointment
.Save(SendInvitationsMode.SendToAllAndSaveCopy)
.then(() => {
Expand Down Expand Up @@ -104,6 +111,14 @@ export default class ExchangeCalendarService implements Calendar {
appointment.RequiredAttendees.Add(new Attendee(member.email));
});
}

appointment.OptionalAttendees.Clear();
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
appointment.OptionalAttendees.Add(new Attendee(member.email));
});
}

return appointment
.Update(
ConflictResolutionMode.AlwaysOverwrite,
Expand Down
34 changes: 27 additions & 7 deletions packages/app-store/feishucalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,27 +401,47 @@ export default class FeishuCalendarService implements Calendar {

private translateAttendees = (event: CalendarEvent): FeishuEventAttendee[] => {
const attendeeArray: FeishuEventAttendee[] = [];
const mandatoryEmails = new Set<string>();
const optionalEmails = new Set<string>();

// Add main attendees (mandatory)
event.attendees
.filter((att) => att.email)
.forEach((att) => {
const attendee: FeishuEventAttendee = {
mandatoryEmails.add(att.email);
attendeeArray.push({
type: "third_party",
is_optional: false,
third_party_email: att.email,
};
attendeeArray.push(attendee);
});
});

// Add team members (mandatory, skip duplicates and self)
event.team?.members.forEach((member) => {
if (member.email !== this.credential.user?.email) {
const attendee: FeishuEventAttendee = {
if (member.email !== this.credential.user?.email && !mandatoryEmails.has(member.email)) {
mandatoryEmails.add(member.email);
attendeeArray.push({
type: "third_party",
is_optional: false,
third_party_email: member.email,
};
attendeeArray.push(attendee);
});
}
});

// Add optional guest team members, but only if not already added as mandatory
if (event.optionalGuestTeamMembers) {
event.optionalGuestTeamMembers.forEach((member: { email: string }) => {
if (!mandatoryEmails.has(member.email) && !optionalEmails.has(member.email)) {
optionalEmails.add(member.email);
attendeeArray.push({
type: "third_party",
is_optional: true,
third_party_email: member.email,
});
}
});
}

return attendeeArray;
};
}
18 changes: 18 additions & 0 deletions packages/app-store/googlecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ export default class GoogleCalendarService implements Calendar {
attendees.push(...teamAttendeesWithoutCurrentUser);
}

if (event.optionalGuestTeamMembers) {
const optionalGuestMembers = event.optionalGuestTeamMembers
?.map(({ email, name }) => ({
email,
displayName: name,
optional: true,
responseStatus: "needsAction",
}))
.filter(
(guest) =>
guest.email &&
!attendees.some(
(attendee) => attendee.email && attendee.email.toLowerCase() === guest.email.toLowerCase()
)
);
attendees.push(...optionalGuestMembers);
}

return attendees;
};

Expand Down
11 changes: 11 additions & 0 deletions packages/app-store/larkcalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,17 @@ export default class LarkCalendarService implements Calendar {
}
});

if (event.optionalGuestTeamMembers) {
const optionalGuestMembers = event.optionalGuestTeamMembers?.map(
({ email }): LarkEventAttendee => ({
type: "third_party",
is_optional: true,
third_party_email: email,
})
);
attendeeArray.push(...optionalGuestMembers);
}

return attendeeArray;
};
}
16 changes: 15 additions & 1 deletion packages/app-store/zohocalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ export default class ZohoCalendarService implements Calendar {
end: dayjs(event.endTime).format("YYYYMMDDTHHmmssZZ"),
timezone: event.organizer.timeZone,
},
attendees: event.attendees.map((attendee) => ({ email: attendee.email })),
attendees: this.getAttendees(event),
isprivate: event.seatsShowAttendees,
reminders: [
{
Expand All @@ -480,4 +480,18 @@ export default class ZohoCalendarService implements Calendar {

return zohoEvent;
};

private getAttendees = (event: CalendarEvent) => {
const attendees = event.attendees.map((attendee) => ({ email: attendee.email }));
if (event.optionalGuestTeamMembers) {
attendees.push(
...event.optionalGuestTeamMembers.map((member) => ({
email: member.email,
// 2 is optional guest
attendance: 2,
}))
);
}
return attendees;
};
Comment on lines +484 to +496
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add email validation for robustness.

The refactoring into a separate method is excellent for maintainability. Consider adding email validation to prevent potential API errors when team members lack email addresses.

  private getAttendees = (event: CalendarEvent) => {
    const attendees = event.attendees.map((attendee) => ({ email: attendee.email }));
    if (event.optionalGuestTeamMembers) {
      attendees.push(
-        ...event.optionalGuestTeamMembers.map((member) => ({
+        ...event.optionalGuestTeamMembers.filter(member => !!member.email).map((member) => ({
          email: member.email,
          // 2 is optional guest
          attendance: 2,
        }))
      );
    }
    return attendees;
  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private getAttendees = (event: CalendarEvent) => {
const attendees = event.attendees.map((attendee) => ({ email: attendee.email }));
if (event.optionalGuestTeamMembers) {
attendees.push(
...event.optionalGuestTeamMembers.map((member) => ({
email: member.email,
// 2 is optional guest
attendance: 2,
}))
);
}
return attendees;
};
private getAttendees = (event: CalendarEvent) => {
const attendees = event.attendees.map((attendee) => ({ email: attendee.email }));
if (event.optionalGuestTeamMembers) {
attendees.push(
...event.optionalGuestTeamMembers.filter(member => !!member.email).map((member) => ({
email: member.email,
// 2 is optional guest
attendance: 2,
}))
);
}
return attendees;
};
🤖 Prompt for AI Agents
In packages/app-store/zohocalendar/lib/CalendarService.ts around lines 484 to
496, the getAttendees method currently maps attendees and
optionalGuestTeamMembers without validating their email addresses. To fix this,
add a check to ensure each attendee and optional guest team member has a valid,
non-empty email before including them in the returned array. This validation
will prevent potential API errors caused by missing or invalid emails.

}
12 changes: 12 additions & 0 deletions packages/features/CalendarEventBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,18 @@ export class CalendarEventBuilder {
};
return this;
}
withOptionalGuestTeamMembers(
optionalGuestTeamMembers?: {
email: string;
name: string | null;
}[]
) {
this.event = {
...this.event,
optionalGuestTeamMembers: optionalGuestTeamMembers || [],
};
return this;
}

build(): CalendarEvent {
// Validate required fields
Expand Down
1 change: 1 addition & 0 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,7 @@ async function handler(
platformCancelUrl,
platformBookingUrl,
})
.withOptionalGuestTeamMembers(eventType.optionalGuestTeamMembers || [])
.build();

if (input.bookingData.thirdPartyRecurringEventId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
useEventLevelSelectedCalendars: true,
optionalGuestTeamMembers: {
select: {
name: true,
email: true,
id: true,
},
},
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal";
import CustomEventTypeModal from "./CustomEventTypeModal";
import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmailsSetting";
import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting";
import GuestTeamMemberController from "./GuestTeamMemberController";
import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController";
import RequiresConfirmationController from "./RequiresConfirmationController";

Expand Down Expand Up @@ -1201,6 +1202,9 @@ export const EventAdvancedTab = ({
</SettingsToggle>
)}
/>
{team?.members && team.members.length > 0 && (
<GuestTeamMemberController team={team} eventType={eventType} />
)}
{isRoundRobinEventType && (
<Controller
name="rescheduleWithSameRoundRobinHost"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";

import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { SettingsToggle } from "@calcom/ui/components/form";

import type { EventTypeSetup, FormValues } from "../../../lib/types";

type GuestTeamMemberControllerProps = {
team: EventTypeSetup["team"];
eventType: EventTypeSetup;
};

function GuestTeamMemberController({ team, eventType }: GuestTeamMemberControllerProps) {
const { t } = useLocale();

const formMethods = useFormContext<FormValues>();

const [isGuestTeamMembersEnabled, setIsGuestTeamMembersEnabled] = useState(
eventType.optionalGuestTeamMembers.length > 0
);

useEffect(() => {
setIsGuestTeamMembersEnabled(eventType.optionalGuestTeamMembers.length > 0);
}, [eventType.optionalGuestTeamMembers]);

const addedGuestTeamMembers = formMethods.watch("optionalGuestTeamMembers", []);

if (!team) {
return null;
}

return (
<div className="block w-full items-start sm:flex">
<Controller
name="optionalGuestTeamMembers"
control={formMethods.control}
render={({ field: { onChange } }) => (
<div className="w-full">
<SettingsToggle
title={t("optional_guest_team_members")}
description={t("optional_guest_team_members_description")}
childrenClassName={classNames("lg:ml-0")}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
isGuestTeamMembersEnabled && "rounded-b-none"
)}
labelClassName={classNames("text-sm")}
checked={isGuestTeamMembersEnabled}
toggleSwitchAtTheEnd={true}
onCheckedChange={(checked) => {
setIsGuestTeamMembersEnabled(checked);
if (!checked) {
onChange([]);
}
}}>
<div className="border-subtle flex flex-col gap-4 rounded-b-lg border border-t-0 p-6">
<CheckedTeamSelect
onChange={(options) => {
if (!onChange) return;
onChange(options.map((option) => ({ id: parseInt(option.value) })));
}}
value={(addedGuestTeamMembers || []).reduce((acc, host) => {
const option = team.members?.find((member) => member.user.id === host.id);
if (!option) return acc;

acc.push({
value: option.user.id.toString(),
avatar: option.user.avatarUrl || "",
label: option.user.email,
isFixed: true,
});
return acc;
}, [] as CheckedSelectOption[])}
options={team?.members.map((member) => ({
avatar: member.user.avatarUrl || "",
label: member.user.email || "",
value: member.user.id.toString() || "",
}))}
controlShouldRenderValue={false}
/>
</div>
</SettingsToggle>
</div>
)}
/>
</div>
);
}

export default GuestTeamMemberController;
1 change: 1 addition & 0 deletions packages/features/eventtypes/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export type FormValues = {
redirectUrlOnExit?: string;
};
maxActiveBookingPerBookerOfferReschedule: boolean;
optionalGuestTeamMembers: { id: number }[];
};

export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
Expand Down
1 change: 1 addition & 0 deletions packages/lib/defaultEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const commons = {
instantMeetingScheduleId: null,
instantMeetingParameters: [],
eventTypeColor: null,
optionalGuestTeamMembers: [],
};

export const dynamicEvent = {
Expand Down
5 changes: 5 additions & 0 deletions packages/lib/server/repository/eventType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,11 @@ export class EventTypeRepository {
field: true,
},
},
optionalGuestTeamMembers: {
select: {
id: true,
},
},
recurringEvent: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
Expand Down
Loading
Loading