Feature: Reserve slots currently being booked (#6909)

* Reserving slot picked up on cache

* change memory-cache to database table to block slots while reservation completes

* remove memory-cache

* update realeaseAt field when same user change te selected Slot

* Change default time to book

Co-authored-by: alannnc <alannnc@gmail.com>

* remove ip field and renews the session when the user remains in the booking form

* Remove duplicate router

* types fixes

* nit picks

* Update turbo.json

* Revert unrelated change

* Uses constant

* Constant already has a fallback

* Update slots.ts

* Unit test fixes

* slot reservation on user level and support seats

* types fixes and reserve slots on click

* Fix nit var name

---------

Co-authored-by: Efraín Rochín <roae.85@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
alannnc 2023-04-13 12:55:26 -07:00 committed by GitHub
parent 7c9012738a
commit e478a46358
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 227 additions and 8 deletions

View File

@ -177,3 +177,5 @@ CSP_POLICY=
# Vercel Edge Config
EDGE_CONFIG=
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes

View File

@ -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<AvailableTimesProps> = ({
@ -42,7 +44,9 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
seatsPerTimeSlot,
bookingAttendees,
ethSignature,
duration,
}) => {
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
const [slotPickerRef] = useAutoAnimate<HTMLDivElement>();
const { t, i18n } = useLocale();
const router = useRouter();
@ -63,6 +67,14 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
[isMobile]
);
const reserveSlot = (slot: Slot) => {
reserveSlotMutation.mutate({
slotUtcStartDate: slot.time,
eventTypeId,
slotUtcEndDate: dayjs(slot.time).utc().add(duration, "minutes").format(),
});
};
return (
<div ref={slotPickerRef}>
{!!date ? (
@ -150,6 +162,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
" 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 && (

View File

@ -194,6 +194,7 @@ export const SlotPicker = ({
bookingAttendees={bookingAttendees}
recurringCount={recurringEventCount}
ethSignature={ethSignature}
duration={parseInt(duration)}
/>
</>
);

View File

@ -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 = ({
</Button>
</div>
</Form>
{(mutation.isError || recurringMutation.isError) && (
{mutation.isError || recurringMutation.isError ? (
<ErrorMessage error={mutation.error || recurringMutation.error} />
)}
) : null}
</div>
</div>
</div>

View File

@ -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 = {

View File

@ -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";

View File

@ -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");

View File

@ -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")
}

View File

@ -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,

View File

@ -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<typeof getScheduleSchema>) {
@ -117,6 +183,7 @@ async function getEventType(ctx: { prisma: typeof prisma }, input: z.infer<typeo
},
select: {
id: true,
slug: true,
minimumBookingNotice: true,
length: true,
seatsPerTimeSlot: true,
@ -236,7 +303,7 @@ export async function getSchedule(input: z.infer<typeof getScheduleSchema>, 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<typeof getScheduleSchema>, 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<typeof getScheduleSchema>, 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<EventBusyDate[]>((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(

View File

@ -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",