feat: useApiV2AvailableSlots (#21138)

* feat: useApiV2AvailableSlots

* feat: useApiV2AvailableSlots

* fix type check

* remove log

* fix unit tests

* simplify

* feat: add missing params to the slots endpoint

* updated documentation

* _shouldServeCache is correct

* fix types

* fix enabled prop

* fix type

* add embedConnectVersion to query key

* add teamId to slot type

* fix

* add fallback to trpc query if apiv2 fails

* add comment

* remove logs

* fix

* refactor: default to isTeamEvent in query params

* fix: only return v2 slots once fetch is success

* fix last e2e failing test

* add feature flag

* add migration sql

* Update packages/prisma/migrations/20250512153630_add_use_api_v2_for_team_slots_feature_flag/migration.sql

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Morgan
2025-05-12 20:48:29 +00:00
committed by GitHub
co-authored by hbjORbj Somay Chauhan supalarry Keith Williams
parent ade64c0d09
commit 048e56806c
16 changed files with 218 additions and 46 deletions
@@ -157,7 +157,10 @@ export class SlotsController_2024_04_15 {
@Query() query: GetAvailableSlotsInput_2024_04_15,
@Req() req: ExpressRequest
): Promise<ApiResponse<{ slots: TimeSlots["slots"] | RangeSlots["slots"] }>> {
const isTeamEvent = await this.slotsService.checkIfIsTeamEvent(query.eventTypeId);
const isTeamEvent =
query.isTeamEvent === undefined
? await this.slotsService.checkIfIsTeamEvent(query.eventTypeId)
: query.isTeamEvent;
const availableSlots = await getAvailableSlots({
input: {
...query,
+20 -20
View File
@@ -18293,10 +18293,10 @@
"type": {
"type": "string"
},
"value": {
"option": {
"type": "string"
},
"valueId": {
"optionId": {
"type": "string"
}
},
@@ -18304,8 +18304,8 @@
"id",
"name",
"type",
"value",
"valueId"
"option",
"optionId"
]
},
"NumberAttribute": {
@@ -18320,10 +18320,10 @@
"type": {
"type": "string"
},
"value": {
"option": {
"type": "number"
},
"valueId": {
"optionId": {
"type": "string"
}
},
@@ -18331,8 +18331,8 @@
"id",
"name",
"type",
"value",
"valueId"
"option",
"optionId"
]
},
"SingleSelectAttribute": {
@@ -18347,10 +18347,10 @@
"type": {
"type": "string"
},
"value": {
"option": {
"type": "string"
},
"valueId": {
"optionId": {
"type": "string"
}
},
@@ -18358,23 +18358,23 @@
"id",
"name",
"type",
"value",
"valueId"
"option",
"optionId"
]
},
"MultiSelectAttributeValue": {
"MultiSelectAttributeOption": {
"type": "object",
"properties": {
"valueId": {
"optionId": {
"type": "string"
},
"value": {
"option": {
"type": "string"
}
},
"required": [
"valueId",
"value"
"optionId",
"option"
]
},
"MultiSelectAttribute": {
@@ -18389,10 +18389,10 @@
"type": {
"type": "string"
},
"values": {
"options": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MultiSelectAttributeValue"
"$ref": "#/components/schemas/MultiSelectAttributeOption"
}
}
},
@@ -18400,7 +18400,7 @@
"id",
"name",
"type",
"values"
"options"
]
},
"MembershipUserOutputDto": {
@@ -6,6 +6,7 @@ import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
@@ -113,8 +114,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const featureRepo = new FeaturesRepository();
const useApiV2 = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
return {
props: {
useApiV2,
eventData: {
eventTypeId,
entity: {
+2
View File
@@ -35,6 +35,7 @@ function Type({
crmOwnerRecordType,
crmAppSlug,
isEmbed,
useApiV2,
}: PageProps) {
const searchParams = useSearchParams();
@@ -42,6 +43,7 @@ function Type({
<BookingPageErrorBoundary>
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
<Booker
useApiV2={useApiV2}
username={user}
eventSlug={slug}
bookingData={booking}
+16 -16
View File
@@ -16992,14 +16992,14 @@
"type": {
"type": "string"
},
"value": {
"option": {
"type": "string"
},
"valueId": {
"optionId": {
"type": "string"
}
},
"required": ["id", "name", "type", "value", "valueId"]
"required": ["id", "name", "type", "option", "optionId"]
},
"NumberAttribute": {
"type": "object",
@@ -17013,14 +17013,14 @@
"type": {
"type": "string"
},
"value": {
"option": {
"type": "number"
},
"valueId": {
"optionId": {
"type": "string"
}
},
"required": ["id", "name", "type", "value", "valueId"]
"required": ["id", "name", "type", "option", "optionId"]
},
"SingleSelectAttribute": {
"type": "object",
@@ -17034,26 +17034,26 @@
"type": {
"type": "string"
},
"value": {
"option": {
"type": "string"
},
"valueId": {
"optionId": {
"type": "string"
}
},
"required": ["id", "name", "type", "value", "valueId"]
"required": ["id", "name", "type", "option", "optionId"]
},
"MultiSelectAttributeValue": {
"MultiSelectAttributeOption": {
"type": "object",
"properties": {
"valueId": {
"optionId": {
"type": "string"
},
"value": {
"option": {
"type": "string"
}
},
"required": ["valueId", "value"]
"required": ["optionId", "option"]
},
"MultiSelectAttribute": {
"type": "object",
@@ -17067,14 +17067,14 @@
"type": {
"type": "string"
},
"values": {
"options": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MultiSelectAttributeValue"
"$ref": "#/components/schemas/MultiSelectAttributeOption"
}
}
},
"required": ["id", "name", "type", "values"]
"required": ["id", "name", "type", "options"]
},
"MembershipUserOutputDto": {
"type": "object",
@@ -96,6 +96,7 @@ export interface BookerProps {
areInstantMeetingParametersSet?: boolean | null;
userLocale?: string | null;
hasValidLicense?: boolean;
useApiV2?: boolean;
}
export type WrappedBookerPropsMain = {
@@ -71,6 +71,7 @@ export const useScheduleForEvent = ({
orgSlug,
teamMemberEmail,
isTeamEvent,
useApiV2 = true,
}: {
prefetchNextMonth?: boolean;
username?: string | null;
@@ -85,6 +86,7 @@ export const useScheduleForEvent = ({
teamMemberEmail?: string | null;
fromRedirectOfNonOrgLink?: boolean;
isTeamEvent?: boolean;
useApiV2?: boolean;
} = {}) => {
const { timezone } = useBookerTime();
const [usernameFromStore, eventSlugFromStore, monthFromStore, durationFromStore] = useBookerStore(
@@ -95,7 +97,7 @@ export const useScheduleForEvent = ({
const searchParams = useCompatSearchParams();
const rescheduleUid = searchParams?.get("rescheduleUid");
const schedule = useSchedule({
const scheduleUsingApiV2 = useSchedule({
username: usernameFromStore ?? username,
eventSlug: eventSlugFromStore ?? eventSlug,
eventId,
@@ -110,8 +112,32 @@ export const useScheduleForEvent = ({
isTeamEvent,
orgSlug,
teamMemberEmail,
useApiV2: useApiV2,
});
const scheduleNotUsingApiV2 = useSchedule({
username: usernameFromStore ?? username,
eventSlug: eventSlugFromStore ?? eventSlug,
eventId,
timezone,
selectedDate,
prefetchNextMonth,
monthCount,
dayCount,
rescheduleUid,
month: monthFromStore ?? month,
duration: durationFromStore ?? duration,
isTeamEvent,
orgSlug,
teamMemberEmail,
useApiV2: false,
// only run this query if the one using Api v2 fails
// Network error does not trigger `isError` flag, so we are instead using `failureReason` here
enabled: isTeamEvent && !!scheduleUsingApiV2?.failureReason,
});
const schedule = scheduleUsingApiV2?.isSuccess ? scheduleUsingApiV2 : scheduleNotUsingApiV2;
return {
data: schedule?.data,
isPending: schedule?.isPending,
+1
View File
@@ -278,6 +278,7 @@ const EmailEmbed = ({
eventId: eventType?.id,
isTeamEvent,
duration: selectedDuration,
useApiV2: false,
});
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);
+1
View File
@@ -20,4 +20,5 @@ export type AppFlags = {
"salesforce-crm-tasker": boolean;
"workflow-smtp-emails": boolean;
"cal-video-log-in-overlay": boolean;
"use-api-v2-for-team-slots": boolean;
};
+1
View File
@@ -19,6 +19,7 @@ const initialData: AppFlags = {
"salesforce-crm-tasker": false,
"workflow-smtp-emails": false,
"cal-video-log-in-overlay": false,
"use-api-v2-for-team-slots": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
@@ -0,0 +1,48 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import type {
GetAvailableSlotsInput_2024_04_15,
ApiResponse,
ApiSuccessResponse,
} from "@calcom/platform-types";
export const QUERY_KEY = "get-available-slots";
export const useApiV2AvailableSlots = ({
enabled,
...rest
}: GetAvailableSlotsInput_2024_04_15 & { enabled: boolean }) => {
const availableSlots = useQuery({
queryKey: [
QUERY_KEY,
rest.startTime,
rest.endTime,
rest.eventTypeId,
rest.eventTypeSlug,
rest.isTeamEvent ?? false,
rest.teamId ?? false,
rest.usernameList,
rest.routedTeamMemberIds,
rest.skipContactOwner,
rest._shouldServeCache,
rest.teamMemberEmail,
rest.embedConnectVersion ?? false,
],
queryFn: () => {
return axios
.get<ApiResponse<AvailableSlotsType>>(`${process.env.NEXT_PUBLIC_API_V2_URL}/slots/available`, {
params: rest,
})
.then((res) => {
if (res.data.status === "success") {
return (res.data as ApiSuccessResponse<AvailableSlotsType>).data;
}
throw new Error(res.data.error.message);
});
},
enabled: enabled,
});
return availableSlots;
};
@@ -8,6 +8,8 @@ import { PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS } from "@calcom/lib/const
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { trpc } from "@calcom/trpc/react";
import { useApiV2AvailableSlots } from "./useApiV2AvailableSlots";
export type UseScheduleWithCacheArgs = {
username?: string | null;
eventSlug?: string | null;
@@ -23,6 +25,8 @@ export type UseScheduleWithCacheArgs = {
isTeamEvent?: boolean;
orgSlug?: string;
teamMemberEmail?: string | null;
useApiV2?: boolean;
enabled?: boolean;
};
export const useSchedule = ({
@@ -40,6 +44,8 @@ export const useSchedule = ({
isTeamEvent,
orgSlug,
teamMemberEmail,
useApiV2 = false,
enabled: enabledProp = true,
}: UseScheduleWithCacheArgs) => {
const bookerState = useBookerStore((state) => state.state);
@@ -111,16 +117,40 @@ export const useSchedule = ({
Boolean(month) &&
Boolean(timezone) &&
// Should only wait for one or the other, not both.
(Boolean(eventSlug) || Boolean(eventId) || eventId === 0),
(Boolean(eventSlug) || Boolean(eventId) || eventId === 0) &&
enabledProp,
};
let schedule;
if (isTeamEvent) {
schedule = trpc.viewer.highPerf.getTeamSchedule.useQuery(input, options);
} else {
schedule = trpc.viewer.slots.getSchedule.useQuery(input, options);
const isCallingApiV2Slots = useApiV2 && Boolean(isTeamEvent) && options.enabled;
const teamSchedule = useApiV2AvailableSlots({
...input,
enabled: isCallingApiV2Slots,
duration: input.duration ? Number(input.duration) : undefined,
routedTeamMemberIds: input.routedTeamMemberIds ?? undefined,
teamMemberEmail: input.teamMemberEmail ?? undefined,
eventTypeId: eventId ?? undefined,
});
if (isCallingApiV2Slots) {
updateEmbedBookerState({
bookerState,
slotsQuery: teamSchedule,
});
return {
...teamSchedule,
/**
* Invalidates the request and resends it regardless of any other configuration including staleTime
*/
invalidate: () => {
return teamSchedule.refetch();
},
};
}
const schedule = isTeamEvent
? trpc.viewer.highPerf.getTeamSchedule.useQuery(input, options)
: trpc.viewer.slots.getSchedule.useQuery(input, options);
updateEmbedBookerState({
bookerState,
slotsQuery: schedule,
@@ -158,6 +158,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
teamMemberEmail: props.teamMemberEmail,
fromRedirectOfNonOrgLink: props.entity.fromRedirectOfNonOrgLink,
isTeamEvent: props.isTeamEvent ?? !!event.data?.team,
useApiV2: props.useApiV2,
});
const bookings = useBookings({
event,
@@ -15,7 +15,7 @@ export const QUERY_KEY = "get-available-slots";
export const useAvailableSlots = ({
enabled,
...rest
}: GetAvailableSlotsInput_2024_04_15 & { enabled: boolean; isTeamEvent?: boolean; teamId?: number }) => {
}: GetAvailableSlotsInput_2024_04_15 & { enabled: boolean }) => {
const availableSlots = useQuery({
queryKey: [
QUERY_KEY,
@@ -28,7 +28,7 @@ export const useAvailableSlots = ({
rest.usernameList,
rest.routedTeamMemberIds,
rest.skipContactOwner,
rest.shouldServeCache,
rest._shouldServeCache,
rest.teamMemberEmail,
],
queryFn: () => {
@@ -129,6 +129,50 @@ export class GetAvailableSlotsInput_2024_04_15 {
@IsOptional()
@ApiPropertyOptional()
teamMemberEmail?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
@ApiHideProperty()
embedConnectVersion?: string;
@IsString()
@IsOptional()
@ApiPropertyOptional()
@ApiHideProperty()
email?: string | null;
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
@Min(1, { message: "routingFormResponseId must be a positive number" })
@ApiPropertyOptional()
@ApiHideProperty()
routingFormResponseId?: number;
@Transform(({ value }) => value && value.toLowerCase() === "true")
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_shouldServeCache?: boolean;
@Transform(({ value }) => value && value.toLowerCase() === "true")
@IsBoolean()
@IsOptional()
@ApiHideProperty()
_bypassCalendarBusyTimes?: boolean;
@Transform(({ value }) => (value ? value.toLowerCase() === "true" : false))
@IsBoolean()
@IsOptional()
@ApiHideProperty()
isTeamEvent?: boolean;
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
@ApiHideProperty()
teamId?: number;
}
export class RemoveSelectedSlotInput_2024_04_15 {
@@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'use-api-v2-for-team-slots',
false,
'Whether to use API v2 for fetching team schedule slots.',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;