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:
Rajiv Sahal
2024-03-27 12:34:54 +00:00
committed by GitHub
co-authored by Morgan Vernay Morgan
parent a35683667b
commit cc2164657c
27 changed files with 986 additions and 131 deletions
@@ -0,0 +1,236 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { SchedulesRepository } from "@/ee/schedules/schedules.repository";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { AvailabilitiesModule } from "@/modules/availabilities/availabilities.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
handleNewBooking,
getBookingInfo,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
describe("User Authenticated", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let createdBooking: Awaited<ReturnType<typeof handleNewBooking>>;
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, AvailabilitiesModule, UsersModule],
providers: [SchedulesRepository, SchedulesService],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should create a booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingMetadata = {};
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: bookingMetadata,
hashedLink: bookingHashedLink,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings")
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof handleNewBooking>>> =
response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toBeDefined();
expect(responseBody.data.user.email).toEqual(userEmail);
expect(responseBody.data.id).toBeDefined();
expect(responseBody.data.uid).toBeDefined();
expect(responseBody.data.startTime).toEqual(bookingStart);
expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId);
expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone);
expect(responseBody.data.metadata).toEqual(bookingMetadata);
createdBooking = responseBody.data;
});
});
it("should get bookings", async () => {
return request(app.getHttpServer())
.get("/api/v2/ee/bookings")
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getAllUserBookings>>> =
response.body;
const fetchedBooking = responseBody.data.bookings[0];
expect(responseBody.data.bookings.length).toEqual(1);
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(fetchedBooking).toBeDefined();
expect(fetchedBooking.id).toEqual(createdBooking.id);
expect(fetchedBooking.uid).toEqual(createdBooking.uid);
expect(fetchedBooking.startTime).toEqual(createdBooking.startTime);
expect(fetchedBooking.endTime).toEqual(createdBooking.endTime);
expect(fetchedBooking.user?.email).toEqual(createdBooking.user.email);
});
});
it("should get booking", async () => {
return request(app.getHttpServer())
.get(`/api/v2/ee/bookings/${createdBooking.uid}`)
.then((response) => {
const responseBody: ApiSuccessResponse<Awaited<ReturnType<typeof getBookingInfo>>> = response.body;
const { bookingInfo } = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(bookingInfo?.id).toBeDefined();
expect(bookingInfo?.uid).toBeDefined();
expect(bookingInfo?.id).toEqual(createdBooking.id);
expect(bookingInfo?.uid).toEqual(createdBooking.uid);
expect(bookingInfo?.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(bookingInfo?.startTime).toEqual(createdBooking.startTime);
});
});
it("should create a recurring booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const bookingRecurringCount = 5;
const currentBookingRecurringIndex = 0;
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
recurringCount: bookingRecurringCount,
currentRecurringIndex: currentBookingRecurringIndex,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/reccuring")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleNewRecurringBooking>>> =
response.body;
expect(responseBody.status).toEqual("recurring");
});
});
it("should create an instant booking", async () => {
const bookingStart = "2023-05-25T09:30:00.000Z";
const bookingEnd = "2023-05-25T10:30:00.000Z";
const bookingEventTypeId = 7;
const bookingTimeZone = "Europe/Londom";
const bookingLanguage = "en";
const bookingHashedLink = "";
const body = {
start: bookingStart,
end: bookingEnd,
eventTypeId: bookingEventTypeId,
timeZone: bookingTimeZone,
language: bookingLanguage,
metadata: {},
hashedLink: bookingHashedLink,
};
return request(app.getHttpServer())
.post("/api/v2/ee/bookings/instant")
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<Awaited<ReturnType<typeof handleInstantMeeting>>> = response.body;
expect(responseBody.status).toEqual("instant");
});
});
it("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
allRemainingBookings: false,
cancellationReason: "Was fighting some unforseen rescheduling demons",
};
return request(app.getHttpServer())
.post(`/api/v2/ee/bookings/${bookingId}/cancel`)
.send(body)
.expect(201)
.then((response) => {
const responseBody: ApiResponse<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> =
response.body;
expect(bookingId).toBeDefined();
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1,8 +1,11 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateReccuringBookingInput } from "@/ee/bookings/inputs/create-reccuring-booking.input";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import {
Controller,
Post,
@@ -11,12 +14,23 @@ import {
InternalServerErrorException,
Body,
HttpException,
Param,
Get,
Query,
NotFoundException,
UseGuards,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries";
import {
handleNewBooking,
BookingResponse,
@@ -24,7 +38,9 @@ import {
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
import { GetBookingsInput, CancelBookingInput } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "ee/bookings",
@@ -34,7 +50,65 @@ import { ApiResponse } from "@calcom/platform-types";
export class BookingsController {
private readonly logger = new Logger("ee bookings controller");
constructor(private readonly oAuthFlowService: OAuthFlowService) {}
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService
) {}
// note(Rajiv): currently this endpoint is atoms only
@Get("/")
@UseGuards(AccessTokenGuard)
async getBookings(
@GetUser() user: User,
@Query() queryParams: GetBookingsInput
): Promise<ApiResponse<unknown>> {
const { filters, cursor, limit } = queryParams;
const bookings = await getAllUserBookings({
bookingListingByStatus: filters.status,
skip: cursor ?? 0,
take: limit ?? 10,
filters,
ctx: {
user: { email: user.email, id: user.id },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
},
});
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid")
async getBooking(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const { bookingInfo } = await getBookingInfo(bookingUid);
if (!bookingInfo) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
return {
status: SUCCESS_STATUS,
data: bookingInfo,
};
}
// note(Rajiv): currently this endpoint is atoms only
@Get("/:bookingUid/reschedule")
async getBookingForReschedule(@Param("bookingUid") bookingUid: string): Promise<ApiResponse<unknown>> {
const booking = await getBookingForReschedule(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
}
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/")
@Permissions([BOOKING_WRITE])
@@ -56,6 +130,30 @@ export class BookingsController {
throw new InternalServerErrorException("Could not create booking.");
}
@Post("/:bookingId/cancel")
@Permissions([BOOKING_WRITE])
async cancelBooking(
@Req() req: Request & { userId?: number },
@Param("bookingId") bookingId: string,
@Body() body: CancelBookingInput
): Promise<ApiResponse> {
if (bookingId) {
req.userId = await this.getOwnerId(req);
req.body = { ...body, id: parseInt(bookingId) };
try {
await handleCancelBooking(req as unknown as NextApiRequest & { userId?: number });
return {
status: SUCCESS_STATUS,
};
} catch (err) {
handleBookingErrors(err);
}
} else {
throw new NotFoundException("Booking ID is required.");
}
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/reccuring")
@Permissions([BOOKING_WRITE])
async createReccuringBooking(
@@ -0,0 +1,26 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Booking, User } from "@prisma/client";
export class BookingsRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.primaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async getById(bookingId: Booking["id"]) {
return this.primaReadClient.booking.findFirst({ where: { id: bookingId } });
}
async deleteById(bookingId: Booking["id"]) {
return this.prismaWriteClient.booking.delete({ where: { id: bookingId } });
}
async deleteAllBookings(userId: User["id"], userEmail: User["email"]) {
return this.prismaWriteClient.booking.deleteMany({ where: { userId, userPrimaryEmail: userEmail } });
}
}
@@ -2,7 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { parseRecurringEvent } from "@calcom/lib";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import { maybeGetBookingUidFromSeat } from "@calcom/lib/server/maybeGetBookingUidFromSeat";
@@ -62,58 +62,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const maybeBookingUidFromSeat = await maybeGetBookingUidFromSeat(prisma, uid);
if (maybeBookingUidFromSeat.uid) uid = maybeBookingUidFromSeat.uid;
if (maybeBookingUidFromSeat.seatReferenceUid) seatReferenceUid = maybeBookingUidFromSeat.seatReferenceUid;
const bookingInfoRaw = 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,
},
},
},
});
const { bookingInfoRaw, bookingInfo } = await getBookingInfo(uid);
if (!bookingInfoRaw) {
return {
notFound: true,
@@ -133,7 +84,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
requiresLoginToUpdate = true;
}
const bookingInfo = getBookingWithResponses(bookingInfoRaw);
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
bookingInfo["startTime"] = (bookingInfo?.startTime as Date)?.toISOString() as unknown as Date;
+3 -1
View File
@@ -26,7 +26,9 @@
"../../packages/types/next-auth.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
"../../packages/features/bookings/lib/getBookingInfo.ts",
"../../packages/features/bookings/lib/getUserBooking.ts"
],
"exclude": ["node_modules"]
}
+5
View File
@@ -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;
+104
View File
@@ -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;
+1
View File
@@ -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;
};
+3
View File
@@ -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";
+1
View File
@@ -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>
);
}
+9
View File
@@ -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 };
+82
View File
@@ -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;
}
+1
View File
@@ -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,
+5
View File
@@ -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) {