From 4ebf0e8adf87cb9dd1d4118cb141ad52c70d2485 Mon Sep 17 00:00:00 2001 From: Jayant Pranjal Date: Wed, 2 Jul 2025 02:17:21 +0000 Subject: [PATCH] feat: add optional guest members from team --- apps/web/public/static/locales/en/common.json | 2 + .../lib/CalendarService.ts | 12 +++ .../lib/CalendarService.ts | 12 +++ .../exchangecalendar/lib/CalendarService.ts | 15 +++ .../feishucalendar/lib/CalendarService.ts | 34 +++++-- .../googlecalendar/lib/CalendarService.ts | 18 ++++ .../larkcalendar/lib/CalendarService.ts | 11 +++ .../zohocalendar/lib/CalendarService.ts | 16 +++- packages/features/CalendarEventBuilder.ts | 12 +++ .../features/bookings/lib/handleNewBooking.ts | 1 + .../handleNewBooking/getEventTypesFromDB.ts | 7 ++ .../tabs/advanced/EventAdvancedTab.tsx | 4 + .../advanced/GuestTeamMemberController.tsx | 94 +++++++++++++++++++ packages/features/eventtypes/lib/types.ts | 1 + packages/lib/defaultEvents.ts | 1 + packages/lib/server/repository/eventType.ts | 5 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 17 ++++ packages/prisma/schema.prisma | 87 ++++++++--------- .../server/routers/viewer/eventTypes/types.ts | 4 + .../viewer/eventTypes/update.handler.ts | 85 ++++++++++------- packages/types/Calendar.d.ts | 4 + 22 files changed, 359 insertions(+), 84 deletions(-) create mode 100644 packages/features/eventtypes/components/tabs/advanced/GuestTeamMemberController.tsx create mode 100644 packages/prisma/migrations/20250628001746_add_optional_guest_team_members/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4df124bc83c4df..0274a7a242b74d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/exchange2013calendar/lib/CalendarService.ts b/packages/app-store/exchange2013calendar/lib/CalendarService.ts index 13f17e28216b58..b6a8c832c6ac12 100644 --- a/packages/app-store/exchange2013calendar/lib/CalendarService.ts +++ b/packages/app-store/exchange2013calendar/lib/CalendarService.ts @@ -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 { @@ -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 diff --git a/packages/app-store/exchange2016calendar/lib/CalendarService.ts b/packages/app-store/exchange2016calendar/lib/CalendarService.ts index 0f97cadc39dc8d..e3fdaddc7d169d 100644 --- a/packages/app-store/exchange2016calendar/lib/CalendarService.ts +++ b/packages/app-store/exchange2016calendar/lib/CalendarService.ts @@ -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)); + }); + } + await appointment.Save(SendInvitationsMode.SendToAllAndSaveCopy); return { @@ -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)); + }); + } appointment.Update( ConflictResolutionMode.AlwaysOverwrite, SendInvitationsOrCancellationsMode.SendToAllAndSaveCopy diff --git a/packages/app-store/exchangecalendar/lib/CalendarService.ts b/packages/app-store/exchangecalendar/lib/CalendarService.ts index b781761e2e18d0..635b28a6e3d215 100644 --- a/packages/app-store/exchangecalendar/lib/CalendarService.ts +++ b/packages/app-store/exchangecalendar/lib/CalendarService.ts @@ -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(() => { @@ -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, diff --git a/packages/app-store/feishucalendar/lib/CalendarService.ts b/packages/app-store/feishucalendar/lib/CalendarService.ts index 7ddc601c60971c..6a8dd28e9df887 100644 --- a/packages/app-store/feishucalendar/lib/CalendarService.ts +++ b/packages/app-store/feishucalendar/lib/CalendarService.ts @@ -401,27 +401,47 @@ export default class FeishuCalendarService implements Calendar { private translateAttendees = (event: CalendarEvent): FeishuEventAttendee[] => { const attendeeArray: FeishuEventAttendee[] = []; + const mandatoryEmails = new Set(); + const optionalEmails = new Set(); + + // 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; }; } diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 6c445e2a9c1843..63717441025c96 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -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; }; diff --git a/packages/app-store/larkcalendar/lib/CalendarService.ts b/packages/app-store/larkcalendar/lib/CalendarService.ts index 1ab7cde839a0ed..5597667dcc8fa2 100644 --- a/packages/app-store/larkcalendar/lib/CalendarService.ts +++ b/packages/app-store/larkcalendar/lib/CalendarService.ts @@ -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; }; } diff --git a/packages/app-store/zohocalendar/lib/CalendarService.ts b/packages/app-store/zohocalendar/lib/CalendarService.ts index 775c61b30e587e..509aef9a55e950 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,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; + }; } diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index a4d9bae742d0cd..d7f8510ebe24b4 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -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 diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 113cb225f37a1a..4fc3838b5eed04 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1152,6 +1152,7 @@ async function handler( platformCancelUrl, platformBookingUrl, }) + .withOptionalGuestTeamMembers(eventType.optionalGuestTeamMembers || []) .build(); if (input.bookingData.thirdPartyRecurringEventId) { diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index dc4db93820ae40..c03f8d51fbf960 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -172,6 +172,13 @@ export const getEventTypesFromDB = async (eventTypeId: number) => { assignRRMembersUsingSegment: true, rrSegmentQueryValue: true, useEventLevelSelectedCalendars: true, + optionalGuestTeamMembers: { + select: { + name: true, + email: true, + id: true, + }, + }, }, }); diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 253d12e3cd1a08..878b2c26251374 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -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"; @@ -1201,6 +1202,9 @@ export const EventAdvancedTab = ({ )} /> + {team?.members && team.members.length > 0 && ( + + )} {isRoundRobinEventType && ( (); + + 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 ( +
+ ( +
+ { + setIsGuestTeamMembersEnabled(checked); + if (!checked) { + onChange([]); + } + }}> +
+ { + 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} + /> +
+
+
+ )} + /> +
+ ); +} + +export default GuestTeamMemberController; diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 782fa2c827da09..0f7cb75234895c 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -164,6 +164,7 @@ export type FormValues = { redirectUrlOnExit?: string; }; maxActiveBookingPerBookerOfferReschedule: boolean; + optionalGuestTeamMembers: { id: number }[]; }; export type LocationFormValues = Pick; diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 2e7539d6a22688..82c8d8b1d18072 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -144,6 +144,7 @@ const commons = { instantMeetingScheduleId: null, instantMeetingParameters: [], eventTypeColor: null, + optionalGuestTeamMembers: [], }; export const dynamicEvent = { diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index fa4330484db53d..ee308bbf8a0f5e 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -514,6 +514,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 59ea173f6c2bd8..f5b122abeb6bc0 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -55,6 +55,7 @@ export const useEventTypeForm = ({ scheduleName: eventType.scheduleName, periodDays: eventType.periodDays, requiresBookerEmailVerification: eventType.requiresBookerEmailVerification, + optionalGuestTeamMembers: eventType.optionalGuestTeamMembers || [], seatsPerTimeSlot: eventType.seatsPerTimeSlot, seatsShowAttendees: eventType.seatsShowAttendees, seatsShowAvailabilityCount: eventType.seatsShowAvailabilityCount, diff --git a/packages/prisma/migrations/20250628001746_add_optional_guest_team_members/migration.sql b/packages/prisma/migrations/20250628001746_add_optional_guest_team_members/migration.sql new file mode 100644 index 00000000000000..817159c17e1147 --- /dev/null +++ b/packages/prisma/migrations/20250628001746_add_optional_guest_team_members/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 61e071108ffe53..0d4d2de4f38c9c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -200,6 +200,8 @@ model EventType { eventTypeColor Json? rescheduleWithSameRoundRobinHost Boolean @default(false) + optionalGuestTeamMembers User[] @relation("optional_guest_team_members") + secondaryEmailId Int? secondaryEmail SecondaryEmail? @relation(fields: [secondaryEmailId], references: [id], onDelete: Cascade) @@ -307,54 +309,55 @@ model TravelSchedule { // It holds Personal Profiles of a User plus it has email, password and other core things.. model User { - id Int @id @default(autoincrement()) - username String? - name String? + id Int @id @default(autoincrement()) + username String? + name String? /// @zod.custom(imports.emailSchema) - email String - emailVerified DateTime? - password UserPassword? - bio String? - avatarUrl String? - timeZone String @default("Europe/London") - travelSchedules TravelSchedule[] - weekStart String @default("Sunday") + email String + emailVerified DateTime? + password UserPassword? + bio String? + avatarUrl String? + timeZone String @default("Europe/London") + travelSchedules TravelSchedule[] + weekStart String @default("Sunday") // DEPRECATED - TO BE REMOVED - startTime Int @default(0) - endTime Int @default(1440) + startTime Int @default(0) + endTime Int @default(1440) // - bufferTime Int @default(0) - hideBranding Boolean @default(false) + bufferTime Int @default(0) + hideBranding Boolean @default(false) // TODO: should be renamed since it only affects the booking page - theme String? - appTheme String? - createdDate DateTime @default(now()) @map(name: "created") - trialEndsAt DateTime? - lastActiveAt DateTime? - eventTypes EventType[] @relation("user_eventtype") - credentials Credential[] - teams Membership[] - bookings Booking[] - schedules Schedule[] - defaultScheduleId Int? - selectedCalendars SelectedCalendar[] - completedOnboarding Boolean @default(false) - locale String? - timeFormat Int? @default(12) - twoFactorSecret String? - twoFactorEnabled Boolean @default(false) - backupCodes String? - identityProvider IdentityProvider @default(CAL) - identityProviderId String? - availability Availability[] - invitedTo Int? - webhooks Webhook[] - brandColor String? - darkBrandColor String? + theme String? + appTheme String? + createdDate DateTime @default(dbgenerated("now() AT TIME ZONE 'UTC'")) @map(name: "created") + trialEndsAt DateTime? + lastActiveAt DateTime? + eventTypes EventType[] @relation("user_eventtype") + optionalGuestTeamMemberFor EventType[] @relation("optional_guest_team_members") + credentials Credential[] + teams Membership[] + bookings Booking[] + schedules Schedule[] + defaultScheduleId Int? + selectedCalendars SelectedCalendar[] + completedOnboarding Boolean @default(false) + locale String? + timeFormat Int? @default(12) + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + backupCodes String? + identityProvider IdentityProvider @default(CAL) + identityProviderId String? + availability Availability[] + invitedTo Int? + webhooks Webhook[] + brandColor String? + darkBrandColor String? // the location where the events will end up - destinationCalendar DestinationCalendar? + destinationCalendar DestinationCalendar? // participate in dynamic group booking or not - allowDynamicBooking Boolean? @default(true) + allowDynamicBooking Boolean? @default(true) // participate in SEO indexing or not allowSEOIndexing Boolean? @default(true) diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index f7e0ae2464a73d..266202d1e0c64f 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -86,6 +86,10 @@ const BaseEventTypeUpdateInput = _EventTypeModel rrSegmentQueryValue: rrSegmentQueryValueSchema.optional(), useEventLevelSelectedCalendars: z.boolean().optional(), seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(), + optionalGuestTeamMembers: z + .array(z.object({ id: z.number() })) + .nullable() + .optional(), }) .partial() .extend(_EventTypeModel.pick({ id: true }).shape); diff --git a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts index 6ca597b9f4dd12..a4b3c80579f599 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/update.handler.ts @@ -91,6 +91,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { seatsPerTimeSlot, restrictionScheduleId, calVideoSettings, + optionalGuestTeamMembers = [], ...rest } = input; @@ -389,13 +390,26 @@ 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 && optionalGuestTeamMembers.length > 0) { + if (optionalGuestTeamMembers.every((each) => teamMemberIds.includes(each.id))) { + data.optionalGuestTeamMembers = { + set: [], + connect: optionalGuestTeamMembers.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", }); @@ -405,43 +419,46 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const isWeightsEnabled = isRRWeightsEnabled || (typeof isRRWeightsEnabled === "undefined" && eventType.isRRWeightsEnabled); - 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, - }; - }), - 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, - }, - })), - }; + create: newHosts.map((host) => { + return { + ...host, + isFixed: data.schedulingType === SchedulingType.COLLECTIVE || host.isFixed, + priority: host.priority ?? 2, + weight: host.weight ?? 100, + }; + }), + 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, + }, + })), + }; + } } if (input.metadata?.disableStandardEmails?.all) { diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 656c7b6c0a1ac1..bf26f7da1308e5 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -221,6 +221,10 @@ export interface CalendarEvent { domainWideDelegationCredentialId?: string | null; customReplyToEmail?: string | null; rescheduledBy?: string; + optionalGuestTeamMembers?: { + email: string; + name: string | null; + }[]; } export interface EntryPoint {