diff --git a/apps/web/components/booking/pages/AvailabilityPage.tsx b/apps/web/components/booking/pages/AvailabilityPage.tsx index 3d1bcd814a..914fb24a53 100644 --- a/apps/web/components/booking/pages/AvailabilityPage.tsx +++ b/apps/web/components/booking/pages/AvailabilityPage.tsx @@ -21,6 +21,7 @@ import classNames from "@calcom/lib/classNames"; import { CAL_URL, WEBSITE_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; +import notEmpty from "@calcom/lib/notEmpty"; import { getRecurringFreq } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { detectBrowserTimeFormat } from "@calcom/lib/timeFormat"; @@ -97,13 +98,17 @@ const GoBackToPreviousPage = ({ t }: { t: TFunction }) => { const useSlots = ({ eventTypeId, + eventTypeSlug, startTime, endTime, + usernameList, timeZone, }: { eventTypeId: number; + eventTypeSlug: string; startTime?: Dayjs; endTime?: Dayjs; + usernameList: string[]; timeZone?: string; }) => { const { data, isLoading, isIdle } = trpc.useQuery( @@ -111,6 +116,8 @@ const useSlots = ({ "viewer.public.slots.getSchedule", { eventTypeId, + eventTypeSlug, + usernameList, startTime: startTime?.toISOString() || "", endTime: endTime?.toISOString() || "", timeZone, @@ -118,7 +125,6 @@ const useSlots = ({ ], { enabled: !!startTime && !!endTime } ); - const [cachedSlots, setCachedSlots] = useState["slots"]>({}); useEffect(() => { @@ -136,6 +142,7 @@ const SlotPicker = ({ timeFormat, timeZone, recurringEventCount, + users, seatsPerTimeSlot, weekStart = 0, }: { @@ -144,6 +151,7 @@ const SlotPicker = ({ timeZone?: string; seatsPerTimeSlot?: number; recurringEventCount?: number; + users: string[]; weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6; }) => { const [selectedDate, setSelectedDate] = useState(); @@ -176,12 +184,16 @@ const SlotPicker = ({ const { i18n, isLocaleReady } = useLocale(); const { slots: _1 } = useSlots({ eventTypeId: eventType.id, + eventTypeSlug: eventType.slug, + usernameList: users, startTime: selectedDate?.startOf("day"), endTime: selectedDate?.endOf("day"), timeZone, }); const { slots: _2, isLoading } = useSlots({ eventTypeId: eventType.id, + eventTypeSlug: eventType.slug, + usernameList: users, startTime: browsingDate?.startOf("month"), endTime: browsingDate?.endOf("month"), timeZone, @@ -356,6 +368,8 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { } }, [telemetry]); + // get dynamic user list here + const userList = eventType.users.map((user) => user.username).filter(notEmpty); // Recurring event sidebar requires more space const maxWidth = isAvailableTimesVisible ? recurringEventCount @@ -373,7 +387,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { onChangeTimeZone={setTimeZone} /> ), - [timeZone] + [timeZone, timeFormat] ); const rawSlug = profile.slug ? profile.slug.split("/") : []; if (rawSlug.length > 1) rawSlug.pop(); //team events have team name as slug, but user events have [user]/[type] as slug. @@ -694,6 +708,7 @@ const AvailabilityPage = ({ profile, eventType }: Props) => { eventType={eventType} timeFormat={timeFormat} timeZone={timeZone} + users={userList} seatsPerTimeSlot={eventType.seatsPerTimeSlot || undefined} recurringEventCount={recurringEventCount} /> diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index b7b9b86fbd..b10d447aa4 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -54,47 +54,47 @@ export default function User(props: inferSSRProps) { const { t } = useLocale(); const router = useRouter(); - const groupEventTypes = - /* props.users.some((user) => !user.allowDynamicBooking) TODO: Re-enable after v1.7 launch */ true ? ( -
-
-
-

{" " + t("unavailable")}

-

{t("user_dynamic_booking_disabled") as string}

-
+ const groupEventTypes = props.users.some((user) => !user.allowDynamicBooking) ? ( +
+
+
+

{" " + t("unavailable")}

+

{t("user_dynamic_booking_disabled") as string}

- ) : ( - - ); +
+ ) : ( + + ); + const isEmbed = useIsEmbed(); const eventTypeListItemEmbedStyles = useEmbedStyles("eventTypeListItem"); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx index 5598a1971c..e853d374f4 100644 --- a/apps/web/pages/[user]/[type].tsx +++ b/apps/web/pages/[user]/[type].tsx @@ -34,7 +34,7 @@ export default function Type(props: AvailabilityPageProps) {
- ) : props.isDynamic /* && !props.profile.allowDynamicBooking TODO: Re-enable after v1.7 launch */ ? ( + ) : props.isDynamic && !props.profile.allowDynamicBooking ? (
@@ -175,6 +175,7 @@ async function getUserPageProps(context: GetStaticPropsContext) { hideBranding: user.hideBranding, plan: user.plan, timeZone: user.timeZone, + allowDynamicBooking: false, weekStart: user.weekStart, brandColor: user.brandColor, darkBrandColor: user.darkBrandColor, diff --git a/apps/web/pages/[user]/book.tsx b/apps/web/pages/[user]/book.tsx index 98570b8b68..44b34546d1 100644 --- a/apps/web/pages/[user]/book.tsx +++ b/apps/web/pages/[user]/book.tsx @@ -142,6 +142,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { ...e, periodStartDate: e.periodStartDate?.toString() ?? null, periodEndDate: e.periodEndDate?.toString() ?? null, + users: users.map((u) => ({ + id: u.id, + name: u.name, + username: u.username, + avatar: u.avatar, + image: u.avatar, + slug: u.username, + theme: u.theme, + })), }; })[0]; diff --git a/apps/web/pages/api/book/event.ts b/apps/web/pages/api/book/event.ts index 8141c89f92..4e95d07d27 100644 --- a/apps/web/pages/api/book/event.ts +++ b/apps/web/pages/api/book/event.ts @@ -258,6 +258,13 @@ async function handler(req: NextApiRequest) { ...userSelect, }) : eventType.users; + const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); + if (!isDynamicAllowed) { + throw new HttpError({ + message: "Some of the users in this group do not allow dynamic booking", + statusCode: 400, + }); + } /* If this event was pre-relationship migration */ if (!users.length && eventType.userId) { @@ -270,6 +277,9 @@ async function handler(req: NextApiRequest) { if (!eventTypeUser) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); users.push(eventTypeUser); } + + if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" }); + const [organizerUser] = users; /** * @TODO: add a validation to check if organizerUser is found, otherwise it will throw error on user not found @@ -285,7 +295,6 @@ async function handler(req: NextApiRequest) { }); const tOrganizer = await getTranslation(organizer?.locale ?? "en", "common"); - if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { const bookingCounts = await getUserNameWithBookingCounts( eventTypeId, diff --git a/apps/web/pages/d/[link]/[slug].tsx b/apps/web/pages/d/[link]/[slug].tsx index 75bec18dca..681af469bb 100644 --- a/apps/web/pages/d/[link]/[slug].tsx +++ b/apps/web/pages/d/[link]/[slug].tsx @@ -99,7 +99,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ? (hashedLink.eventType.locations as LocationObject[]) : []; - const [user] = users; const eventTypeObject = Object.assign({}, hashedLink.eventType, { metadata: {} as JSONObject, recurringEvent: parseRecurringEvent(hashedLink.eventType.recurringEvent), @@ -107,8 +106,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodEndDate: hashedLink.eventType.periodEndDate?.toString() ?? null, slug, locations: locationHiddenFilter(locations), + users: users.map((u) => ({ + name: u.name, + username: u.username, + hideBranding: u.hideBranding, + plan: u.plan, + timeZone: u.timeZone, + })), }); + const [user] = users; + const schedule = { ...user.schedules.filter( (schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId @@ -150,6 +158,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => trpcState: ssr.dehydrate(), previousPage: context.req.headers.referer ?? null, booking, + users: [user.username], }, }; }; diff --git a/apps/web/pages/d/[link]/book.tsx b/apps/web/pages/d/[link]/book.tsx index 7f63a61872..b21bbf08f2 100644 --- a/apps/web/pages/d/[link]/book.tsx +++ b/apps/web/pages/d/[link]/book.tsx @@ -103,6 +103,18 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { ...e, periodStartDate: e.periodStartDate?.toString() ?? null, periodEndDate: e.periodEndDate?.toString() ?? null, + users: users.map((u) => ({ + id: u.id, + name: u.name, + username: u.username, + avatar: u.avatar, + image: u.avatar, + slug: u.username, + theme: u.theme, + email: u.email, + brandColor: u.brandColor, + darkBrandColor: u.darkBrandColor, + })), }; })[0]; @@ -118,7 +130,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { // Checking if number of recurring event ocurrances is valid against event type configuration const recurringEventCount = - (eventTypeObject?.recurringEvent?.count && + (eventTypeObject.recurringEvent?.count && recurringEventCountQuery && (parseInt(recurringEventCountQuery) <= eventTypeObject.recurringEvent.count ? parseInt(recurringEventCountQuery) diff --git a/apps/web/pages/team/[slug]/[type].tsx b/apps/web/pages/team/[slug]/[type].tsx index fad74bb099..0fd67160a6 100644 --- a/apps/web/pages/team/[slug]/[type].tsx +++ b/apps/web/pages/team/[slug]/[type].tsx @@ -123,6 +123,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodEndDate: eventType.periodEndDate?.toString() ?? null, recurringEvent: parseRecurringEvent(eventType.recurringEvent), locations: locationHiddenFilter(locations), + users: eventType.users.map((user) => ({ + name: user.name, + username: user.username, + hideBranding: user.hideBranding, + plan: user.plan, + timeZone: user.timeZone, + })), }); eventTypeObject.availability = []; diff --git a/apps/web/pages/team/[slug]/book.tsx b/apps/web/pages/team/[slug]/book.tsx index 99a627ff28..6f6ac5703c 100644 --- a/apps/web/pages/team/[slug]/book.tsx +++ b/apps/web/pages/team/[slug]/book.tsx @@ -96,6 +96,14 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { metadata: (eventType.metadata || {}) as JSONObject, periodStartDate: e.periodStartDate?.toString() ?? null, periodEndDate: e.periodEndDate?.toString() ?? null, + users: eventType.users.map((u) => ({ + id: u.id, + name: u.name, + username: u.username, + avatar: u.avatar, + image: u.avatar, + slug: u.username, + })), }; })[0]; diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 7c6cf03b8a..b1f4854d72 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -320,6 +320,7 @@ describe("getSchedule", () => { const scheduleOnCompletelyFreeDay = await getSchedule( { eventTypeId: eventType.id, + eventTypeSlug: "", startTime: `${plus1DateString}T18:30:00.000Z`, endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: "Asia/Kolkata", @@ -349,6 +350,7 @@ describe("getSchedule", () => { const scheduleForDayWithOneBooking = await getSchedule( { eventTypeId: eventType.id, + eventTypeSlug: "", startTime: `${plus2DateString}T18:30:00.000Z`, endTime: `${plus3DateString}T18:29:59.999Z`, timeZone: "Asia/Kolkata", // GMT+5:30 @@ -438,6 +440,7 @@ describe("getSchedule", () => { const scheduleForDayWithAGoogleCalendarBooking = await getSchedule( { eventTypeId: eventType.id, + eventTypeSlug: "", startTime: `${plus1DateString}T18:30:00.000Z`, endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: "Asia/Kolkata", @@ -480,6 +483,7 @@ describe("getSchedule", () => { const scheduleForTeamEventOnADayWithNoBooking = await getSchedule( { eventTypeId: 1, + eventTypeSlug: "", startTime: `${todayDateString}T18:30:00.000Z`, endTime: `${plus1DateString}T18:29:59.999Z`, timeZone: "Asia/Kolkata", @@ -509,6 +513,7 @@ describe("getSchedule", () => { const scheduleForTeamEventOnADayWithOneBooking = await getSchedule( { eventTypeId: 1, + eventTypeSlug: "", startTime: `${plus1DateString}T18:30:00.000Z`, endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: "Asia/Kolkata", @@ -557,6 +562,7 @@ describe("getSchedule", () => { const scheduleOfTeamEventHavingAUserWithBlockedTimeInAnotherEvent = await getSchedule( { eventTypeId: 1, + eventTypeSlug: "", startTime: `${plus1DateString}T18:30:00.000Z`, endTime: `${plus2DateString}T18:29:59.999Z`, timeZone: "Asia/Kolkata", diff --git a/apps/web/test/lib/team-event-types.test.ts b/apps/web/test/lib/team-event-types.test.ts index 4262ce90c3..74367e50c4 100644 --- a/apps/web/test/lib/team-event-types.test.ts +++ b/apps/web/test/lib/team-event-types.test.ts @@ -24,6 +24,7 @@ const baseUser = { plan: UserPlan.PRO, avatar: "", hideBranding: true, + allowDynamicBooking: true, }; it("can find lucky users", async () => { diff --git a/packages/core/getBusyTimes.ts b/packages/core/getBusyTimes.ts index 6d34efb066..1d9b995bc4 100644 --- a/packages/core/getBusyTimes.ts +++ b/packages/core/getBusyTimes.ts @@ -47,7 +47,7 @@ export async function getBusyTimes(params: { logger.silly(`Busy Time from Cal Bookings ${JSON.stringify(busyTimes)}`); const endPrismaBookingGet = performance.now(); logger.debug(`prisma booking get took ${endPrismaBookingGet - startPrismaBookingGet}ms`); - if (credentials.length > 0) { + if (credentials?.length > 0) { const calendarBusyTimes = await getBusyCalendarTimes(credentials, startTime, endTime, selectedCalendars); busyTimes.push(...calendarBusyTimes); /* diff --git a/packages/core/getUserAvailability.ts b/packages/core/getUserAvailability.ts index cf07c9de2d..e4249f041c 100644 --- a/packages/core/getUserAvailability.ts +++ b/packages/core/getUserAvailability.ts @@ -146,6 +146,7 @@ export async function getUserAvailability( const timeZone = timezone || schedule?.timeZone || eventType?.timeZone || currentUser.timeZone; const startGetWorkingHours = performance.now(); + const workingHours = getWorkingHours( { timeZone }, schedule.availability || diff --git a/packages/lib/defaultEvents.ts b/packages/lib/defaultEvents.ts index 9b98b20861..5a4779d10b 100644 --- a/packages/lib/defaultEvents.ts +++ b/packages/lib/defaultEvents.ts @@ -1,20 +1,9 @@ import type { EventTypeCustomInput } from "@prisma/client"; import { PeriodType, Prisma, SchedulingType, UserPlan } from "@prisma/client"; -import { baseUserSelect } from "@calcom/prisma/selects"; +import { userSelect } from "@calcom/prisma/selects"; -const userSelectData = Prisma.validator()({ select: baseUserSelect }); -type User = Prisma.UserGetPayload; - -const availability = [ - { - days: [1, 2, 3, 4, 5], - startTime: new Date().getTime(), - endTime: new Date().getTime(), - date: new Date(), - scheduleId: null, - }, -]; +type User = Prisma.UserGetPayload; type UsernameSlugLinkProps = { users: { @@ -33,6 +22,31 @@ type UsernameSlugLinkProps = { slug: string; }; +const user: User = { + theme: null, + credentials: [], + username: "john.doe", + timeZone: "", + bufferTime: 0, + availability: [], + id: 0, + startTime: 0, + endTime: 0, + selectedCalendars: [], + schedules: [], + defaultScheduleId: null, + locale: "en", + email: "john.doe@example.com", + name: "John doe", + avatar: "", + destinationCalendar: null, + plan: UserPlan.PRO, + hideBranding: true, + brandColor: "#797979", + darkBrandColor: "#efefef", + allowDynamicBooking: true, +}; + const customInputs: EventTypeCustomInput[] = []; const commons = { @@ -52,6 +66,8 @@ const commons = { schedule: null, timeZone: null, successRedirectUrl: "", + teamId: null, + scheduleId: null, availability: [], price: 0, currency: "usd", @@ -70,31 +86,7 @@ const commons = { hidden: false, userId: 0, workflows: [], - users: [ - { - id: 0, - plan: UserPlan.PRO, - email: "jdoe@example.com", - name: "John Doe", - username: "jdoe", - avatar: "", - hideBranding: true, - timeZone: "", - destinationCalendar: null, - credentials: [], - bufferTime: 0, - locale: "en", - theme: null, - brandColor: "#292929", - darkBrandColor: "#fafafa", - availability: [], - selectedCalendars: [], - startTime: 0, - endTime: 0, - schedules: [], - defaultScheduleId: null, - } as User, - ], + users: [user], }; const min15Event = { @@ -103,6 +95,7 @@ const min15Event = { title: "15min", eventName: "Dynamic Collective 15min Event", description: "Dynamic Collective 15min Event", + position: 0, ...commons, }; const min30Event = { @@ -111,6 +104,7 @@ const min30Event = { title: "30min", eventName: "Dynamic Collective 30min Event", description: "Dynamic Collective 30min Event", + position: 1, ...commons, }; const min60Event = { @@ -119,6 +113,7 @@ const min60Event = { title: "60min", eventName: "Dynamic Collective 60min Event", description: "Dynamic Collective 60min Event", + position: 2, ...commons, }; diff --git a/packages/prisma/selects/user.ts b/packages/prisma/selects/user.ts index 153a71d45d..58287d55de 100644 --- a/packages/prisma/selects/user.ts +++ b/packages/prisma/selects/user.ts @@ -7,6 +7,7 @@ export const availabilityUserSelect = Prisma.validator()({ availability: true, id: true, startTime: true, + username: true, endTime: true, selectedCalendars: true, schedules: { @@ -22,7 +23,6 @@ export const availabilityUserSelect = Prisma.validator()({ export const baseUserSelect = Prisma.validator()({ email: true, name: true, - username: true, destinationCalendar: true, locale: true, plan: true, @@ -38,7 +38,7 @@ export const userSelect = Prisma.validator()({ select: { email: true, name: true, - username: true, + allowDynamicBooking: true, destinationCalendar: true, locale: true, plan: true, diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.tsx index e460740a19..c401742fe5 100644 --- a/packages/trpc/server/routers/viewer/slots.tsx +++ b/packages/trpc/server/routers/viewer/slots.tsx @@ -4,6 +4,7 @@ import { z } from "zod"; import type { CurrentSeats } from "@calcom/core/getUserAvailability"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import dayjs, { Dayjs } from "@calcom/dayjs"; +import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import isOutOfBounds from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; import { performance } from "@calcom/lib/server/perfObserver"; @@ -23,7 +24,9 @@ const getScheduleSchema = z // endTime ISOString endTime: z.string(), // Event type ID - eventTypeId: z.number().optional(), + eventTypeId: z.number().int().optional(), + // Event type slug + eventTypeSlug: z.string(), // invitee timezone timeZone: z.string().optional(), // or list of users (for dynamic events) @@ -100,17 +103,7 @@ export const slotsRouter = createRouter().query("getSchedule", { }, }); -export async function getSchedule( - input: { - timeZone?: string | undefined; - eventTypeId?: number | undefined; - usernameList?: string[] | undefined; - debug?: boolean | undefined; - startTime: string; - endTime: string; - }, - ctx: { prisma: typeof prisma } -) { +export async function getSchedule(input: z.infer, ctx: { prisma: typeof prisma }) { if (input.debug === true) { logger.setSettings({ minLevel: "debug" }); } @@ -118,7 +111,7 @@ export async function getSchedule( logger.setSettings({ minLevel: "silly" }); } const startPrismaEventTypeGet = performance.now(); - const eventType = await ctx.prisma.eventType.findUnique({ + const eventTypeObject = await ctx.prisma.eventType.findUnique({ where: { id: input.eventTypeId, }, @@ -152,12 +145,42 @@ export async function getSchedule( }, users: { select: { - username: true, ...availabilityUserSelect, }, }, }, }); + + const isDynamicBooking = !input.eventTypeId; + // For dynamic booking, we need to get and update user credentials, schedule and availability in the eventTypeObject as they're required in the new availability logic + const dynamicEventType = getDefaultEvent(input.eventTypeSlug); + let dynamicEventTypeObject = dynamicEventType; + + if (isDynamicBooking) { + const users = await ctx.prisma.user.findMany({ + where: { + username: { + in: input.usernameList, + }, + }, + select: { + allowDynamicBooking: true, + ...availabilityUserSelect, + }, + }); + const isDynamicAllowed = !users.some((user) => !user.allowDynamicBooking); + if (!isDynamicAllowed) { + throw new TRPCError({ + message: "Some of the users in this group do not allow dynamic booking", + code: "UNAUTHORIZED", + }); + } + dynamicEventTypeObject = Object.assign({}, dynamicEventType, { + users, + }); + } + const eventType = isDynamicBooking ? dynamicEventTypeObject : eventTypeObject; + const endPrismaEventTypeGet = performance.now(); logger.debug( `Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms for event:${ @@ -189,6 +212,7 @@ export async function getSchedule( } = await getUserAvailability( { userId: currentUser.id, + username: currentUser.username || "", dateFrom: startTime.format(), dateTo: endTime.format(), eventTypeId: input.eventTypeId, diff --git a/packages/trpc/server/routers/viewer/teams.tsx b/packages/trpc/server/routers/viewer/teams.tsx index c1be3a150b..f57e7b00cd 100644 --- a/packages/trpc/server/routers/viewer/teams.tsx +++ b/packages/trpc/server/routers/viewer/teams.tsx @@ -412,7 +412,6 @@ export const viewerTeamsRouter = createProtectedRouter() include: { user: { select: { - username: true, ...availabilityUserSelect, }, },