feat(api-v2): add GET /v2/bookings/by-seat/{seatUid} endpoint (#26786)

* feat(api-v2): add GET /v2/bookings/by-seat/{seatUid} endpoint

- Add new endpoint to retrieve a booking by its seat reference UID
- Add getByReferenceUidIncludeBookingWithAttendeesAndUserAndEvent method to BookingSeatRepository
- Add getBookingBySeatUid method to BookingsService
- Add e2e tests for the new endpoint

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: use Object.prototype.hasOwnProperty.call for ES compatibility in e2e tests

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: use select instead of include in BookingSeatRepository for security and performance

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: add teamId and userId to eventType select for access check

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: return seatUid attendee when not admin and seatsShowAttendees is false

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: use select for eventType in repository and fetch full eventType for access check

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Morgan
2026-02-04 17:54:10 +09:00
committed by GitHub
co-authored by morgan@cal.com Devin AI
parent 49a2fcaf65
commit 4367028492
4 changed files with 404 additions and 55 deletions
@@ -1,3 +1,50 @@
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
CancelBookingInputPipe,
CancelSeatedBookingInput_2024_08_13,
CreateBookingInput,
CreateBookingInput_2024_08_13,
CreateBookingInputPipe,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingsInput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetBookingTranscriptsOutput,
GetBookingVideoSessionsOutput,
MarkAbsentBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
RescheduleBookingInput,
RescheduleBookingInput_2024_08_13,
RescheduleBookingInputPipe,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Logger,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import {
ApiBody,
ApiExtraModels,
ApiHeader,
ApiOperation,
ApiTags as DocsTags,
getSchemaPath,
} from "@nestjs/swagger";
import { Request } from "express";
import { BookingPbacGuard } from "@/ee/bookings/2024-08-13/guards/booking-pbac.guard";
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
@@ -11,7 +58,7 @@ import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/out
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
import {
API_KEY_OR_ACCESS_TOKEN_HEADER,
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
@@ -31,56 +78,6 @@ import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/op
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { UsersService } from "@/modules/users/services/users.service";
import {
Controller,
Post,
Logger,
Body,
UseGuards,
Req,
Get,
Param,
Query,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import {
ApiOperation,
ApiTags as DocsTags,
ApiHeader,
getSchemaPath,
ApiBody,
ApiExtraModels,
} from "@nestjs/swagger";
import { Request } from "express";
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
CancelBookingInputPipe,
CancelSeatedBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
RescheduleBookingInput,
RescheduleBookingInput_2024_08_13,
RescheduleBookingInputPipe,
RescheduleSeatedBookingInput_2024_08_13,
GetBookingRecordingsOutput,
GetBookingTranscriptsOutput,
GetBookingVideoSessionsOutput,
} from "@calcom/platform-types";
import {
CreateBookingInputPipe,
CreateBookingInput,
GetBookingsInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
} from "@calcom/platform-types";
@Controller({
path: "/v2/bookings",
@@ -178,6 +175,33 @@ export class BookingsController_2024_08_13 {
};
}
@Get("/by-seat/:seatUid")
@UseGuards(OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get a booking by seat UID",
description: `Get a seated booking by its seat reference UID. This is useful when you have a seatUid from a seated booking and want to retrieve the full booking details.
If you are fetching a seated booking for an event type with 'show attendees' disabled, then to retrieve attendees in the response either set 'show attendees' to true on event type level or
you have to provide an authentication method of event type owner, host, team admin or owner or org admin or owner.
<Note>Please make sure to pass in the cal-api-version header value as mentioned in the Headers section. Not passing the correct value will default to an older version of this endpoint.</Note>
`,
})
async getBookingBySeatUid(
@Param("seatUid") seatUid: string,
@GetOptionalUser() user: AuthOptionalUser
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.getBookingBySeatUid(seatUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Get("/:bookingUid")
@UseGuards(BookingUidGuard, OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@@ -364,6 +364,185 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should get a booking by seatUid", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/by-seat/${createdSeatedBooking.seatUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toEqual(createdSeatedBooking.id);
expect(data.uid).toEqual(createdSeatedBooking.uid);
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedEventSlug,
});
expect(data.attendees.length).toEqual(2);
} else {
throw new Error(
"Invalid response data - expected seated booking but received non seated booking response"
);
}
});
});
it("should return 404 when getting booking by non-existent seatUid", async () => {
return request(app.getHttpServer())
.get("/v2/bookings/by-seat/non-existent-seat-uid")
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(404)
.then(async (response) => {
expect(response.body.error.message).toEqual(
"Booking with seatUid=non-existent-seat-uid was not found in the database"
);
});
});
describe("get booking by seatUid with seatsShowAttendees disabled", () => {
let bookingWithAttendeesDisabled: CreateSeatedBookingOutput_2024_08_13;
let secondAttendeeSeatUid: string;
const emailAttendeeThree = `seated-bookings-attendee3-${randomString()}@api.com`;
const nameAttendeeThree = `Attendee Three ${randomString()}`;
it("should book first seat on event type with seatsShowAttendees disabled", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
bookingWithAttendeesDisabled = data;
} else {
throw new Error("Invalid response data - expected seated booking");
}
});
});
it("should book second seat on same event type with seatsShowAttendees disabled", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
attendee: {
name: nameAttendeeThree,
email: emailAttendeeThree,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "Rust",
},
metadata: {
userId: "300",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
secondAttendeeSeatUid = data.seatUid;
} else {
throw new Error("Invalid response data - expected seated booking");
}
});
});
it("should return only the attendee for the specific seatUid when seatsShowAttendees is false and no auth", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/by-seat/${secondAttendeeSeatUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toEqual(bookingWithAttendeesDisabled.id);
expect(data.uid).toEqual(bookingWithAttendeesDisabled.uid);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0].email).toEqual(emailAttendeeThree);
expect(data.attendees[0].name).toEqual(nameAttendeeThree);
expect(data.attendees[0].seatUid).toEqual(secondAttendeeSeatUid);
} else {
throw new Error("Invalid response data - expected seated booking");
}
});
});
it("should return all attendees when admin auth is provided", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/by-seat/${secondAttendeeSeatUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toEqual(bookingWithAttendeesDisabled.id);
expect(data.uid).toEqual(bookingWithAttendeesDisabled.uid);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.attendees.length).toEqual(2);
await bookingsRepositoryFixture.deleteById(data.id);
} else {
throw new Error("Invalid response data - expected seated booking");
}
});
});
});
it("should not be able to reschedule seated booking if seatUid is not provided", async () => {
const body = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
@@ -924,11 +1103,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
return Object.hasOwn(data, "seatUid");
return Object.prototype.hasOwnProperty.call(data, "seatUid");
}
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
return data?.attendees?.every((attendee: any) =>
Object.prototype.hasOwnProperty.call(attendee, "seatUid")
);
}
afterAll(async () => {
@@ -615,6 +615,50 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputRecurringBookings(ids);
}
async getBookingBySeatUid(seatUid: string, authUser: AuthOptionalUser) {
const bookingSeat =
await this.bookingSeatRepository.getByReferenceUidIncludeBookingWithAttendeesAndUserAndEvent(seatUid);
if (!bookingSeat || !bookingSeat.booking) {
throw new NotFoundException(`Booking with seatUid=${seatUid} was not found in the database`);
}
const booking = bookingSeat.booking;
const userIsEventTypeAdminOrOwner =
authUser && booking.eventType
? await this.eventTypeAccessService.userIsEventTypeAdminOrOwner(
authUser,
booking.eventType as EventType
)
: false;
const isRecurring = !!booking.recurringEventId;
const seatsShowAttendees = !!booking.eventType?.seatsShowAttendees;
const showAllAttendees = userIsEventTypeAdminOrOwner || seatsShowAttendees;
// When user is not admin and seatsShowAttendees is false, show only the attendee for this seatUid
if (!showAllAttendees) {
const seatAttendee = booking.attendees.find(
(attendee) => attendee.bookingSeat?.referenceUid === seatUid
);
const bookingWithFilteredAttendees = {
...booking,
attendees: seatAttendee ? [seatAttendee] : [],
};
if (isRecurring) {
return this.outputService.getOutputRecurringSeatedBooking(bookingWithFilteredAttendees, true);
}
return this.outputService.getOutputSeatedBooking(bookingWithFilteredAttendees, true);
}
if (isRecurring) {
return this.outputService.getOutputRecurringSeatedBooking(booking, true);
}
return this.outputService.getOutputSeatedBooking(booking, true);
}
async getBookings(
queryParams: GetBookingsInput_2024_08_13,
user: { email: string; id: number; orgId?: number },
@@ -1,10 +1,13 @@
import { Injectable } from "@nestjs/common";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BookingSeatRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
) {}
async getByReferenceUid(referenceUid: string) {
return this.dbRead.prisma.bookingSeat.findUnique({
@@ -16,4 +19,101 @@ export class BookingSeatRepository {
},
});
}
async getByReferenceUidIncludeBookingWithAttendeesAndUserAndEvent(referenceUid: string) {
return this.dbRead.prisma.bookingSeat.findUnique({
where: {
referenceUid,
},
select: {
id: true,
referenceUid: true,
data: true,
metadata: true,
bookingId: true,
attendeeId: true,
booking: {
select: {
id: true,
uid: true,
userId: true,
idempotencyKey: true,
userPrimaryEmail: true,
title: true,
description: true,
customInputs: true,
responses: true,
startTime: true,
endTime: true,
status: true,
location: true,
metadata: true,
paid: true,
destinationCalendarId: true,
cancellationReason: true,
rejectionReason: true,
reassignReason: true,
reassignById: true,
dynamicEventSlugRef: true,
dynamicGroupSlugRef: true,
fromReschedule: true,
rescheduled: true,
recurringEventId: true,
smsReminderNumber: true,
scheduledJobs: true,
isRecorded: true,
iCalUID: true,
iCalSequence: true,
rating: true,
ratingFeedback: true,
noShowHost: true,
oneTimePassword: true,
cancelledBy: true,
rescheduledBy: true,
createdAt: true,
updatedAt: true,
eventTypeId: true,
creationSource: true,
attendees: {
select: {
name: true,
email: true,
timeZone: true,
locale: true,
phoneNumber: true,
noShow: true,
bookingSeat: {
select: {
id: true,
referenceUid: true,
data: true,
metadata: true,
bookingId: true,
attendeeId: true,
},
},
},
},
user: {
select: {
id: true,
name: true,
email: true,
username: true,
},
},
eventType: {
select: {
id: true,
slug: true,
seatsShowAttendees: true,
teamId: true,
userId: true,
},
},
},
},
},
});
}
}