From 048e56806c9696dfa34ca8909e8d6225f1cbc550 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Mon, 12 May 2025 23:48:29 +0300 Subject: [PATCH] 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 Co-authored-by: Somay Chauhan Co-authored-by: supalarry Co-authored-by: Keith Williams --- .../controllers/slots.controller.ts | 5 +- apps/api/v2/swagger/documentation.json | 40 ++++++++-------- .../team/[slug]/[type]/getServerSideProps.tsx | 5 ++ apps/web/modules/team/type-view.tsx | 2 + docs/api-reference/v2/openapi.json | 32 ++++++------- packages/features/bookings/Booker/types.ts | 1 + .../features/bookings/Booker/utils/event.ts | 28 ++++++++++- packages/features/embed/Embed.tsx | 1 + packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 1 + .../use-schedule/useApiV2AvailableSlots.ts | 48 +++++++++++++++++++ .../schedules/lib/use-schedule/useSchedule.ts | 42 +++++++++++++--- .../atoms/booker/BookerWebWrapper.tsx | 1 + .../platform/atoms/hooks/useAvailableSlots.ts | 4 +- .../slots/slots-2024-04-15/inputs/index.ts | 44 +++++++++++++++++ .../migration.sql | 9 ++++ 16 files changed, 218 insertions(+), 46 deletions(-) create mode 100644 packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts create mode 100644 packages/prisma/migrations/20250512153630_add_use_api_v2_for_team_slots_feature_flag/migration.sql diff --git a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts index d8d2f20e06..501503f915 100644 --- a/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/slots-2024-04-15/controllers/slots.controller.ts @@ -157,7 +157,10 @@ export class SlotsController_2024_04_15 { @Query() query: GetAvailableSlotsInput_2024_04_15, @Req() req: ExpressRequest ): Promise> { - 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, diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 785d34d0bd..ad281f3b00 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -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": { diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 20b15904a2..1d075be0cc 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -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: { diff --git a/apps/web/modules/team/type-view.tsx b/apps/web/modules/team/type-view.tsx index 6153b6a025..2aaf43faef 100644 --- a/apps/web/modules/team/type-view.tsx +++ b/apps/web/modules/team/type-view.tsx @@ -35,6 +35,7 @@ function Type({ crmOwnerRecordType, crmAppSlug, isEmbed, + useApiV2, }: PageProps) { const searchParams = useSearchParams(); @@ -42,6 +43,7 @@ function Type({
{ 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, diff --git a/packages/features/embed/Embed.tsx b/packages/features/embed/Embed.tsx index 715427837e..f43690a9a7 100644 --- a/packages/features/embed/Embed.tsx +++ b/packages/features/embed/Embed.tsx @@ -278,6 +278,7 @@ const EmailEmbed = ({ eventId: eventType?.id, isTeamEvent, duration: selectedDuration, + useApiV2: false, }); const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots); diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 640bc7fb48..0e026221c2 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -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; }; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 6cadd3bcd3..1e98dd2e22 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -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) { diff --git a/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts b/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts new file mode 100644 index 0000000000..34ec55a3a2 --- /dev/null +++ b/packages/features/schedules/lib/use-schedule/useApiV2AvailableSlots.ts @@ -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>(`${process.env.NEXT_PUBLIC_API_V2_URL}/slots/available`, { + params: rest, + }) + .then((res) => { + if (res.data.status === "success") { + return (res.data as ApiSuccessResponse).data; + } + throw new Error(res.data.error.message); + }); + }, + enabled: enabled, + }); + return availableSlots; +}; diff --git a/packages/features/schedules/lib/use-schedule/useSchedule.ts b/packages/features/schedules/lib/use-schedule/useSchedule.ts index e619bcb9d6..3fc02f95f0 100644 --- a/packages/features/schedules/lib/use-schedule/useSchedule.ts +++ b/packages/features/schedules/lib/use-schedule/useSchedule.ts @@ -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, diff --git a/packages/platform/atoms/booker/BookerWebWrapper.tsx b/packages/platform/atoms/booker/BookerWebWrapper.tsx index 49c8fc7041..3dd99c3b15 100644 --- a/packages/platform/atoms/booker/BookerWebWrapper.tsx +++ b/packages/platform/atoms/booker/BookerWebWrapper.tsx @@ -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, diff --git a/packages/platform/atoms/hooks/useAvailableSlots.ts b/packages/platform/atoms/hooks/useAvailableSlots.ts index 158dac96fc..5258257fbf 100644 --- a/packages/platform/atoms/hooks/useAvailableSlots.ts +++ b/packages/platform/atoms/hooks/useAvailableSlots.ts @@ -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: () => { diff --git a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts index b225caf390..cc4f9cd564 100644 --- a/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts +++ b/packages/platform/types/slots/slots-2024-04-15/inputs/index.ts @@ -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 { diff --git a/packages/prisma/migrations/20250512153630_add_use_api_v2_for_team_slots_feature_flag/migration.sql b/packages/prisma/migrations/20250512153630_add_use_api_v2_for_team_slots_feature_flag/migration.sql new file mode 100644 index 0000000000..de67248fde --- /dev/null +++ b/packages/prisma/migrations/20250512153630_add_use_api_v2_for_team_slots_feature_flag/migration.sql @@ -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;