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:
co-authored by
Morgan Vernay
Morgan
parent
a35683667b
commit
cc2164657c
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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