diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index dcd87cb72d..d71df63192 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -30,7 +30,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.95", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.99", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts index 675b94cf69..30512f2ab8 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/inputs/create-booking.input.ts @@ -169,4 +169,19 @@ export class CreateBookingInput_2024_04_15 { label?: string | undefined; } >; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + teamMemberEmail?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + crmAppSlug?: string; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + crmOwnerRecordType?: string; } diff --git a/apps/api/v2/src/modules/router/controllers/router.controller.ts b/apps/api/v2/src/modules/router/controllers/router.controller.ts index 7ead58090f..eec6286be7 100644 --- a/apps/api/v2/src/modules/router/controllers/router.controller.ts +++ b/apps/api/v2/src/modules/router/controllers/router.controller.ts @@ -1,9 +1,13 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; import { Controller, Req, NotFoundException, Param, Post, Body } from "@nestjs/common"; import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; import { Request } from "express"; -import { getRoutedUrl } from "@calcom/platform-libraries"; +import { + getRoutedUrl, + getTeamMemberEmailForResponseOrContactUsingUrlQuery, +} from "@calcom/platform-libraries"; import { ApiResponse } from "@calcom/platform-types"; @Controller({ @@ -13,6 +17,8 @@ import { ApiResponse } from "@calcom/platform-types"; @DocsTags("Router controller") @DocsExcludeController(true) export class RouterController { + constructor(private readonly teamsEventTypesRepository: TeamsEventTypesRepository) {} + @Post("/forms/:formId/submit") async getRoutingFormResponse( @Req() request: Request, @@ -27,13 +33,54 @@ export class RouterController { } if (routedUrlData?.redirect?.destination) { - return { status: "success", data: routedUrlData?.redirect?.destination, redirect: true }; + return this.handleRedirect(routedUrlData.redirect.destination); } if (routedUrlData?.props) { - return { status: "success", data: { message: routedUrlData?.props?.message ?? "" }, redirect: false }; + return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false }; } return { status: "success", data: { message: "No Route nor custom message found." }, redirect: false }; } + + private async handleRedirect(destination: string): Promise & { redirect: boolean }> { + const routingUrl = new URL(destination); + const routingSearchParams = routingUrl.searchParams; + if ( + routingSearchParams.get("cal.action") === "eventTypeRedirectUrl" && + routingSearchParams.has("email") && + routingSearchParams.has("cal.teamId") && + !routingSearchParams.has("cal.skipContactOwner") + ) { + return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams); + } + + return { status: "success", data: destination, redirect: true }; + } + + private async handleRedirectWithContactOwner( + routingUrl: URL, + routingSearchParams: URLSearchParams + ): Promise & { redirect: boolean }> { + const pathNameParams = routingUrl.pathname.split("/"); + const eventTypeSlug = pathNameParams[pathNameParams.length - 1]; + const teamId = Number(routingSearchParams.get("cal.teamId")); + const eventTypeData = this.teamsEventTypesRepository.getTeamEventTypeBySlug(teamId, eventTypeSlug, 3); + + // get the salesforce record owner email for the email given as a form response. + const { + email: teamMemberEmail, + recordType: crmOwnerRecordType, + crmAppSlug, + } = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query: Object.fromEntries(routingSearchParams), + eventData: eventTypeData, + }); + + Boolean(teamMemberEmail) && routingUrl.searchParams.set("cal.teamMemberEmail", teamMemberEmail); + Boolean(crmOwnerRecordType) && routingUrl.searchParams.set("cal.crmOwnerRecordType", crmOwnerRecordType); + Boolean(crmAppSlug) && routingUrl.searchParams.set("cal.crmAppSlug", crmAppSlug); + + return { status: "success", data: routingUrl.toString(), redirect: true }; + } } diff --git a/apps/api/v2/src/modules/router/router.module.ts b/apps/api/v2/src/modules/router/router.module.ts index f7725675f4..7bb2859f9b 100644 --- a/apps/api/v2/src/modules/router/router.module.ts +++ b/apps/api/v2/src/modules/router/router.module.ts @@ -1,10 +1,11 @@ import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RouterController } from "@/modules/router/controllers/router.controller"; +import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository"; import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule], - providers: [], + providers: [TeamsEventTypesRepository], exports: [], controllers: [RouterController], }) diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 34861ab468..8739dfad24 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -5593,6 +5593,14 @@ ], "type": "string" } + }, + { + "name": "teamMemberEmail", + "required": false, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 6f40c0d609..d6fb2413cc 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -2,13 +2,13 @@ import type { GetServerSidePropsContext } from "next"; import { z } from "zod"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains"; import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import slugify from "@calcom/lib/slugify"; import prisma from "@calcom/prisma"; +import type { User } from "@calcom/prisma/client"; import { RedirectType } from "@calcom/prisma/client"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; @@ -21,9 +21,6 @@ const paramsSchema = z.object({ slug: z.string().transform((s) => slugify(s)), }); -// Booker page fetches a tiny bit of data server side: -// 1. Check if team exists, to show 404 -// 2. If rescheduling, get the booking details export const getServerSideProps = async (context: GetServerSidePropsContext) => { const { req, params, query } = context; const session = await getServerSession({ req }); @@ -45,7 +42,90 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => } } - const team = await prisma.team.findFirst({ + const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain); + + if (!team || !team.eventTypes?.[0]) { + return { notFound: true } as const; + } + + const eventData = team.eventTypes[0]; + const eventTypeId = eventData.id; + const eventHostsUserData = await getUsersData( + team.isPrivate, + eventTypeId, + eventData.hosts.map((h) => h.user) + ); + const orgSlug = isValidOrgDomain ? currentOrgDomain : null; + const name = team.parent?.name ?? team.name ?? null; + + const booking = rescheduleUid ? await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : null; + const ssr = await ssrInit(context); + const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true"; + const isUnpublished = team.parent ? !team.parent.slug : !team.slug; + + const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import( + "@calcom/lib/server/getTeamMemberEmailFromCrm" + ); + const { + email: teamMemberEmail, + recordType: crmOwnerRecordType, + crmAppSlug, + } = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ + query, + eventData, + }); + + const organizationSettings = getOrganizationSEOSettings(team); + const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false; + + return { + props: { + eventData: { + eventTypeId, + entity: { + fromRedirectOfNonOrgLink, + considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink, + orgSlug, + teamSlug: team.slug ?? null, + name, + }, + length: eventData.length, + metadata: EventTypeMetaDataSchema.parse(eventData.metadata), + profile: { + image: team.parent + ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) + : getPlaceholderAvatar(team.logoUrl, team.name), + name, + username: orgSlug ?? null, + }, + title: eventData.title, + users: eventHostsUserData, + hidden: eventData.hidden, + }, + booking, + user: teamSlug, + teamId: team.id, + slug: meetingSlug, + trpcState: ssr.dehydrate(), + isBrandingHidden: team?.hideBranding, + isInstantMeeting: eventData && queryIsInstantMeeting ? true : false, + themeBasis: null, + orgBannerUrl: team.parent?.bannerUrl ?? "", + teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, + isSEOIndexable: allowSEOIndexing, + }, + }; +}; + +const getTeamWithEventsData = async ( + teamSlug: string, + meetingSlug: string, + isValidOrgDomain: boolean, + currentOrgDomain: string | null +) => { + return await prisma.team.findFirst({ where: { ...getSlugOrRequestedSlug(teamSlug), parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null, @@ -107,32 +187,22 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }, }); +}; - if (!team || !team.eventTypes?.[0]) { - return { - notFound: true, - } as const; - } - const eventData = team.eventTypes[0]; - const eventTypeId = eventData.id; - - // INFO: This code was pulled from getPublicEvent and used here. - // Calling the tRPC fetch to get the public event data is incredibly slow - // for large teams and we don't want to add it back. Future refactors will happen - // to speed up this call. - let users: { username: string; name: string }[] = []; - - if (!team.isPrivate && eventData.hosts.length > 0) { - users = eventData.hosts - .filter((host) => host.user.username) - .map((host) => ({ - username: host.user.username ?? "", - name: host.user.name ?? "", +const getUsersData = async ( + isPrivateTeam: boolean, + eventTypeId: number, + users: Pick[] +) => { + if (!isPrivateTeam && users.length > 0) { + return users + .filter((user) => user.username) + .map((user) => ({ + username: user.username ?? "", + name: user.name ?? "", })); } - if (!team.isPrivate && eventData.hosts.length === 0) { - // a minimalistic version of `getOwnerFromUsersArray` in `getPublicEvent.ts` - // backward compatibility logic for team event types that have users[] but not hosts[] + if (!isPrivateTeam && users.length === 0) { const { users: data } = await prisma.eventType.findUniqueOrThrow({ where: { id: eventTypeId }, select: { @@ -146,85 +216,15 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, }); - if (data.length > 0) { - users = [ - { - username: data[0].username ?? "", - name: data[0].name ?? "", - }, - ]; - } + return data.length > 0 + ? [ + { + username: data[0].username ?? "", + name: data[0].name ?? "", + }, + ] + : []; } - const orgSlug = isValidOrgDomain ? currentOrgDomain : null; - const name = team.parent?.name ?? team.name ?? null; - - let booking: GetBookingType | null = null; - if (rescheduleUid) { - booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id); - } - - const ssr = await ssrInit(context); - const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true"; - const isUnpublished = team.parent ? !team.parent.slug : !team.slug; - const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import( - "@calcom/lib/server/getTeamMemberEmailFromCrm" - ); - const { - email: teamMemberEmail, - recordType: crmOwnerRecordType, - crmAppSlug, - } = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({ - query, - eventData, - }); - - const organizationSettings = getOrganizationSEOSettings(team); - const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false; - - if (!eventData) { - return { - notFound: true, - } as const; - } - - return { - props: { - eventData: { - eventTypeId, - entity: { - fromRedirectOfNonOrgLink, - considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink, - orgSlug, - teamSlug: team.slug ?? null, - name, - }, - length: eventData.length, - metadata: EventTypeMetaDataSchema.parse(eventData.metadata), - profile: { - image: team.parent - ? getPlaceholderAvatar(team.parent.logoUrl, team.parent.name) - : getPlaceholderAvatar(team.logoUrl, team.name), - name, - username: orgSlug ?? null, - }, - title: eventData.title, - users, - hidden: eventData.hidden, - }, - booking, - user: teamSlug, - teamId: team.id, - slug: meetingSlug, - trpcState: ssr.dehydrate(), - isBrandingHidden: team?.hideBranding, - isInstantMeeting: eventData && queryIsInstantMeeting ? true : false, - themeBasis: null, - orgBannerUrl: team.parent?.bannerUrl ?? "", - teamMemberEmail, - crmOwnerRecordType, - crmAppSlug, - isSEOIndexable: allowSEOIndexing, - }, - }; + return []; }; diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 1fa8046015..68b3b5980b 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -5380,6 +5380,14 @@ "enum": ["range", "time"], "type": "string" } + }, + { + "name": "teamMemberEmail", + "required": false, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -7338,7 +7346,7 @@ }, "disableOnPrefill": { "type": "boolean", - "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio button will be selected and the select field will be disabled." + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio buttom will be selected and the select field will be disabled." }, "hidden": { "type": "boolean", @@ -7946,7 +7954,7 @@ }, "beforeEventBuffer": { "type": "number", - "description": "Time spaces that can be prepended before an event to give more time before it." + "description": "Time spaces that can be pre-pended before an event to give more time before it." }, "afterEventBuffer": { "type": "number", @@ -8997,7 +9005,7 @@ }, "disableOnPrefill": { "type": "boolean", - "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio button will be selected and the select field will be disabled." + "description": "Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value. For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`, the 'italian' radio buttom will be selected and the select field will be disabled." }, "hidden": { "type": "boolean", @@ -9638,7 +9646,7 @@ }, "beforeEventBuffer": { "type": "number", - "description": "Time spaces that can be prepended before an event to give more time before it." + "description": "Time spaces that can be pre-pended before an event to give more time before it." }, "afterEventBuffer": { "type": "number", @@ -10992,7 +11000,7 @@ }, "beforeEventBuffer": { "type": "number", - "description": "Time spaces that can be prepended before an event to give more time before it." + "description": "Time spaces that can be pre-pended before an event to give more time before it." }, "afterEventBuffer": { "type": "number", @@ -11763,7 +11771,7 @@ }, "beforeEventBuffer": { "type": "number", - "description": "Time spaces that can be prepended before an event to give more time before it." + "description": "Time spaces that can be pre-pended before an event to give more time before it." }, "afterEventBuffer": { "type": "number", @@ -15366,7 +15374,7 @@ "externalId": { "type": "string", "example": "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/", - "description": "Unique identifier used to represent the specific calendar, as returned by the /calendars endpoint" + "description": "Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint" } }, "required": ["integration", "externalId"] diff --git a/packages/app-store/routing-forms/lib/handleResponse.ts b/packages/app-store/routing-forms/lib/handleResponse.ts index 4f5579ca2f..c97a5c2fed 100644 --- a/packages/app-store/routing-forms/lib/handleResponse.ts +++ b/packages/app-store/routing-forms/lib/handleResponse.ts @@ -15,7 +15,7 @@ import isRouter from "../lib/isRouter"; import { onFormSubmission } from "../trpc/utils"; import type { FormResponse, SerializableForm } from "../types/types"; -type Form = SerializableForm< +export type Form = SerializableForm< App_RoutingForms_Form & { user: { id: number; diff --git a/packages/platform/atoms/booker-embed/BookerEmbed.tsx b/packages/platform/atoms/booker-embed/BookerEmbed.tsx index 016f020cd9..397078ab27 100644 --- a/packages/platform/atoms/booker-embed/BookerEmbed.tsx +++ b/packages/platform/atoms/booker-embed/BookerEmbed.tsx @@ -41,6 +41,9 @@ export const BookerEmbed = ( eventTypeSlug, username, defaultFormValues, + teamMemberEmail, + crmOwnerRecordType, + crmAppSlug, ...routingFormSearchParams } = routingFormUrlProps; const { onDryRunSuccess, ...rest } = props; @@ -67,6 +70,9 @@ export const BookerEmbed = ( })} routingFormSearchParams={routingFormSearchParams} defaultFormValues={defaultFormValues} + teamMemberEmail={teamMemberEmail} + crmOwnerRecordType={crmOwnerRecordType} + crmAppSlug={crmAppSlug} onDryRunSuccess={() => { if (onDryRunSuccess) { onDryRunSuccess(); diff --git a/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx b/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx index aa1cb4b425..33a8a480e2 100644 --- a/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx +++ b/packages/platform/atoms/booker-embed/useGetRoutingFormUrlProps.tsx @@ -81,6 +81,15 @@ export const useGetRoutingFormUrlProps = ({ routingFormUrl }: { routingFormUrl?: ["cal.salesforce.rrSkipToAccountLookupField"]: routingSearchParams.get("cal.salesforce.rrSkipToAccountLookupField") ?? undefined, }), + ...(routingSearchParams.get("cal.teamMemberEmail") && { + teamMemberEmail: routingSearchParams.get("cal.teamMemberEmail") ?? undefined, + }), + ...(routingSearchParams.get("cal.crmOwnerRecordType") && { + crmOwnerRecordType: routingSearchParams.get("cal.crmOwnerRecordType") ?? undefined, + }), + ...(routingSearchParams.get("cal.crmAppSlug") && { + crmAppSlug: routingSearchParams.get("cal.crmAppSlug") ?? undefined, + }), } satisfies RoutingFormSearchParamsForEmbed; return { ...routingformProps, defaultFormValues }; } diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 6dbd602bb9..7217c6709f 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -102,7 +102,14 @@ export type BookerPlatformWrapperAtomPropsForTeam = BookerPlatformWrapperAtomPro export const BookerPlatformWrapper = ( props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam ) => { - const { view = "MONTH_VIEW", bannerUrl, routingFormSearchParams } = props; + const { + view = "MONTH_VIEW", + bannerUrl, + routingFormSearchParams, + teamMemberEmail, + crmAppSlug, + crmOwnerRecordType, + } = props; const layout = BookerLayouts[view]; const { clientId } = useAtomsContext(); @@ -205,6 +212,9 @@ export const BookerPlatformWrapper = ( const bookerLayout = useBookerLayout(event.data); useInitializeBookerStore({ ...props, + teamMemberEmail, + crmAppSlug, + crmOwnerRecordType, eventId: event.data?.id, rescheduleUid: props.rescheduleUid ?? null, bookingUid: props.bookingUid ?? null, @@ -296,7 +306,6 @@ export const BookerPlatformWrapper = ( ...(isBookingDryRun ? { isBookingDryRun } : {}), }); }, [routingFormSearchParams]); - const schedule = useAvailableSlots({ usernameList: getUsernameList(username), eventTypeId: event?.data?.id ?? 0, @@ -305,6 +314,7 @@ export const BookerPlatformWrapper = ( timeZone: timezone, duration: selectedDuration ?? undefined, rescheduleUid: props.rescheduleUid, + teamMemberEmail: teamMemberEmail ?? undefined, ...(props.isTeamEvent ? { isTeamEvent: props.isTeamEvent, @@ -476,6 +486,9 @@ export const BookerPlatformWrapper = ( return ( { return http diff --git a/packages/platform/types/embed.ts b/packages/platform/types/embed.ts index 3357d00020..9769fd5b8e 100644 --- a/packages/platform/types/embed.ts +++ b/packages/platform/types/embed.ts @@ -3,6 +3,9 @@ export type RoutingFormSearchParamsForEmbed = { teamId?: number; eventTypeSlug: string; username?: string; + crmAppSlug?: string; + crmOwnerRecordType?: string; + teamMemberEmail?: string; } & RoutingFormSearchParams; export type RoutingFormSearchParams = { @@ -12,5 +15,8 @@ export type RoutingFormSearchParams = { ["cal.isBookingDryRun"]?: string; ["cal.cache"]?: string; ["cal.routingFormResponseId"]?: string; + ["cal.crmAppSlug"]?: string; + ["cal.crmOwnerRecordType"]?: string; + ["cal.teamMemberEmail"]?: string; ["cal.salesforce.rrSkipToAccountLookupField"]?: string; }; diff --git a/packages/platform/types/slots.ts b/packages/platform/types/slots.ts index 693c5a2942..449ee623ce 100644 --- a/packages/platform/types/slots.ts +++ b/packages/platform/types/slots.ts @@ -124,6 +124,11 @@ export class GetAvailableSlotsInput { @IsNumber({}, { each: true }) @ApiHideProperty() routedTeamMemberIds?: number[]; + + @IsString() + @IsOptional() + @ApiPropertyOptional() + teamMemberEmail?: string; } export class RemoveSelectedSlotInput {