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:
co-authored by
hbjORbj
Somay Chauhan
supalarry
Keith Williams
parent
ade64c0d09
commit
048e56806c
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -278,6 +278,7 @@ const EmailEmbed = ({
|
||||
eventId: eventType?.id,
|
||||
isTeamEvent,
|
||||
duration: selectedDuration,
|
||||
useApiV2: false,
|
||||
});
|
||||
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+9
@@ -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;
|
||||
Reference in New Issue
Block a user