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>pull/6909/merge
parent
7c9012738a
commit
e478a46358
|
@ -177,3 +177,5 @@ CSP_POLICY=
|
|||
|
||||
# Vercel Edge Config
|
||||
EDGE_CONFIG=
|
||||
|
||||
NEXT_PUBLIC_MINUTES_TO_BOOK=5 # Minutes
|
|
@ -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 && (
|
||||
|
|
|
@ -194,6 +194,7 @@ export const SlotPicker = ({
|
|||
bookingAttendees={bookingAttendees}
|
||||
recurringCount={recurringEventCount}
|
||||
ethSignature={ethSignature}
|
||||
duration={parseInt(duration)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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");
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue