feat: add 10 minute cooldown in instant meetings (#24119)
* feat: add 10 minute * feat: add 10 minute * chore: change name * chore: change name --------- Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
1 parent
6ff5c8ce5f
commit
022f08c05e
6 files changed
+116
-20
No files matched your search
@@ -12,7 +12,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
||||
const userIp = getIP(req);
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
rateLimitingType: "instantMeeting",
|
||||
identifier: `instant.event-${piiHasher.hash(userIp)}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -2301,6 +2301,7 @@
|
||||
"locked_by_team_admin": "Locked by team admin",
|
||||
"app_not_connected": "You have not connected a {{appName}} account.",
|
||||
"connect_now": "Connect now",
|
||||
"connect_now_unavailable_tooltip": "Connect now is unavailable right now. Please book from above slots or try again later.",
|
||||
"managed_event_dialog_confirm_button_one": "Replace & notify {{count}} member",
|
||||
"managed_event_dialog_confirm_button_other": "Replace & notify {{count}} members",
|
||||
"managed_event_dialog_title_one": "The url /{{slug}} already exists for {{count}} member. Do you want to replace it?",
|
||||
|
||||
@@ -92,7 +92,6 @@ const BookerComponent = ({
|
||||
);
|
||||
|
||||
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
|
||||
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
|
||||
|
||||
const {
|
||||
shouldShowFormInDialog,
|
||||
@@ -145,7 +144,14 @@ const BookerComponent = ({
|
||||
|
||||
const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm;
|
||||
|
||||
const { handleBookEvent, errors, loadingStates, expiryTime, instantVideoMeetingUrl } = bookings;
|
||||
const {
|
||||
handleBookEvent,
|
||||
errors,
|
||||
loadingStates,
|
||||
expiryTime,
|
||||
instantVideoMeetingUrl,
|
||||
instantConnectCooldownMs,
|
||||
} = bookings;
|
||||
|
||||
const watchedCfToken = bookingForm.watch("cfToken");
|
||||
|
||||
@@ -230,9 +236,9 @@ const BookerComponent = ({
|
||||
setSelectedTimeslot(slot || null);
|
||||
}, [slot, setSelectedTimeslot]);
|
||||
|
||||
const onSubmit = (timeSlot?: string) => {
|
||||
const onSubmit = (timeSlot?: string) =>
|
||||
renderConfirmNotVerifyEmailButtonCond ? handleBookEvent(timeSlot) : handleVerifyEmail();
|
||||
};
|
||||
|
||||
|
||||
const EventBooker = useMemo(() => {
|
||||
return bookerState === "booking" ? (
|
||||
@@ -543,6 +549,7 @@ const BookerComponent = ({
|
||||
style={{ animationDelay: "1s" }}>
|
||||
<InstantBooking
|
||||
event={event.data}
|
||||
cooldownMs={instantConnectCooldownMs}
|
||||
onConnectNow={() => {
|
||||
onConnectNowInstantMeeting();
|
||||
}}
|
||||
|
||||
@@ -3,16 +3,19 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { UserAvatarGroupWithOrg } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
interface IInstantBookingProps {
|
||||
onConnectNow: () => void;
|
||||
event: Pick<BookerEvent, "entity" | "schedulingType"> & {
|
||||
subsetOfUsers: (Pick<User, "name" | "username" | "avatarUrl"> & { bookerUrl: string })[];
|
||||
};
|
||||
cooldownMs?: number;
|
||||
}
|
||||
|
||||
export const InstantBooking = ({ onConnectNow, event }: IInstantBookingProps) => {
|
||||
export const InstantBooking = ({ onConnectNow, event, cooldownMs = 0 }: IInstantBookingProps) => {
|
||||
const { t } = useLocale();
|
||||
const disabled = cooldownMs > 0;
|
||||
|
||||
return (
|
||||
<div className=" bg-default border-subtle mx-2 block items-center gap-3 rounded-xl border p-[6px] text-sm shadow-sm delay-1000 sm:flex">
|
||||
@@ -33,16 +36,33 @@ export const InstantBooking = ({ onConnectNow, event }: IInstantBookingProps) =>
|
||||
</div>
|
||||
<div>{t("dont_want_to_wait")}</div>
|
||||
</div>
|
||||
<div className="mt-2 sm:mt-0">
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
onConnectNow();
|
||||
}}
|
||||
size="sm"
|
||||
className="w-full justify-center rounded-lg sm:w-auto">
|
||||
{t("connect_now")}
|
||||
</Button>
|
||||
<div className="mt-2 sm:mt-0 flex items-center gap-3">
|
||||
{disabled ? (
|
||||
<Tooltip content={t("connect_now_unavailable_tooltip") || "Connect now is not available right now."}>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
onConnectNow();
|
||||
}}
|
||||
size="sm"
|
||||
className="w-full justify-center rounded-lg sm:w-auto">
|
||||
{t("connect_now")}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
onConnectNow();
|
||||
}}
|
||||
size="sm"
|
||||
className="w-full justify-center rounded-lg sm:w-auto">
|
||||
{t("connect_now")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -114,6 +114,43 @@ export interface IUseBookingErrors {
|
||||
export type UseBookingsReturnType = ReturnType<typeof useBookings>;
|
||||
|
||||
const STORAGE_KEY = "instantBookingData";
|
||||
const COOLDOWN_STORAGE_KEY = "instantBookingCooldownByEvent";
|
||||
const COOLDOWN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
type InstantBookingCooldownMap = Record<string, number>;
|
||||
|
||||
const readInstantCooldownMap = (): InstantBookingCooldownMap => {
|
||||
try {
|
||||
const raw = localStorage.getItem(COOLDOWN_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as InstantBookingCooldownMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const writeInstantCooldownMap = (map: InstantBookingCooldownMap) => {
|
||||
try {
|
||||
localStorage.setItem(COOLDOWN_STORAGE_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
// don't do anything
|
||||
}
|
||||
};
|
||||
|
||||
const getInstantCooldownRemainingMs = (eventTypeId?: number | null): number => {
|
||||
if (!eventTypeId) return 0;
|
||||
const map = readInstantCooldownMap();
|
||||
const lastTs = map[String(eventTypeId)];
|
||||
if (!lastTs) return 0;
|
||||
const remaining = lastTs + COOLDOWN_WINDOW_MS - Date.now();
|
||||
return remaining > 0 ? remaining : 0;
|
||||
};
|
||||
|
||||
const setInstantCooldownNow = (eventTypeId?: number | null) => {
|
||||
if (!eventTypeId) return;
|
||||
const map = readInstantCooldownMap();
|
||||
map[String(eventTypeId)] = Date.now();
|
||||
writeInstantCooldownMap(map);
|
||||
};
|
||||
|
||||
const storeInLocalStorage = ({
|
||||
eventTypeId,
|
||||
@@ -133,7 +170,6 @@ export const useBookings = ({
|
||||
hashedLink,
|
||||
bookingForm,
|
||||
metadata,
|
||||
teamMemberEmail,
|
||||
isBookingDryRun,
|
||||
}: IUseBookings) => {
|
||||
const router = useRouter();
|
||||
@@ -179,6 +215,8 @@ export const useBookings = ({
|
||||
}
|
||||
}, [eventTypeId, isInstantMeeting]);
|
||||
|
||||
const instantConnectCooldownMs = getInstantCooldownRemainingMs(eventTypeId);
|
||||
|
||||
const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
|
||||
{
|
||||
bookingId: bookingId,
|
||||
@@ -245,7 +283,7 @@ export const useBookings = ({
|
||||
const { uid, paymentUid } = booking;
|
||||
const fullName = getFullName(bookingForm.getValues("responses.name"));
|
||||
|
||||
const users = !!event.data?.subsetOfHosts?.length
|
||||
const users = event.data?.subsetOfHosts?.length
|
||||
? event.data?.subsetOfHosts.map((host) => host.user)
|
||||
: event.data?.subsetOfUsers;
|
||||
|
||||
@@ -368,6 +406,7 @@ export const useBookings = ({
|
||||
expiryTime: responseData.expires,
|
||||
bookingId: responseData.bookingId,
|
||||
});
|
||||
setInstantCooldownNow(eventTypeId);
|
||||
}
|
||||
|
||||
updateQueryParam("bookingId", responseData.bookingId);
|
||||
@@ -472,7 +511,19 @@ export const useBookings = ({
|
||||
bookingForm,
|
||||
hashedLink,
|
||||
metadata,
|
||||
handleInstantBooking: createInstantBookingMutation.mutate,
|
||||
handleInstantBooking: (
|
||||
variables: Parameters<typeof createInstantBookingMutation.mutate>[0]
|
||||
) => {
|
||||
const remaining = getInstantCooldownRemainingMs(eventTypeId);
|
||||
if (remaining > 0) {
|
||||
showToast(
|
||||
t("please_try_again_later_or_book_another_slot"),
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
createInstantBookingMutation.mutate(variables);
|
||||
},
|
||||
handleRecBooking: createRecurringBookingMutation.mutate,
|
||||
handleBooking: createBookingMutation.mutate,
|
||||
isBookingDryRun,
|
||||
@@ -506,5 +557,6 @@ export const useBookings = ({
|
||||
errors,
|
||||
loadingStates,
|
||||
instantVideoMeetingUrl,
|
||||
instantConnectCooldownMs,
|
||||
};
|
||||
};
|
||||
@@ -8,7 +8,15 @@ const log = logger.getSubLogger({ prefix: ["RateLimit"] });
|
||||
export { type RatelimitResponse };
|
||||
|
||||
export type RateLimitHelper = {
|
||||
rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai" | "sms" | "smsMonth";
|
||||
rateLimitingType?:
|
||||
| "core"
|
||||
| "forcedSlowMode"
|
||||
| "common"
|
||||
| "api"
|
||||
| "ai"
|
||||
| "sms"
|
||||
| "smsMonth"
|
||||
| "instantMeeting";
|
||||
identifier: string;
|
||||
opts?: LimitOptions;
|
||||
/**
|
||||
@@ -51,6 +59,14 @@ export function rateLimiter() {
|
||||
timeout,
|
||||
onError,
|
||||
}),
|
||||
instantMeeting: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "instantMeeting",
|
||||
limit: 1,
|
||||
duration: "10m",
|
||||
timeout,
|
||||
onError,
|
||||
}),
|
||||
common: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "common",
|
||||
|
||||
Reference in new issue
Block a user