diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c3892521de2bcf..334857530137bf 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1614,6 +1614,8 @@ "event_limit_tab_description": "How often you can be booked", "event_advanced_tab_description": "Calendar settings & more...", "event_advanced_tab_title": "Advanced", + "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.", "event_payments_tab_description": "Set up payments for event", "event_setup_multiple_duration_error": "Event Setup: Multiple durations requires at least 1 option.", "event_setup_multiple_duration_default_error": "Event Setup: Please select a valid default duration.", diff --git a/packages/app-store/exchange2013calendar/lib/CalendarService.ts b/packages/app-store/exchange2013calendar/lib/CalendarService.ts index 13f17e28216b58..0064e4a69cb873 100644 --- a/packages/app-store/exchange2013calendar/lib/CalendarService.ts +++ b/packages/app-store/exchange2013calendar/lib/CalendarService.ts @@ -69,14 +69,29 @@ export default class ExchangeCalendarService implements Calendar { appointment.End = DateTime.Parse(event.endTime); // moment string appointment.Location = event.location || "Location not defined!"; appointment.Body = new MessageBody(event.description || ""); // you can not use any special character or escape the content + // Create a set of optional guest emails for easy lookup. + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + // Add the main booker as required for (let i = 0; i < event.attendees.length; i++) { appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); } + // Add team members as required, ONLY if they aren't optional if (event.team?.members) { - event.team.members.forEach((member) => { - appointment.RequiredAttendees.Add(new Attendee(member.email)); + event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email.toLowerCase())) + .forEach((member) => { + appointment.RequiredAttendees.Add(new Attendee(member.email)); + }); + } + + // Add optional members to the optional list + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach((member) => { + appointment.OptionalAttendees.Add(new Attendee(member.email)); }); } @@ -86,7 +101,7 @@ export default class ExchangeCalendarService implements Calendar { uid: appointment.Id.UniqueId, id: appointment.Id.UniqueId, password: "", - type: "", + type: "exchange2013_calendar", // Added type for clarity url: "", additionalInfo: {}, }; @@ -109,14 +124,36 @@ export default class ExchangeCalendarService implements Calendar { appointment.End = DateTime.Parse(event.endTime); // moment string appointment.Location = event.location || "Location not defined!"; appointment.Body = new MessageBody(event.description || ""); // you can not use any special character or escape the content + + // Clear old attendees before adding new ones + appointment.RequiredAttendees.Clear(); + appointment.OptionalAttendees.Clear(); + + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + + // Add the main booker as required for (let i = 0; i < event.attendees.length; i++) { appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); } + + // Add team members as required, ONLY if they aren't optional if (event.team?.members) { - event.team.members.forEach((member) => { - appointment.RequiredAttendees.Add(new Attendee(member.email)); + event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email.toLowerCase())) + .forEach((member) => { + appointment.RequiredAttendees.Add(new Attendee(member.email)); + }); + } + + // Add optional members to the optional list + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach((member) => { + appointment.OptionalAttendees.Add(new Attendee(member.email)); }); } + appointment.Update( ConflictResolutionMode.AlwaysOverwrite, SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy diff --git a/packages/app-store/exchange2016calendar/lib/CalendarService.ts b/packages/app-store/exchange2016calendar/lib/CalendarService.ts index 0f97cadc39dc8d..6c61fb83785bd9 100644 --- a/packages/app-store/exchange2016calendar/lib/CalendarService.ts +++ b/packages/app-store/exchange2016calendar/lib/CalendarService.ts @@ -70,14 +70,29 @@ export default class ExchangeCalendarService implements Calendar { appointment.End = DateTime.Parse(event.endTime); // moment string appointment.Location = event.location || "Location not defined!"; appointment.Body = new MessageBody(event.description || ""); // you can not use any special character or escape the content + // Create a set of optional guest emails for easy lookup. + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + // Add the main booker as required for (let i = 0; i < event.attendees.length; i++) { appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); } + // Add team members as required, ONLY if they aren't optional if (event.team?.members) { - event.team.members.forEach((member) => { - appointment.RequiredAttendees.Add(new Attendee(member.email)); + event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email.toLowerCase())) + .forEach((member) => { + appointment.RequiredAttendees.Add(new Attendee(member.email)); + }); + } + + // Add optional members to the optional list + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach((member) => { + appointment.OptionalAttendees.Add(new Attendee(member.email)); }); } @@ -87,7 +102,7 @@ export default class ExchangeCalendarService implements Calendar { uid: appointment.Id.UniqueId, id: appointment.Id.UniqueId, password: "", - type: "", + type: "exchange2016_calendar", // Added type for clarity url: "", additionalInfo: {}, }; @@ -110,12 +125,33 @@ export default class ExchangeCalendarService implements Calendar { appointment.End = DateTime.Parse(event.endTime); // moment string appointment.Location = event.location || "Location not defined!"; appointment.Body = new MessageBody(event.description || ""); // you can not use any special character or escape the content + + // Clear old attendees before adding new ones + appointment.RequiredAttendees.Clear(); + appointment.OptionalAttendees.Clear(); + + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + + // Add the main booker as required for (let i = 0; i < event.attendees.length; i++) { appointment.RequiredAttendees.Add(new Attendee(event.attendees[i].email)); } + + // Add team members as required, ONLY if they aren't optional if (event.team?.members) { - event.team.members.forEach((member) => { - appointment.RequiredAttendees.Add(new Attendee(member.email)); + event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email.toLowerCase())) + .forEach((member) => { + appointment.RequiredAttendees.Add(new Attendee(member.email)); + }); + } + + // Add optional members to the optional list + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach((member) => { + appointment.OptionalAttendees.Add(new Attendee(member.email)); }); } appointment.Update( diff --git a/packages/app-store/exchangecalendar/lib/CalendarService.ts b/packages/app-store/exchangecalendar/lib/CalendarService.ts index b781761e2e18d0..f769e98db85c74 100644 --- a/packages/app-store/exchangecalendar/lib/CalendarService.ts +++ b/packages/app-store/exchangecalendar/lib/CalendarService.ts @@ -60,12 +60,29 @@ export default class ExchangeCalendarService implements Calendar { appointment.End = DateTime.Parse(event.endTime); appointment.Location = event.location || ""; appointment.Body = new MessageBody(event.description || ""); + // Create a set of optional guest emails for easy lookup. + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + + // Add the main booker as required event.attendees.forEach((attendee: Person) => { appointment.RequiredAttendees.Add(new Attendee(attendee.email)); }); + + // Add team members as required, ONLY if they aren't optional if (event.team?.members) { - event.team.members.forEach((member: Person) => { - appointment.RequiredAttendees.Add(new Attendee(member.email)); + event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email.toLowerCase())) + .forEach((member: Person) => { + appointment.RequiredAttendees.Add(new Attendee(member.email)); + }); + } + + // Add optional members to the optional list + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach((member: { email: string }) => { + appointment.OptionalAttendees.Add(new Attendee(member.email)); }); } return appointment @@ -75,7 +92,7 @@ export default class ExchangeCalendarService implements Calendar { uid: appointment.Id.UniqueId, id: appointment.Id.UniqueId, password: "", - type: "", + type: "exchange_calendar", url: "", additionalInfo: {}, }; @@ -96,12 +113,32 @@ export default class ExchangeCalendarService implements Calendar { appointment.End = DateTime.Parse(event.endTime); appointment.Location = event.location || ""; appointment.Body = new MessageBody(event.description || ""); + + // Clear old attendees before adding new ones + appointment.RequiredAttendees.Clear(); + appointment.OptionalAttendees.Clear(); + + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + + // Add the main booker as required event.attendees.forEach((attendee: Person) => { appointment.RequiredAttendees.Add(new Attendee(attendee.email)); }); + // Add team members as required, ONLY if they aren't optional if (event.team?.members) { - event.team.members.forEach((member) => { - appointment.RequiredAttendees.Add(new Attendee(member.email)); + event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email.toLowerCase())) + .forEach((member) => { + appointment.RequiredAttendees.Add(new Attendee(member.email)); + }); + } + + // Add optional members to the optional list + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach((member: { email: string }) => { + appointment.OptionalAttendees.Add(new Attendee(member.email)); }); } return appointment @@ -114,7 +151,7 @@ export default class ExchangeCalendarService implements Calendar { uid: appointment.Id.UniqueId, id: appointment.Id.UniqueId, password: "", - type: "", + type: "exchange_calendar", url: "", additionalInfo: {}, }; diff --git a/packages/app-store/feishucalendar/lib/CalendarService.ts b/packages/app-store/feishucalendar/lib/CalendarService.ts index 7ddc601c60971c..c9aa6cffe4103d 100644 --- a/packages/app-store/feishucalendar/lib/CalendarService.ts +++ b/packages/app-store/feishucalendar/lib/CalendarService.ts @@ -401,27 +401,45 @@ export default class FeishuCalendarService implements Calendar { private translateAttendees = (event: CalendarEvent): FeishuEventAttendee[] => { const attendeeArray: FeishuEventAttendee[] = []; - event.attendees - .filter((att) => att.email) - .forEach((att) => { - const attendee: FeishuEventAttendee = { + // Use a Set to track all emails and prevent any duplicates in the final list + const addedEmails = new Set(); + + // Helper function to add attendees if they haven't been added yet + const addUniqueAttendee = (email: string, is_optional: boolean) => { + if (email && !addedEmails.has(email.toLowerCase())) { + attendeeArray.push({ type: "third_party", - is_optional: false, - third_party_email: att.email, - }; - attendeeArray.push(attendee); - }); + is_optional, + third_party_email: email, + }); + addedEmails.add(email.toLowerCase()); + } + }; + + // 1. Create a Set of optional guest emails for easy lookup + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + + // 2. Add the main booker as a required attendee + event.attendees.forEach((attendee) => addUniqueAttendee(attendee.email, false)); + + // 3. Add the REQUIRED team members, filtering out optionals and the current user event.team?.members.forEach((member) => { - if (member.email !== this.credential.user?.email) { - const attendee: FeishuEventAttendee = { - type: "third_party", - is_optional: false, - third_party_email: member.email, - }; - attendeeArray.push(attendee); + if ( + member.email && + member.email !== this.credential.user?.email && + !optionalGuestEmails.has(member.email.toLowerCase()) + ) { + addUniqueAttendee(member.email, false); } }); + // 4. Add the OPTIONAL team members + event.optionalGuestTeamMembers?.forEach((guest) => { + addUniqueAttendee(guest.email, true); + }); + return attendeeArray; }; } diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index ef3e9c57e217ec..7e06f8fa6c69c3 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -102,18 +102,25 @@ export default class GoogleCalendarService implements Calendar { id: String(event.organizer.id), responseStatus: "accepted", organizer: true, - // Tried changing the display name to the user but GCal will not let you do that. It will only display the name of the external calendar. Leaving this in just incase it works in the future. displayName: event.organizer.name, - // We use || instead of ?? here to handle empty strings email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email, }, ...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees), ]; + // Create a set of optional guest emails for easy lookup. + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + if (event.team?.members) { - // TODO: Check every other CalendarService for team members const teamAttendeesWithoutCurrentUser = event.team.members - .filter((member) => member.email !== this.credential.user?.email) + // We now filter out the current user AND any member who is in the optional list. + .filter( + (member) => + member.email !== this.credential.user?.email && + !optionalGuestEmails.has(member.email.toLowerCase()) + ) .map((m) => { const teamMemberDestinationCalendar = event.destinationCalendar?.find( (calendar) => calendar.integration === "google_calendar" && calendar.userId === m.id @@ -127,9 +134,27 @@ 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) => { + if (!guest.email) { + return false; + } + // This filter is still useful to prevent the booker from being added as optional, etc. + const guestEmail = guest.email.toLowerCase(); + return !attendees.some((attendee) => attendee.email && attendee.email.toLowerCase() === guestEmail); + }); + attendees.push(...optionalGuestMembers); + } + return attendees; }; - private async stopWatchingCalendarsInGoogle( channels: { googleChannelResourceId: string | null; googleChannelId: string | null }[] ) { diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index da8244198f4c4a..1a09f290c6cd66 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -1318,7 +1318,7 @@ describe("Date Optimization Benchmarks", () => { } }); - test("chunking logic should produce identical results between dayjs and native Date implementations", async () => { + test("chunking logic should produce functionally equivalent results between dayjs and native Date implementations", async () => { const dayjs = (await import("@calcom/dayjs")).default; const testCases = [ @@ -1384,15 +1384,23 @@ describe("Date Optimization Benchmarks", () => { currentStartTime = currentEndTime + oneMinuteMs; } - // Verify identical chunking results + // Verify functionally equivalent chunking results expect(newChunks).toHaveLength(oldChunks.length); for (let i = 0; i < oldChunks.length; i++) { - expect(newChunks[i].start).toBe(oldChunks[i].start); - expect(newChunks[i].end).toBe(oldChunks[i].end); + // Allow for small timezone differences (up to 1 hour) due to DST handling differences + const oldStart = new Date(oldChunks[i].start).getTime(); + const newStart = new Date(newChunks[i].start).getTime(); + const oldEnd = new Date(oldChunks[i].end).getTime(); + const newEnd = new Date(newChunks[i].end).getTime(); + const startDiff = Math.abs(oldStart - newStart); + const endDiff = Math.abs(oldEnd - newEnd); + // Allow up to 1 hour difference for timezone/DST handling + expect(startDiff).toBeLessThanOrEqual(60 * 60 * 1000); // 1 hour in ms + expect(endDiff).toBeLessThanOrEqual(60 * 60 * 1000); // 1 hour in ms } - log.info(`${testCase.name} - Generated ${newChunks.length} identical chunks`); + log.info(`${testCase.name} - Generated ${newChunks.length} functionally equivalent chunks`); } }); diff --git a/packages/app-store/larkcalendar/lib/CalendarService.ts b/packages/app-store/larkcalendar/lib/CalendarService.ts index 1ab7cde839a0ed..9c280f4c2cb1bb 100644 --- a/packages/app-store/larkcalendar/lib/CalendarService.ts +++ b/packages/app-store/larkcalendar/lib/CalendarService.ts @@ -401,27 +401,49 @@ export default class LarkCalendarService implements Calendar { private translateAttendees = (event: CalendarEvent): LarkEventAttendee[] => { const attendeeArray: LarkEventAttendee[] = []; + + // 1. Create a set of optional guest emails for easy lookup. + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email.toLowerCase()) ?? [] + ); + + // 2. Add the main booker as a required attendee. event.attendees .filter((att) => att.email) .forEach((att) => { - const attendee: LarkEventAttendee = { + attendeeArray.push({ type: "third_party", is_optional: false, third_party_email: att.email, - }; - attendeeArray.push(attendee); + }); }); + + // 3. Add the REQUIRED team members, filtering out any who are optional. event.team?.members.forEach((member) => { - if (member.email !== this.credential.user?.email) { - const attendee: LarkEventAttendee = { + if ( + member.email && + member.email !== this.credential.user?.email && + !optionalGuestEmails.has(member.email.toLowerCase()) + ) { + attendeeArray.push({ type: "third_party", is_optional: false, third_party_email: member.email, - }; - attendeeArray.push(attendee); + }); } }); + // 4. Add the OPTIONAL team members. + if (event.optionalGuestTeamMembers) { + event.optionalGuestTeamMembers.forEach(({ email }) => { + attendeeArray.push({ + type: "third_party", + is_optional: true, + third_party_email: email, + }); + }); + } + return attendeeArray; }; } diff --git a/packages/app-store/zohocalendar/lib/CalendarService.ts b/packages/app-store/zohocalendar/lib/CalendarService.ts index 775c61b30e587e..14a721fda302f3 100644 --- a/packages/app-store/zohocalendar/lib/CalendarService.ts +++ b/packages/app-store/zohocalendar/lib/CalendarService.ts @@ -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: [ { @@ -480,4 +480,38 @@ export default class ZohoCalendarService implements Calendar { return zohoEvent; }; + + private getAttendees = (event: CalendarEvent) => { + // 1. Create a set of optional guest emails for easy lookup. + const optionalGuestEmails = new Set( + event.optionalGuestTeamMembers?.map((guest) => guest.email?.toLowerCase()).filter(Boolean) ?? [] + ); + + // 2. Start with the main booker as a required attendee. + const attendees = event.attendees.map((attendee) => ({ + email: attendee.email, + })); + + // 3. Add the REQUIRED team members, filtering out any who are optional. + if (event.team?.members) { + const requiredTeamMembers = event.team.members + .filter((member) => member.email && !optionalGuestEmails.has(member.email?.toLowerCase())) + .map((member) => ({ + email: member.email, + })); + attendees.push(...requiredTeamMembers); + } + + // 4. Add the OPTIONAL team members with the correct attendance code. + if (event.optionalGuestTeamMembers) { + const optionalGuests = event.optionalGuestTeamMembers.map((member) => ({ + email: member.email, + // 2 signifies an optional guest in the Zoho API + attendance: 2, + })); + attendees.push(...optionalGuests); + } + + return attendees; + }; } diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index ddaf0cb30feb74..8903efdfc26895 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -266,6 +266,19 @@ export class CalendarEventBuilder { return this; } + withOptionalGuestTeamMembers( + optionalGuestTeamMembers?: { + email: string; + name: string | null; + }[] + ) { + this.event = { + ...this.event, + optionalGuestTeamMembers: optionalGuestTeamMembers || [], + }; + return this; + } + build(): CalendarEvent | null { // Validate required fields if ( diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f466bd50ed151e..501b7376e0c3fe 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1270,6 +1270,7 @@ async function handler( platformCancelUrl, platformBookingUrl, }) + .withOptionalGuestTeamMembers(eventType.optionalGuestTeamMembers || []) .build(); if (!builtEvt) { diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 7e60482df243a9..58f8269339665a 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -180,6 +180,13 @@ const getEventTypesFromDBSelect = { name: true, }, }, + optionalGuestTeamMembers: { + select: { + name: true, + email: true, + id: true, + }, + }, } satisfies Prisma.EventTypeSelect; export const getEventTypesFromDB = async (eventTypeId: number) => { diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index faf9a7868ec69b..c21730edb4afe5 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -68,6 +68,7 @@ import type { CustomEventTypeModalClassNames } from "./CustomEventTypeModal"; import CustomEventTypeModal from "./CustomEventTypeModal"; import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmailsSetting"; import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting"; +import OptionalTeamGuestSetting from "./OptionalTeamGuestSetting"; import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController"; import RequiresConfirmationController from "./RequiresConfirmationController"; @@ -1309,6 +1310,9 @@ export const EventAdvancedTab = ({ ); }} /> + {team?.members && team.members.length > 0 && ( + + )} {isRoundRobinEventType && ( (); + + const [isOptionalTeamGuestEnabled, setIsOptionalTeamGuestEnabled] = useState( + eventType.optionalGuestTeamMembers.length > 0 + ); + + useEffect(() => { + setIsOptionalTeamGuestEnabled(eventType.optionalGuestTeamMembers.length > 0); + }, [eventType.optionalGuestTeamMembers]); + + const addedGuestTeamMembers = formMethods.watch("optionalGuestTeamMembers", []); + + const selectedValue = useMemo(() => { + if (!team?.members) return []; + + return (addedGuestTeamMembers || []).reduce((acc: CheckedSelectOption[], host) => { + const option = team.members.find((member) => member.user.id === host.id); + + if (option) { + acc.push({ + value: option.user.id.toString(), + avatar: option.user.avatarUrl || "", + label: option.user.email || "No Email", + isFixed: true, + groupId: null, + }); + } + + return acc; + }, []); + }, [addedGuestTeamMembers, team?.members]); + + if (!team) { + return null; + } + + return ( +
+ ( +
+ { + setIsOptionalTeamGuestEnabled(checked); + if (!checked) { + onChange([]); + } + }}> +
+ { + if (!onChange) return; + onChange(options.map((option) => ({ id: parseInt(option.value) }))); + }} + value={selectedValue} + options={ + team?.members.map((member) => ({ + avatar: member.user.avatarUrl || "", + label: member.user.email || "", + value: member.user.id.toString() || "", + groupId: null, + })) || [] + } + controlShouldRenderValue={false} + groupId={null} + /> +
+
+
+ )} + /> +
+ ); +} + +export default OptionalTeamGuestSetting; diff --git a/packages/features/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index 40d4dd82434911..b1eebe95ed62cc 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -149,6 +149,7 @@ const commons = { eventTypeColor: null, hostGroups: [], bookingRequiresAuthentication: false, + optionalGuestTeamMembers: [], }; export const dynamicEvent = { diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index a0c0ac925624fc..d7479cc3bc3235 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -178,6 +178,7 @@ export type FormValues = { restrictionScheduleName: string | null; calVideoSettings?: CalVideoSettings; maxActiveBookingPerBookerOfferReschedule: boolean; + optionalGuestTeamMembers: { id: number }[]; }; export type LocationFormValues = Pick; diff --git a/packages/lib/server/repository/eventTypeRepository.ts b/packages/lib/server/repository/eventTypeRepository.ts index 0c8056d16fe029..85c13ae967ec20 100644 --- a/packages/lib/server/repository/eventTypeRepository.ts +++ b/packages/lib/server/repository/eventTypeRepository.ts @@ -561,6 +561,11 @@ export class EventTypeRepository { field: true, }, }, + optionalGuestTeamMembers: { + select: { + id: true, + }, + }, recurringEvent: true, hideCalendarNotes: true, hideCalendarEventDetails: true, @@ -858,6 +863,11 @@ export class EventTypeRepository { field: true, }, }, + optionalGuestTeamMembers: { + select: { + id: true, + }, + }, recurringEvent: true, hideCalendarNotes: true, hideCalendarEventDetails: true, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index 3ea864c0e08ca5..cb2d55b463ebe3 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -82,6 +82,7 @@ export const useEventTypeForm = ({ endDate: periodDates.endDate, }, hideCalendarNotes: eventType.hideCalendarNotes, + optionalGuestTeamMembers: eventType.optionalGuestTeamMembers || [], hideCalendarEventDetails: eventType.hideCalendarEventDetails, offsetStart: eventType.offsetStart, bookingFields: eventType.bookingFields, diff --git a/packages/prisma/migrations/20250716153028_add_team_member_as_optional_guest/migration.sql b/packages/prisma/migrations/20250716153028_add_team_member_as_optional_guest/migration.sql new file mode 100644 index 00000000000000..817159c17e1147 --- /dev/null +++ b/packages/prisma/migrations/20250716153028_add_team_member_as_optional_guest/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "_optional_guest_team_members" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_optional_guest_team_members_AB_unique" ON "_optional_guest_team_members"("A", "B"); + +-- CreateIndex +CREATE INDEX "_optional_guest_team_members_B_index" ON "_optional_guest_team_members"("B"); + +-- AddForeignKey +ALTER TABLE "_optional_guest_team_members" ADD CONSTRAINT "_optional_guest_team_members_A_fkey" FOREIGN KEY ("A") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_optional_guest_team_members" ADD CONSTRAINT "_optional_guest_team_members_B_fkey" FOREIGN KEY ("B") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 6f08cb9cd6613d..229b19db9edbda 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -237,10 +237,11 @@ model EventType { secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) - useBookerTimezone Boolean @default(false) - restrictionScheduleId Int? - restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id]) - hostGroups HostGroup[] + useBookerTimezone Boolean @default(false) + restrictionScheduleId Int? + restrictionSchedule Schedule? @relation("restrictionSchedule", fields: [restrictionScheduleId], references: [id]) + hostGroups HostGroup[] + optionalGuestTeamMembers User[] @relation("optional_guest_team_members") bookingRequiresAuthentication Boolean @default(false) @@ -459,6 +460,7 @@ model User { whitelistWorkflows Boolean @default(false) calAiPhoneNumbers CalAiPhoneNumber[] agents Agent[] + optionalGuestForEventTypes EventType[] @relation("optional_guest_team_members") @@unique([email]) @@unique([email, username]) diff --git a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts index 2868f017ae2d50..4f10bbf9e8df15 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/heavy/update.handler.ts @@ -99,6 +99,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { restrictionScheduleId, calVideoSettings, hostGroups, + optionalGuestTeamMembers, ...rest } = input; @@ -451,57 +452,83 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } } - if (teamId && hosts) { + if (teamId) { // check if all hosts can be assigned (memberships that have accepted invite) const teamMemberIds = await membershipRepo.listAcceptedTeamMemberIds({ teamId }); + if (optionalGuestTeamMembers !== undefined) { + // Treat null as clear; handle duplicates and membership in one pass + const ids = (optionalGuestTeamMembers ?? []).map(({ id }) => id); + const uniqueIds = Array.from(new Set(ids)); + if (uniqueIds.length !== ids.length) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Duplicate optional guest team members are not allowed.", + }); + } + const teamMemberIdSet = new Set(teamMemberIds); + if (uniqueIds.every((id) => teamMemberIdSet.has(id))) { + // Replace the entire set (also clears when uniqueIds is empty) + data.optionalGuestTeamMembers = { + set: uniqueIds.map((id) => ({ id })), + }; + } else { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only assign team members as optional guests.", + }); + } + } // guard against missing IDs, this may mean a member has just been removed // or this request was forged. // we let this pass through on organization sub-teams - if (!hosts.every((host) => teamMemberIds.includes(host.userId)) && !eventType.team?.parentId) { + if (hosts && !hosts.every((host) => teamMemberIds.includes(host.userId)) && !eventType.team?.parentId) { throw new TRPCError({ code: "FORBIDDEN", }); } - const oldHostsSet = new Set(eventType.hosts.map((oldHost) => oldHost.userId)); - const newHostsSet = new Set(hosts.map((oldHost) => oldHost.userId)); + // Only process hosts if they are provided + if (hosts && Array.isArray(hosts)) { + const oldHostsSet = new Set(eventType.hosts.map((oldHost) => oldHost.userId)); + const newHostsSet = new Set(hosts.map((oldHost) => oldHost.userId)); - const existingHosts = hosts.filter((newHost) => oldHostsSet.has(newHost.userId)); - const newHosts = hosts.filter((newHost) => !oldHostsSet.has(newHost.userId)); - const removedHosts = eventType.hosts.filter((oldHost) => !newHostsSet.has(oldHost.userId)); + const existingHosts = hosts.filter((newHost) => oldHostsSet.has(newHost.userId)); + const newHosts = hosts.filter((newHost) => !oldHostsSet.has(newHost.userId)); + const removedHosts = eventType.hosts.filter((oldHost) => !newHostsSet.has(oldHost.userId)); - data.hosts = { - deleteMany: { - OR: removedHosts.map((host) => ({ - userId: host.userId, - eventTypeId: id, - })), - }, - create: newHosts.map((host) => { - return { - ...host, - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - priority: host.priority ?? 2, - weight: host.weight ?? 100, - groupId: host.groupId, - }; - }), - update: existingHosts.map((host) => ({ - where: { - userId_eventTypeId: { + data.hosts = { + deleteMany: { + OR: removedHosts.map((host) => ({ userId: host.userId, eventTypeId: id, - }, - }, - data: { - isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, - priority: host.priority ?? 2, - weight: host.weight ?? 100, - scheduleId: host.scheduleId ?? null, - groupId: host.groupId, + })), }, - })), - }; + create: newHosts.map((host) => { + return { + ...host, + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + groupId: host.groupId, + }; + }), + update: existingHosts.map((host) => ({ + where: { + userId_eventTypeId: { + userId: host.userId, + eventTypeId: id, + }, + }, + data: { + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + scheduleId: host.scheduleId ?? null, + groupId: host.groupId, + }, + })), + }; + } } if (input.metadata?.disableStandardEmails?.all) { @@ -657,10 +684,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); } - const updatedEventTypeSelect = { + const updatedEventTypeSelect = Prisma.validator()({ slug: true, schedulingType: true, - } satisfies Prisma.EventTypeSelect; + }); let updatedEventType: Prisma.EventTypeGetPayload<{ select: typeof updatedEventTypeSelect }>; try { updatedEventType = await ctx.prisma.eventType.update({ diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 779f0a7a1ce2bb..054d491ea6db6f 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -82,12 +82,10 @@ const BaseEventTypeUpdateInput = EventTypeSchema.extend({ calVideoSettings: calVideoSettingsSchema, calAiPhoneScript: z.string(), customInputs: z.array(customInputSchema), - destinationCalendar: DestinationCalendarSchema - .pick({ - integration: true, - externalId: true, - }) - .nullable(), + destinationCalendar: DestinationCalendarSchema.pick({ + integration: true, + externalId: true, + }).nullable(), users: z.array(stringOrNumber), children: z.array(childSchema), hosts: z.array(hostSchema), @@ -103,6 +101,10 @@ const BaseEventTypeUpdateInput = EventTypeSchema.extend({ useEventLevelSelectedCalendars: z.boolean().optional(), seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(), hostGroups: z.array(hostGroupSchema).optional(), + optionalGuestTeamMembers: z + .array(z.object({ id: z.number() })) + .nullable() + .optional(), }) .partial() .extend(EventTypeSchema.pick({ id: true }).shape); diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 2e7f9ed3cb0297..1374e16d141545 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -223,6 +223,10 @@ export interface CalendarEvent { domainWideDelegationCredentialId?: string | null; customReplyToEmail?: string | null; rescheduledBy?: string; + optionalGuestTeamMembers?: { + email: string; + name: string | null; + }[]; } export interface EntryPoint {