feat: platform endpoints to fetch/cancel/reschedule bookings and hooks (#14164)
* init bookings endpoint * abstracting functions * e2e tests for bookings * hooks for bookings endpoint * bookings respository fixtures * typings for booking input * fixup * abstract booking info code and use it as handler * import handlers for bookings endpoint * add cancel booking input * add handleCancelBooking handler in platform libraries * cancel booking endpoint * abstract call into its own separate fn * cancel booking hook * e2e test for cancel booking endpoint * fix import * export getBookings function * move getAllUserBookings into lib * add bookings folder to package exports * use getAllUserBookings from lib * fix import path * fix: hooks, endpoint and example for cancel / reschedule / list / booking success page * fix: unit test mock classNames import from lib * fix: unit test mock classNames import from lib --------- Co-authored-by: Morgan Vernay <morgan@cal.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
27 files changed
+986
-131
No files matched your search
@@ -26,6 +26,11 @@ vi.mock("@calcom/atoms/monorepo", () => ({
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
vi.mock("@calcom/lib", () => ({
|
||||
classNames: (...args: string[]) => {
|
||||
return args.filter(Boolean).join(" ");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/event-types/getEventTypesByViewer", () => ({}));
|
||||
vi.mock("@calcom/lib/event-types/getEventTypesPublic", () => ({}));
|
||||
|
||||
@@ -118,6 +118,8 @@ export type BookerStore = {
|
||||
rescheduleUid: string | null;
|
||||
bookingUid: string | null;
|
||||
bookingData: GetBookingType | null;
|
||||
setBookingData: (bookingData: GetBookingType | null | undefined) => void;
|
||||
|
||||
/**
|
||||
* Method called by booker component to set initial data.
|
||||
*/
|
||||
@@ -317,6 +319,9 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
|
||||
set({ selectedDuration });
|
||||
updateQueryParam("duration", selectedDuration ?? "");
|
||||
},
|
||||
setBookingData: (bookingData: GetBookingType | null | undefined) => {
|
||||
set({ bookingData: bookingData ?? null });
|
||||
},
|
||||
recurringEventCount: null,
|
||||
setRecurringEventCount: (recurringEventCount: number | null) => set({ recurringEventCount }),
|
||||
occurenceCount: null,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
|
||||
|
||||
import getUserBooking from "./getUserBooking";
|
||||
|
||||
const getBookingInfo = async (uid: string) => {
|
||||
const bookingInfoRaw = await getUserBooking(uid);
|
||||
|
||||
if (!bookingInfoRaw) {
|
||||
return { bookingInfoRaw: undefined, bookingInfo: undefined };
|
||||
}
|
||||
|
||||
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
|
||||
|
||||
return { bookingInfoRaw, bookingInfo };
|
||||
};
|
||||
|
||||
export default getBookingInfo;
|
||||
@@ -0,0 +1,60 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const getUserBooking = async (uid: string) => {
|
||||
const bookingInfo = await prisma.booking.findFirst({
|
||||
where: {
|
||||
uid: uid,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
smsReminderNumber: true,
|
||||
recurringEventId: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
location: true,
|
||||
status: true,
|
||||
metadata: true,
|
||||
cancellationReason: true,
|
||||
responses: true,
|
||||
rejectionReason: true,
|
||||
userPrimaryEmail: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
username: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
eventTypeId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
eventName: true,
|
||||
slug: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
seatsReferences: {
|
||||
select: {
|
||||
referenceUid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return bookingInfo;
|
||||
};
|
||||
|
||||
export default getUserBooking;
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { getBookings } from "@calcom/trpc/server/routers/viewer/bookings/get.handler";
|
||||
|
||||
type InputByStatus = "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed";
|
||||
type GetOptions = {
|
||||
ctx: {
|
||||
user: { id: number; email: string };
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
bookingListingByStatus: InputByStatus;
|
||||
take: number;
|
||||
skip: number;
|
||||
filters: {
|
||||
status: InputByStatus;
|
||||
teamIds?: number[] | undefined;
|
||||
userIds?: number[] | undefined;
|
||||
eventTypeIds?: number[] | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, skip }: GetOptions) => {
|
||||
const { prisma, user } = ctx;
|
||||
|
||||
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput> = {
|
||||
upcoming: {
|
||||
endTime: { gte: new Date() },
|
||||
// These changes are needed to not show confirmed recurring events,
|
||||
// as rescheduling or cancel for recurring event bookings should be
|
||||
// handled separately for each occurrence
|
||||
OR: [
|
||||
{
|
||||
recurringEventId: { not: null },
|
||||
status: { equals: BookingStatus.ACCEPTED },
|
||||
},
|
||||
{
|
||||
recurringEventId: { equals: null },
|
||||
status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] },
|
||||
},
|
||||
],
|
||||
},
|
||||
recurring: {
|
||||
endTime: { gte: new Date() },
|
||||
AND: [
|
||||
{ NOT: { recurringEventId: { equals: null } } },
|
||||
{ status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } },
|
||||
],
|
||||
},
|
||||
past: {
|
||||
endTime: { lte: new Date() },
|
||||
AND: [
|
||||
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||||
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
||||
],
|
||||
},
|
||||
cancelled: {
|
||||
OR: [{ status: { equals: BookingStatus.CANCELLED } }, { status: { equals: BookingStatus.REJECTED } }],
|
||||
},
|
||||
unconfirmed: {
|
||||
endTime: { gte: new Date() },
|
||||
status: { equals: BookingStatus.PENDING },
|
||||
},
|
||||
};
|
||||
const bookingListingOrderby: Record<
|
||||
typeof bookingListingByStatus,
|
||||
Prisma.BookingOrderByWithAggregationInput
|
||||
> = {
|
||||
upcoming: { startTime: "asc" },
|
||||
recurring: { startTime: "asc" },
|
||||
past: { startTime: "desc" },
|
||||
cancelled: { startTime: "desc" },
|
||||
unconfirmed: { startTime: "asc" },
|
||||
};
|
||||
|
||||
const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus];
|
||||
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
||||
|
||||
const { bookings, recurringInfo } = await getBookings({
|
||||
user,
|
||||
prisma,
|
||||
passedBookingsStatusFilter,
|
||||
filters: filters,
|
||||
orderBy,
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
|
||||
const bookingsFetched = bookings.length;
|
||||
let nextCursor: typeof skip | null = skip;
|
||||
if (bookingsFetched > take) {
|
||||
nextCursor += bookingsFetched;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
}
|
||||
|
||||
return {
|
||||
bookings,
|
||||
recurringInfo,
|
||||
nextCursor,
|
||||
};
|
||||
};
|
||||
|
||||
export default getAllUserBookings;
|
||||
@@ -7,3 +7,4 @@ export * from "./validateIntervalLimitOrder";
|
||||
export * from "./schedules";
|
||||
export * from "./event-types/getEventTypesByViewer";
|
||||
export * from "./event-types/getEventTypesPublic";
|
||||
export * from "./bookings/getAllUserBookings";
|
||||
@@ -27,6 +27,7 @@ import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
|
||||
import { useCreateBooking } from "../hooks/useCreateBooking";
|
||||
import { useCreateInstantBooking } from "../hooks/useCreateInstantBooking";
|
||||
import { useCreateRecurringBooking } from "../hooks/useCreateRecurringBooking";
|
||||
import { useGetBookingForReschedule } from "../hooks/useGetBookingForReschedule";
|
||||
import { useHandleBookEvent } from "../hooks/useHandleBookEvent";
|
||||
import { useMe } from "../hooks/useMe";
|
||||
import { usePublicEvent } from "../hooks/usePublicEvent";
|
||||
@@ -54,7 +55,15 @@ type BookerPlatformWrapperAtomProps = Omit<BookerProps, "entity"> & {
|
||||
export const BookerPlatformWrapper = (props: BookerPlatformWrapperAtomProps) => {
|
||||
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
|
||||
const setSelectedDate = useBookerStore((state) => state.setSelectedDate);
|
||||
const setBookingData = useBookerStore((state) => state.setBookingData);
|
||||
|
||||
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
|
||||
const { data: booking } = useGetBookingForReschedule({
|
||||
uid: props.rescheduleUid ?? props.bookingUid ?? "",
|
||||
onSuccess: (data) => {
|
||||
setBookingData(data);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// reset booker whenever it's unmounted
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiResponse, ApiErrorResponse } from "@calcom/platform-types";
|
||||
import type { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import http from "../lib/http";
|
||||
|
||||
interface IUseCancelBooking {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: ApiErrorResponse | Error) => void;
|
||||
}
|
||||
|
||||
type inputParams = z.infer<typeof schemaBookingCancelParams>;
|
||||
|
||||
export const useCancelBooking = (
|
||||
{ onSuccess, onError }: IUseCancelBooking = {
|
||||
onSuccess: () => {
|
||||
return;
|
||||
},
|
||||
onError: () => {
|
||||
return;
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const cancelBooking = useMutation<ApiResponse, Error, inputParams>({
|
||||
mutationFn: (data) => {
|
||||
return http.post<ApiResponse>(`/ee/bookings/${data.id}/cancel`, data).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return res.data;
|
||||
}
|
||||
throw new Error(res.data.error.message);
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.status === SUCCESS_STATUS) {
|
||||
onSuccess?.();
|
||||
} else {
|
||||
onError?.(data);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
onError?.(err);
|
||||
},
|
||||
});
|
||||
return cancelBooking;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BASE_URL, API_VERSION, V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { getBookingInfo } from "@calcom/platform-libraries";
|
||||
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
import http from "../lib/http";
|
||||
|
||||
export const QUERY_KEY = "user-booking";
|
||||
|
||||
export const useGetBooking = (uid = "") => {
|
||||
const endpoint = new URL(BASE_URL);
|
||||
|
||||
endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.bookings}/${uid}`;
|
||||
|
||||
const bookingQuery = useQuery({
|
||||
queryKey: [QUERY_KEY, uid],
|
||||
queryFn: () => {
|
||||
return http
|
||||
.get<ApiResponse<Awaited<ReturnType<typeof getBookingInfo>>["bookingInfo"]>>(endpoint.toString())
|
||||
.then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return (res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getBookingInfo>>["bookingInfo"]>)
|
||||
.data;
|
||||
}
|
||||
throw new Error(res.data.error.message);
|
||||
});
|
||||
},
|
||||
enabled: !!uid,
|
||||
});
|
||||
|
||||
return bookingQuery;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BASE_URL, API_VERSION, V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { getBookingForReschedule } from "@calcom/platform-libraries";
|
||||
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
import http from "../lib/http";
|
||||
|
||||
export const QUERY_KEY = "user-booking";
|
||||
|
||||
interface IUseGetBookingForReschedule {
|
||||
onSuccess?: (res: ApiSuccessResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>["data"]) => void;
|
||||
onError?: (err: Error) => void;
|
||||
uid?: string;
|
||||
}
|
||||
export const useGetBookingForReschedule = (
|
||||
props: IUseGetBookingForReschedule = {
|
||||
onSuccess: () => {
|
||||
return;
|
||||
},
|
||||
onError: () => {
|
||||
return;
|
||||
},
|
||||
uid: "",
|
||||
}
|
||||
) => {
|
||||
const endpoint = new URL(BASE_URL);
|
||||
|
||||
endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.bookings}/${props.uid}/reschedule`;
|
||||
|
||||
const bookingQuery = useQuery({
|
||||
queryKey: [QUERY_KEY, props.uid],
|
||||
queryFn: () => {
|
||||
return http
|
||||
.get<ApiResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>>(endpoint.toString())
|
||||
.then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
props.onSuccess?.(
|
||||
(res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>).data
|
||||
);
|
||||
return (res.data as ApiSuccessResponse<Awaited<ReturnType<typeof getBookingForReschedule>>>).data;
|
||||
}
|
||||
const error = new Error(res.data.error.message);
|
||||
props.onError?.(error);
|
||||
throw error;
|
||||
})
|
||||
.catch((err) => {
|
||||
props.onError?.(err);
|
||||
});
|
||||
},
|
||||
enabled: !!props?.uid,
|
||||
});
|
||||
|
||||
return bookingQuery;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BASE_URL, SUCCESS_STATUS, API_VERSION, V2_ENDPOINTS } from "@calcom/platform-constants";
|
||||
import type { getAllUserBookings } from "@calcom/platform-libraries";
|
||||
import type { ApiResponse } from "@calcom/platform-types";
|
||||
import type { GetBookingsInput } from "@calcom/platform-types/bookings";
|
||||
|
||||
import http from "../lib/http";
|
||||
|
||||
export const QUERY_KEY = "user-bookings";
|
||||
|
||||
export const useGetBookings = (input: GetBookingsInput) => {
|
||||
const endpoint = new URL(BASE_URL);
|
||||
|
||||
endpoint.pathname = `api/${API_VERSION}/${V2_ENDPOINTS.bookings}`;
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: [QUERY_KEY, input?.limit ?? 50, input?.cursor ?? 0, input?.filters?.status ?? "upcoming"],
|
||||
queryFn: () => {
|
||||
return http
|
||||
.get<ApiResponse<Awaited<ReturnType<typeof getAllUserBookings>>>>(endpoint.toString(), {
|
||||
params: input,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return res.data.data;
|
||||
}
|
||||
throw new Error(res.data.error.message);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return bookingsQuery;
|
||||
};
|
||||
@@ -6,3 +6,6 @@ export { useIsPlatform } from "./hooks/useIsPlatform";
|
||||
export { useAtomsContext } from "./hooks/useAtomsContext";
|
||||
export { useConnectedCalendars } from "./hooks/useConnectedCalendars";
|
||||
export { useEventTypesPublic } from "./hooks/event-types/useEventTypesPublic";
|
||||
export { useCancelBooking } from "./hooks/useCancelBooking";
|
||||
export { useGetBooking } from "./hooks/useGetBooking";
|
||||
export { useGetBookings } from "./hooks/useGetBookings";
|
||||
@@ -4,6 +4,7 @@ export const V2_ENDPOINTS = {
|
||||
me: "me",
|
||||
availability: "schedules",
|
||||
eventTypes: "event-types",
|
||||
bookings: "ee/bookings",
|
||||
};
|
||||
|
||||
export const SUCCESS_STATUS = "success";
|
||||
|
||||
@@ -23,7 +23,10 @@ export function Navbar({ username }: { username?: string }) {
|
||||
<Link href="/availability">Availability</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/booking">Bookings</Link>
|
||||
<Link href="/booking">Book Me</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/bookings">My Bookings</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Inter } from "next/font/google";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useGetBooking, useCancelBooking } from "@calcom/atoms";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function Bookings(props: { calUsername: string; calEmail: string }) {
|
||||
const router = useRouter();
|
||||
const { isLoading, data: booking, refetch } = useGetBooking((router.query.bookingUid as string) ?? "");
|
||||
const { mutate: cancelBooking } = useCancelBooking({
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<main
|
||||
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
|
||||
<Navbar username={props.calUsername} />
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{!isLoading && booking && (
|
||||
<div key={booking.id} className="my-2 w-[440px] overflow-hidden rounded p-2 shadow-md">
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-md mb-0.5 font-semibold">
|
||||
<p>{booking.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-2">
|
||||
<p>{booking.description}</p>
|
||||
<p>{booking.startTime}</p>
|
||||
<p>{booking.endTime}</p>
|
||||
<p>{booking.status}</p>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<button
|
||||
className="rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
cancelBooking({
|
||||
id: parseInt(booking.id),
|
||||
uid: booking.uid,
|
||||
cancellationReason: "User request",
|
||||
allRemainingBookings: true,
|
||||
});
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="ml-4 rounded bg-blue-500 px-4 py-2 font-bold text-white hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/booking?rescheduleUid=${booking?.uid}&eventTypeSlug=${booking?.eventType?.slug}`
|
||||
);
|
||||
}}>
|
||||
Reschedule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Inter } from "next/font/google";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Booker, useEventTypesPublic } from "@calcom/atoms";
|
||||
@@ -9,9 +10,10 @@ const inter = Inter({ subsets: ["latin"] });
|
||||
export default function Bookings(props: { calUsername: string; calEmail: string }) {
|
||||
const [bookingTitle, setBookingTitle] = useState<string | null>(null);
|
||||
const [eventTypeSlug, setEventTypeSlug] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { isLoading: isLoadingEvents, data: eventTypes } = useEventTypesPublic(props.calUsername);
|
||||
console.log(isLoadingEvents, eventTypes);
|
||||
const rescheduleUid = (router.query.rescheduleUid as string) ?? "";
|
||||
const eventTypeSlugQueryParam = (router.query.eventTypeSlug as string) ?? "";
|
||||
return (
|
||||
<main
|
||||
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
|
||||
@@ -21,7 +23,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
|
||||
{isLoadingEvents && !eventTypeSlug && <p>Loading...</p>}
|
||||
|
||||
{!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && (
|
||||
{!isLoadingEvents && !eventTypeSlug && Boolean(eventTypes?.length) && !rescheduleUid && (
|
||||
<div className="flex flex-row gap-4">
|
||||
{eventTypes?.map((event: { id: number; slug: string; title: string }) => (
|
||||
<button
|
||||
@@ -34,12 +36,24 @@ export default function Bookings(props: { calUsername: string; calEmail: string
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!bookingTitle && eventTypeSlug && (
|
||||
{!bookingTitle && eventTypeSlug && !rescheduleUid && (
|
||||
<Booker
|
||||
eventSlug="sixty-minutes-video"
|
||||
eventSlug={eventTypeSlug}
|
||||
username={props.calUsername ?? ""}
|
||||
onCreateBookingSuccess={(data) => {
|
||||
setBookingTitle(data.data.title);
|
||||
router.push(`/${data.data.uid}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!bookingTitle && rescheduleUid && eventTypeSlugQueryParam && (
|
||||
<Booker
|
||||
rescheduleUid={rescheduleUid}
|
||||
eventSlug={eventTypeSlugQueryParam}
|
||||
username={props.calUsername ?? ""}
|
||||
onCreateBookingSuccess={(data) => {
|
||||
setBookingTitle(data.data.title);
|
||||
router.push(`/${data.data.uid}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Inter } from "next/font/google";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useGetBookings } from "@calcom/atoms";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function Bookings(props: { calUsername: string; calEmail: string }) {
|
||||
const { isLoading: isLoadingUpcomingBookings, data: upcomingBookings } = useGetBookings({
|
||||
limit: 50,
|
||||
cursor: 0,
|
||||
filters: { status: "upcoming" },
|
||||
});
|
||||
|
||||
const { isLoading: isLoadingPastBookings, data: pastBookings } = useGetBookings({
|
||||
limit: 50,
|
||||
cursor: 0,
|
||||
filters: { status: "past" },
|
||||
});
|
||||
const isLoading = isLoadingUpcomingBookings || isLoadingPastBookings;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
|
||||
<Navbar username={props.calUsername} />
|
||||
<h1 className="my-4 text-2xl font-semibold">{props.calUsername} Bookings</h1>
|
||||
{isLoading && <p>Loading...</p>}
|
||||
{!isLoading &&
|
||||
(Boolean(upcomingBookings?.bookings.length) || Boolean(pastBookings?.bookings.length)) &&
|
||||
[...pastBookings?.bookings, ...upcomingBookings?.bookings].map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className="my-2 w-[440px] cursor-pointer overflow-hidden rounded shadow-md"
|
||||
onClick={() => {
|
||||
router.push(`/${booking.uid}`);
|
||||
}}>
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-md mb-0.5 font-semibold">
|
||||
<p>{booking.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-2">
|
||||
<p>{booking.startTime}</p>
|
||||
<p>{booking.endTime}</p>
|
||||
<p>{booking.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking";
|
||||
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleInstantMeeting";
|
||||
import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings";
|
||||
import { updateHandler as updateScheduleHandler } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.handler";
|
||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
import { createNewUsersConnectToOrgIfExists } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
|
||||
|
||||
export { getBookingForReschedule };
|
||||
export { updateScheduleHandler };
|
||||
export type UpdateScheduleOutputType = Awaited<
|
||||
ReturnType<
|
||||
@@ -62,3 +67,7 @@ export { TRPCError } from "@trpc/server";
|
||||
export type { TUpdateInputSchema } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.schema";
|
||||
|
||||
export { createNewUsersConnectToOrgIfExists };
|
||||
|
||||
export { getAllUserBookings };
|
||||
export { getBookingInfo };
|
||||
export { handleCancelBooking };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Type, Transform } from "class-transformer";
|
||||
import {
|
||||
ValidateNested,
|
||||
IsNumber,
|
||||
Min,
|
||||
Max,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsBoolean,
|
||||
IsString,
|
||||
} from "class-validator";
|
||||
|
||||
enum Status {
|
||||
upcoming = "upcoming",
|
||||
recurring = "recurring",
|
||||
past = "past",
|
||||
cancelled = "cancelled",
|
||||
unconfirmed = "unconfirmed",
|
||||
}
|
||||
|
||||
type BookingStatus = `${Status}`;
|
||||
|
||||
class Filters {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
teamsIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
userIds?: number[];
|
||||
|
||||
@IsEnum(Status)
|
||||
status!: BookingStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
eventTypeIds?: number[];
|
||||
}
|
||||
|
||||
export class GetBookingsInput {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Filters)
|
||||
filters!: Filters;
|
||||
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
cursor?: number | null;
|
||||
}
|
||||
|
||||
export class CancelBookingInput {
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
id?: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
uid?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
allRemainingBookings?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
cancellationReason?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
seatReferenceUid?: string;
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from "./events";
|
||||
export * from "./slots";
|
||||
export * from "./calendars";
|
||||
export * from "./schedules";
|
||||
export * from "./bookings";
|
||||
@@ -1,8 +1,9 @@
|
||||
import { parseRecurringEvent } from "@calcom/lib";
|
||||
import getAllUserBookings from "@calcom/lib/bookings/getAllUserBookings";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { bookingMinimalSelect } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
@@ -23,77 +24,15 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const skip = input.cursor ?? 0;
|
||||
const { prisma, user } = ctx;
|
||||
const bookingListingByStatus = input.filters.status;
|
||||
const bookingListingFilters: Record<typeof bookingListingByStatus, Prisma.BookingWhereInput> = {
|
||||
upcoming: {
|
||||
endTime: { gte: new Date() },
|
||||
// These changes are needed to not show confirmed recurring events,
|
||||
// as rescheduling or cancel for recurring event bookings should be
|
||||
// handled separately for each occurrence
|
||||
OR: [
|
||||
{
|
||||
recurringEventId: { not: null },
|
||||
status: { equals: BookingStatus.ACCEPTED },
|
||||
},
|
||||
{
|
||||
recurringEventId: { equals: null },
|
||||
status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] },
|
||||
},
|
||||
],
|
||||
},
|
||||
recurring: {
|
||||
endTime: { gte: new Date() },
|
||||
AND: [
|
||||
{ NOT: { recurringEventId: { equals: null } } },
|
||||
{ status: { notIn: [BookingStatus.CANCELLED, BookingStatus.REJECTED] } },
|
||||
],
|
||||
},
|
||||
past: {
|
||||
endTime: { lte: new Date() },
|
||||
AND: [
|
||||
{ NOT: { status: { equals: BookingStatus.CANCELLED } } },
|
||||
{ NOT: { status: { equals: BookingStatus.REJECTED } } },
|
||||
],
|
||||
},
|
||||
cancelled: {
|
||||
OR: [{ status: { equals: BookingStatus.CANCELLED } }, { status: { equals: BookingStatus.REJECTED } }],
|
||||
},
|
||||
unconfirmed: {
|
||||
endTime: { gte: new Date() },
|
||||
status: { equals: BookingStatus.PENDING },
|
||||
},
|
||||
};
|
||||
const bookingListingOrderby: Record<
|
||||
typeof bookingListingByStatus,
|
||||
Prisma.BookingOrderByWithAggregationInput
|
||||
> = {
|
||||
upcoming: { startTime: "asc" },
|
||||
recurring: { startTime: "asc" },
|
||||
past: { startTime: "desc" },
|
||||
cancelled: { startTime: "desc" },
|
||||
unconfirmed: { startTime: "asc" },
|
||||
};
|
||||
|
||||
const passedBookingsStatusFilter = bookingListingFilters[bookingListingByStatus];
|
||||
const orderBy = bookingListingOrderby[bookingListingByStatus];
|
||||
|
||||
const { bookings, recurringInfo } = await getBookings({
|
||||
user,
|
||||
prisma,
|
||||
passedBookingsStatusFilter,
|
||||
const { bookings, recurringInfo, nextCursor } = await getAllUserBookings({
|
||||
ctx: { user: { id: user.id, email: user.email }, prisma: prisma },
|
||||
bookingListingByStatus: bookingListingByStatus,
|
||||
take: take,
|
||||
skip: skip,
|
||||
filters: input.filters,
|
||||
orderBy,
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
|
||||
const bookingsFetched = bookings.length;
|
||||
let nextCursor: typeof skip | null = skip;
|
||||
if (bookingsFetched > take) {
|
||||
nextCursor += bookingsFetched;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
}
|
||||
|
||||
return {
|
||||
bookings,
|
||||
recurringInfo,
|
||||
@@ -112,7 +51,7 @@ const getUniqueBookings = <T extends { uid: string }>(arr: T[]) => {
|
||||
return unique;
|
||||
};
|
||||
|
||||
async function getBookings({
|
||||
export async function getBookings({
|
||||
user,
|
||||
prisma,
|
||||
passedBookingsStatusFilter,
|
||||
|
||||
@@ -67,6 +67,11 @@ vi.mock("@calcom/atoms/monorepo", () => ({
|
||||
|
||||
vi.mock("@calcom/lib/event-types/getEventTypesByViewer", () => ({}));
|
||||
vi.mock("@calcom/lib/event-types/getEventTypesPublic", () => ({}));
|
||||
vi.mock("@calcom/lib", () => ({
|
||||
classNames: (...args: string[]) => {
|
||||
return args.filter(Boolean).join(" ");
|
||||
},
|
||||
}));
|
||||
|
||||
expect.extend({
|
||||
tabToBeDisabled(received) {
|
||||
|
||||
Reference in new issue
Block a user