diff --git a/.env.example b/.env.example index 7e1f26321d..daa0dc0a52 100644 --- a/.env.example +++ b/.env.example @@ -177,3 +177,5 @@ CSP_POLICY= # Vercel Edge Config EDGE_CONFIG= + +NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes \ No newline at end of file diff --git a/apps/web/components/booking/AvailableTimes.tsx b/apps/web/components/booking/AvailableTimes.tsx index 07b4a8fc1d..19cabe0b85 100644 --- a/apps/web/components/booking/AvailableTimes.tsx +++ b/apps/web/components/booking/AvailableTimes.tsx @@ -10,6 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; import { TimeFormat } from "@calcom/lib/timeFormat"; import { nameOfDay } from "@calcom/lib/weekday"; +import { trpc } from "@calcom/trpc/react"; import type { Slot } from "@calcom/trpc/server/routers/viewer/slots"; import { SkeletonContainer, SkeletonText, ToggleGroup } from "@calcom/ui"; @@ -28,6 +29,7 @@ type AvailableTimesProps = { slots?: Slot[]; isLoading: boolean; ethSignature?: string; + duration: number; }; const AvailableTimes: FC = ({ @@ -42,7 +44,9 @@ const AvailableTimes: FC = ({ seatsPerTimeSlot, bookingAttendees, ethSignature, + duration, }) => { + const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation(); const [slotPickerRef] = useAutoAnimate(); const { t, i18n } = useLocale(); const router = useRouter(); @@ -63,6 +67,14 @@ const AvailableTimes: FC = ({ [isMobile] ); + const reserveSlot = (slot: Slot) => { + reserveSlotMutation.mutate({ + slotUtcStartDate: slot.time, + eventTypeId, + slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(), + }); + }; + return (
{!!date ? ( @@ -150,6 +162,7 @@ const AvailableTimes: FC = ({ " bg-default dark:bg-muted border-default hover:bg-subtle hover:border-brand-default text-emphasis mb-2 block rounded-md border py-2 text-sm font-medium", brand === "#fff" || brand === "#ffffff" ? "" : "" )} + onClick={() => reserveSlot(slot)} data-testid="time"> {dayjs(slot.time).tz(timeZone()).format(timeFormat)} {!!seatsPerTimeSlot && ( diff --git a/apps/web/components/booking/SlotPicker.tsx b/apps/web/components/booking/SlotPicker.tsx index 984c4586ab..6b68ebbce1 100644 --- a/apps/web/components/booking/SlotPicker.tsx +++ b/apps/web/components/booking/SlotPicker.tsx @@ -194,6 +194,7 @@ export const SlotPicker = ({ bookingAttendees={bookingAttendees} recurringCount={recurringEventCount} ethSignature={ethSignature} + duration={parseInt(duration)} /> ); diff --git a/apps/web/components/booking/pages/BookingPage.tsx b/apps/web/components/booking/pages/BookingPage.tsx index 0fefcb4539..798c4ddba3 100644 --- a/apps/web/components/booking/pages/BookingPage.tsx +++ b/apps/web/components/booking/pages/BookingPage.tsx @@ -32,7 +32,7 @@ import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocati import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder"; import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect"; import classNames from "@calcom/lib/classNames"; -import { APP_NAME } from "@calcom/lib/constants"; +import { APP_NAME, MINUTES_TO_BOOK } from "@calcom/lib/constants"; import useGetBrandingColours from "@calcom/lib/getBrandColours"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useTheme from "@calcom/lib/hooks/useTheme"; @@ -41,6 +41,7 @@ import { HttpError } from "@calcom/lib/http-error"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { TimeFormat } from "@calcom/lib/timeFormat"; +import { trpc } from "@calcom/trpc"; import { Button, Form, Tooltip, useCalcomTheme } from "@calcom/ui"; import { AlertTriangle, Calendar, RefreshCw, User } from "@calcom/ui/components/icon"; @@ -210,8 +211,11 @@ const BookingPage = ({ hashedLink, ...restProps }: BookingPageProps) => { + const removeSelectedSlotMarkMutation = trpc.viewer.public.slots.removeSelectedSlotMark.useMutation(); + const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation(); const { t, i18n } = useLocale(); const { duration: queryDuration } = useRouterQuery("duration"); + const { date: queryDate } = useRouterQuery("date"); const isEmbed = useIsEmbed(restProps.isEmbed); const embedUiConfig = useEmbedUiConfig(); const shouldAlignCentrallyInEmbed = useEmbedNonStylesConfig("align") !== "left"; @@ -227,6 +231,15 @@ const BookingPage = ({ }), {} ); + const reserveSlot = () => { + if (queryDuration) { + reserveSlotMutation.mutate({ + eventTypeId: eventType.id, + slotUtcStartDate: dayjs(queryDate).utc().format(), + slotUtcEndDate: dayjs(queryDate).utc().add(parseInt(queryDuration), "minutes").format(), + }); + } + }; // Define duration now that we support multiple duration eventTypes let duration = eventType.length; if ( @@ -246,6 +259,12 @@ const BookingPage = ({ collectPageParameters("/book", { isTeamBooking: document.URL.includes("team/") }) ); } + reserveSlot(); + const interval = setInterval(reserveSlot, parseInt(MINUTES_TO_BOOK) * 60 * 1000 - 2000); + return () => { + clearInterval(interval); + removeSelectedSlotMarkMutation.mutate(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -649,9 +668,9 @@ const BookingPage = ({
- {(mutation.isError || recurringMutation.isError) && ( + {mutation.isError || recurringMutation.isError ? ( - )} + ) : null} diff --git a/apps/web/test/lib/getSchedule.test.ts b/apps/web/test/lib/getSchedule.test.ts index 9c31440361..ec19757ca2 100644 --- a/apps/web/test/lib/getSchedule.test.ts +++ b/apps/web/test/lib/getSchedule.test.ts @@ -23,6 +23,7 @@ import { prismaMock, CalendarManagerMock } from "../../../../tests/config/single // TODO: Mock properly prismaMock.eventType.findUnique.mockResolvedValue(null); prismaMock.user.findMany.mockResolvedValue([]); +prismaMock.selectedSlots.findMany.mockResolvedValue([]); jest.mock("@calcom/lib/constants", () => ({ IS_PRODUCTION: true, @@ -152,7 +153,7 @@ const TestData = { }; const ctx = { - prisma, + prisma: prismaMock, }; type App = { diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 85c5311481..19b47fb762 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -63,3 +63,4 @@ export const IS_STRIPE_ENABLED = !!( /** Self hosted shouldn't checkout when creating teams unless required */ export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && (!IS_SELF_HOSTED || HOSTED_CAL_FEATURES); export const FULL_NAME_LENGTH_MAX_LIMIT = 50; +export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5"; diff --git a/packages/prisma/migrations/20230328204152_add_selected_slots_table/migration.sql b/packages/prisma/migrations/20230328204152_add_selected_slots_table/migration.sql new file mode 100644 index 0000000000..cfd9a869fb --- /dev/null +++ b/packages/prisma/migrations/20230328204152_add_selected_slots_table/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "SelectedSlots" ( + "id" SERIAL NOT NULL, + "eventTypeId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "slotUtcStartDate" TIMESTAMP(3) NOT NULL, + "slotUtcEndDate" TIMESTAMP(3) NOT NULL, + "uid" TEXT NOT NULL, + "releaseAt" TIMESTAMP(3) NOT NULL, + "isSeat" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "SelectedSlots_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SelectedSlots_userId_slotUtcStartDate_slotUtcEndDate_uid_key" ON "SelectedSlots"("userId", "slotUtcStartDate", "slotUtcEndDate", "uid"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 5c2dea4198..f064323445 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -723,3 +723,16 @@ enum FeatureType { KILL_SWITCH PERMISSION } + +model SelectedSlots { + id Int @id @default(autoincrement()) + eventTypeId Int + userId Int + slotUtcStartDate DateTime + slotUtcEndDate DateTime + uid String + releaseAt DateTime + isSeat Boolean @default(false) + + @@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique") +} diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index 01d514c0b5..1e230e1d99 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -158,7 +158,6 @@ const publicViewerRouter = router({ }; } }), - // REVIEW: This router is part of both the public and private viewer router? slots: slotsRouter, cityTimezones: publicProcedure.query(async () => { /** @@ -1330,7 +1329,6 @@ export const viewerRouter = mergeRouters( teams: viewerTeamsRouter, webhook: webhookRouter, apiKeys: apiKeysRouter, - slots: slotsRouter, workflows: workflowsRouter, saml: ssoRouter, insights: insightsRouter, diff --git a/packages/trpc/server/routers/viewer/slots.tsx b/packages/trpc/server/routers/viewer/slots.ts similarity index 70% rename from packages/trpc/server/routers/viewer/slots.tsx rename to packages/trpc/server/routers/viewer/slots.ts index 9cd3a19fa8..d15162c336 100644 --- a/packages/trpc/server/routers/viewer/slots.tsx +++ b/packages/trpc/server/routers/viewer/slots.ts @@ -1,4 +1,7 @@ import { SchedulingType } from "@prisma/client"; +import { serialize } from "cookie"; +import { countBy } from "lodash"; +import { v4 as uuid } from "uuid"; import { z } from "zod"; import { getAggregateWorkingHours } from "@calcom/core/getAggregateWorkingHours"; @@ -6,6 +9,7 @@ import type { CurrentSeats } from "@calcom/core/getUserAvailability"; import { getUserAvailability } from "@calcom/core/getUserAvailability"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; +import { MINUTES_TO_BOOK } from "@calcom/lib/constants"; import { getDefaultEvent } from "@calcom/lib/defaultEvents"; import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds"; import logger from "@calcom/lib/logger"; @@ -18,7 +22,7 @@ import type { EventBusyDate } from "@calcom/types/Calendar"; import { TRPCError } from "@trpc/server"; -import { router, publicProcedure } from "../../trpc"; +import { publicProcedure, router } from "../../trpc"; const getScheduleSchema = z .object({ @@ -46,6 +50,19 @@ const getScheduleSchema = z "Either usernameList or eventTypeId should be filled in." ); +const reverveSlotSchema = z + .object({ + eventTypeId: z.number().int(), + // startTime ISOString + slotUtcStartDate: z.string(), + // endTime ISOString + slotUtcEndDate: z.string(), + }) + .refine( + (data) => !!data.eventTypeId || !!data.slotUtcStartDate || !!data.slotUtcEndDate, + "Either slotUtcStartDate, slotUtcEndDate or eventTypeId should be filled in." + ); + export type Slot = { time: string; userIds?: number[]; @@ -108,6 +125,55 @@ export const slotsRouter = router({ getSchedule: publicProcedure.input(getScheduleSchema).query(async ({ input, ctx }) => { return await getSchedule(input, ctx); }), + reserveSlot: publicProcedure.input(reverveSlotSchema).mutation(async ({ ctx, input }) => { + const { prisma, req, res } = ctx; + const uid = req?.cookies?.uid || uuid(); + const { slotUtcStartDate, slotUtcEndDate, eventTypeId } = input; + const releaseAt = dayjs.utc().add(parseInt(MINUTES_TO_BOOK), "minutes").format(); + const eventType = await prisma.eventType.findUnique({ + where: { id: eventTypeId }, + select: { users: { select: { id: true } }, seatsPerTimeSlot: true }, + }); + if (eventType) { + await Promise.all( + eventType.users.map((user) => + prisma.selectedSlots.upsert({ + where: { selectedSlotUnique: { userId: user.id, slotUtcStartDate, slotUtcEndDate, uid } }, + update: { + slotUtcStartDate, + slotUtcEndDate, + releaseAt, + eventTypeId, + }, + create: { + userId: user.id, + eventTypeId, + slotUtcStartDate, + slotUtcEndDate, + uid, + releaseAt, + isSeat: eventType.seatsPerTimeSlot !== null, + }, + }) + ) + ); + } else { + throw new TRPCError({ + message: "Event type not found", + code: "NOT_FOUND", + }); + } + res?.setHeader("Set-Cookie", serialize("uid", uid, { path: "/", sameSite: "lax" })); + return; + }), + removeSelectedSlotMark: publicProcedure.mutation(async ({ ctx }) => { + const { req, prisma } = ctx; + const uid = req?.cookies?.uid; + if (uid) { + await prisma.selectedSlots.deleteMany({ where: { uid: { equals: uid } } }); + } + return; + }), }); async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer) { @@ -117,6 +183,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer, ctx: if (!startTime.isValid() || !endTime.isValid()) { throw new TRPCError({ message: "Invalid time range given.", code: "BAD_REQUEST" }); } - let currentSeats: CurrentSeats | undefined = undefined; + let currentSeats: CurrentSeats | undefined; let users = eventType.users.map((user) => ({ isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE, @@ -326,10 +393,32 @@ export async function getSchedule(input: z.infer, ctx: } let availableTimeSlots: typeof timeSlots = []; + // Load cached busy slots + const selectedSlots = + /* FIXME: For some reason this returns undefined while testing in Jest */ + (await ctx.prisma.selectedSlots.findMany({ + where: { + userId: { in: users.map((user) => user.id) }, + releaseAt: { gt: dayjs.utc().format() }, + }, + select: { + id: true, + slotUtcStartDate: true, + slotUtcEndDate: true, + userId: true, + isSeat: true, + eventTypeId: true, + }, + })) || []; + await ctx.prisma.selectedSlots.deleteMany({ + where: { eventTypeId: { equals: eventType.id }, id: { notIn: selectedSlots.map((item) => item.id) } }, + }); + availableTimeSlots = timeSlots.filter((slot) => { const fixedHosts = userAvailability.filter((availability) => availability.user.isFixed); return fixedHosts.every((schedule) => { const startCheckForAvailability = performance.now(); + const isAvailable = checkIfIsAvailable({ time: slot.time, ...schedule, @@ -364,6 +453,71 @@ export async function getSchedule(input: z.infer, ctx: .filter((slot) => !!slot.userIds?.length); } + if (selectedSlots?.length > 0) { + let occupiedSeats: typeof selectedSlots = selectedSlots.filter( + (item) => item.isSeat && item.eventTypeId === eventType.id + ); + if (occupiedSeats?.length) { + const addedToCurrentSeats: string[] = []; + if (typeof availabilityCheckProps.currentSeats !== undefined) { + availabilityCheckProps.currentSeats = (availabilityCheckProps.currentSeats as CurrentSeats).map( + (item) => { + const attendees = + occupiedSeats.filter( + (seat) => seat.slotUtcStartDate.toISOString() === item.startTime.toISOString() + )?.length || 0; + if (attendees) addedToCurrentSeats.push(item.startTime.toISOString()); + return { + ...item, + _count: { + attendees: item._count.attendees + attendees, + }, + }; + } + ) as CurrentSeats; + occupiedSeats = occupiedSeats.filter( + (item) => !addedToCurrentSeats.includes(item.slotUtcStartDate.toISOString()) + ); + } + + if (occupiedSeats?.length && typeof availabilityCheckProps.currentSeats === undefined) + availabilityCheckProps.currentSeats = []; + const occupiedSeatsCount = countBy(occupiedSeats, (item) => item.slotUtcStartDate.toISOString()); + Object.keys(occupiedSeatsCount).forEach((date) => { + (availabilityCheckProps.currentSeats as CurrentSeats).push({ + uid: uuid(), + startTime: dayjs(date).toDate(), + _count: { attendees: occupiedSeatsCount[date] }, + }); + }); + currentSeats = availabilityCheckProps.currentSeats; + } + + availableTimeSlots = availableTimeSlots + .map((slot) => { + slot.userIds = slot.userIds?.filter((slotUserId) => { + const busy = selectedSlots.reduce((r, c) => { + if (c.userId === slotUserId && !c.isSeat) { + r.push({ start: c.slotUtcStartDate, end: c.slotUtcEndDate }); + } + return r; + }, []); + + if (!busy?.length && eventType.seatsPerTimeSlot === null) { + return false; + } + + return checkIfIsAvailable({ + time: slot.time, + busy, + ...availabilityCheckProps, + }); + }); + return slot; + }) + .filter((slot) => !!slot.userIds?.length); + } + availableTimeSlots = availableTimeSlots.filter((slot) => isTimeWithinBounds(slot.time)); const computedAvailableSlots = availableTimeSlots.reduce( diff --git a/turbo.json b/turbo.json index 32a84e2e42..ccbc681ecd 100644 --- a/turbo.json +++ b/turbo.json @@ -218,6 +218,7 @@ "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PUBLIC_EMBED_LIB_URL", "NEXT_PUBLIC_HOSTED_CAL_FEATURES", + "NEXT_PUBLIC_MINUTES_TO_BOOK", "NEXT_PUBLIC_SENDER_ID", "NEXT_PUBLIC_SENDGRID_SENDER_NAME", "NEXT_PUBLIC_SENTRY_DSN",