feat: add integration options for API v2 update booking location endpoint (#26363)
* init: improvements for update location endpoint * chore: init function to update calendar event * fix: bad imports * chore: update calendar event when updating location * chore: update platform libraries * fix: update calendar event * chore: update platform libraries * chore: cleanup * feat: add logic for video conferecing integrations * chore: update platform libraries * feat: add sms and email notifications * chore: update e2e tests * chore: update openapi spec * chore: implement cubic feedback * chore: update openapi spec * fix: add Jest mock for Daily.co video adapter in e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: mock createMeeting directly to bypass database check in e2e test Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * chore: implement PR feedback * chore: implement feedback * fix: mock throttler guard to prevent rate limiting in e2e tests Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * fix: merge conflicts * chore: update platform libraries * chore: implement feedback part 1 * chore: implement feedback part 2 * chore: remove unnecessary type casting * chore: implement cubic feedback * chore: implement devin feedback * chore: implement PR feedback * fix: type error * chore: update openapi spec * test: add mocks and tests for Google Meet and Microsoft Teams integration location updates Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * refactor: simplify service code - extract shared helpers, remove duplication Co-Authored-By: rajiv@cal.com <sahalrajiv6900@gmail.com> * feat: implement fixtures for bookings references --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
rajiv@cal.com
Devin AI
parent
4291a59b2c
commit
089a39f59f
@@ -8,7 +8,11 @@ import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingAttendeesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-attendees.service";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingLocationCalendarSyncService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service";
|
||||
import { BookingLocationCredentialService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-credential.service";
|
||||
import { BookingLocationIntegrationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-integration.service";
|
||||
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
|
||||
import { BookingVideoService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-video.service";
|
||||
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 { CalVideoOutputService } from "@/ee/bookings/2024-08-13/services/cal-video.output.service";
|
||||
@@ -108,7 +112,11 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
CalVideoService,
|
||||
CalVideoOutputService,
|
||||
BookingPbacGuard,
|
||||
BookingLocationCalendarSyncService_2024_08_13,
|
||||
BookingLocationCredentialService_2024_08_13,
|
||||
BookingLocationIntegrationService_2024_08_13,
|
||||
BookingLocationService_2024_08_13,
|
||||
BookingVideoService_2024_08_13,
|
||||
PrismaFeaturesRepository,
|
||||
],
|
||||
controllers: [
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output";
|
||||
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Controller, Patch, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { ApiExtraModels, ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
UpdateBookingLocationInput_2024_08_13,
|
||||
UpdateInputAddressLocation_2024_08_13,
|
||||
UpdateBookingInputAttendeeAddressLocation_2024_08_13,
|
||||
UpdateBookingInputAttendeeDefinedLocation_2024_08_13,
|
||||
UpdateBookingInputAttendeePhoneLocation_2024_08_13,
|
||||
UpdateBookingInputLinkLocation_2024_08_13,
|
||||
UpdateBookingInputPhoneLocation_2024_08_13,
|
||||
UpdateBookingLocationInput_2024_08_13,
|
||||
UpdateInputAddressLocation_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { Body, Controller, HttpCode, HttpStatus, Param, Patch, UseGuards } from "@nestjs/common";
|
||||
import { ApiExtraModels, ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output";
|
||||
import { BookingLocationService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location.service";
|
||||
import { VERSION_2024_08_13, VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings/:bookingUid/location",
|
||||
@@ -43,20 +43,23 @@ import {
|
||||
required: true,
|
||||
})
|
||||
export class BookingLocationController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingLocationController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingLocationService: BookingLocationService_2024_08_13) {}
|
||||
|
||||
@Patch("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ name: "booking_location_update", limit: 5, ttl: 60000, blockDuration: 60000 })
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Update booking location for an existing booking",
|
||||
description: `**Current Limitation:** Updating a booking location will update the location in Cal.com, but the corresponding Calendar event will not be updated automatically. The old location will persist in the Calendar event. This is a known limitation that will be addressed in a future update.
|
||||
|
||||
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>`,
|
||||
description: `Updates the location for an existing booking.
|
||||
|
||||
**Integration Fallback Behavior:**
|
||||
- **Google Meet**: Requires Google Calendar to be connected. If the booking has no Google Calendar event, it will automatically fall back to Cal Video.
|
||||
- **MS Teams**: If Office 365 Calendar is connected, generates the meeting link via calendar. Otherwise, uses the MS Teams video integration directly.
|
||||
|
||||
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>`,
|
||||
})
|
||||
async updateBookingLocation(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
|
||||
+278
@@ -12,13 +12,70 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
|
||||
const MOCK_GOOGLE_MEET_URL = "https://meet.google.com/mock-meet-id";
|
||||
const MOCK_MS_TEAMS_URL = "https://teams.microsoft.com/l/meetup-join/mock-teams-id";
|
||||
|
||||
jest.mock("@calcom/platform-libraries/conferencing", () => ({
|
||||
...jest.requireActual("@calcom/platform-libraries/conferencing"),
|
||||
createMeeting: jest.fn().mockResolvedValue({
|
||||
appName: "daily-video",
|
||||
type: "daily_video",
|
||||
uid: "MOCK_UID",
|
||||
originalEvent: {},
|
||||
success: true,
|
||||
createdEvent: {
|
||||
type: "daily_video",
|
||||
id: "MOCK_DAILY_ID",
|
||||
password: "MOCK_DAILY_PASS",
|
||||
url: "https://mock-daily.example.com/mock-meeting",
|
||||
},
|
||||
credentialId: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@calcom/platform-libraries", () => {
|
||||
const actual = jest.requireActual("@calcom/platform-libraries");
|
||||
return {
|
||||
...actual,
|
||||
updateEvent: jest.fn().mockImplementation((_credential, evt) => {
|
||||
const isGoogleMeet = evt.conferenceData?.createRequest;
|
||||
return Promise.resolve({
|
||||
uid: "MOCK_CALENDAR_UID",
|
||||
updatedEvent: {
|
||||
id: "MOCK_UPDATED_EVENT_ID",
|
||||
hangoutLink: isGoogleMeet ? MOCK_GOOGLE_MEET_URL : undefined,
|
||||
url: isGoogleMeet ? undefined : MOCK_MS_TEAMS_URL,
|
||||
},
|
||||
});
|
||||
}),
|
||||
CredentialRepository: {
|
||||
...actual.CredentialRepository,
|
||||
findCredentialForCalendarServiceById: jest.fn().mockResolvedValue({
|
||||
id: 99999,
|
||||
type: "google_calendar",
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
key: {},
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
}),
|
||||
},
|
||||
sendLocationChangeEmailsAndSMS: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
import { BookingReferenceRepositoryFixture } from "test/fixtures/repository/booking-reference.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { mockThrottlerGuard } from "test/utils/withNoThrottler";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { bootstrap } from "@/bootstrap";
|
||||
import { UpdateBookingLocationOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/update-location.output";
|
||||
@@ -27,6 +84,7 @@ import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
|
||||
type TestUser = {
|
||||
@@ -48,15 +106,19 @@ describe("Bookings Endpoints 2024-08-13 update booking location", () => {
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let bookingReferenceRepositoryFixture: BookingReferenceRepositoryFixture;
|
||||
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
let prismaWrite: PrismaWriteService;
|
||||
|
||||
let testSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
mockThrottlerGuard();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
@@ -68,11 +130,14 @@ describe("Bookings Endpoints 2024-08-13 update booking location", () => {
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
bookingReferenceRepositoryFixture = new BookingReferenceRepositoryFixture(moduleRef);
|
||||
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
prismaWrite = moduleRef.get(PrismaWriteService);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `update-booking-location-organization-${randomString()}`,
|
||||
@@ -322,6 +387,219 @@ describe("Bookings Endpoints 2024-08-13 update booking location", () => {
|
||||
expect(updatedBooking.location).toEqual(attendeeDefinedLocation);
|
||||
});
|
||||
|
||||
it("can update location to type integration (cal-video)", async () => {
|
||||
const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
|
||||
location: {
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
};
|
||||
|
||||
const updatedBookingResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/bookings/${bookingUid}/location`)
|
||||
.send(updatedBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 =
|
||||
updatedBookingResponse.body;
|
||||
expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
if (!responseDataIsBooking(updatedBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13;
|
||||
expect(updatedBooking).toHaveProperty("id");
|
||||
// cal-video location should be either a Daily video URL or the internal location string
|
||||
expect(
|
||||
updatedBooking.location?.startsWith("http") || updatedBooking.location === "integrations:daily"
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("can update location to type integration (google-meet)", async () => {
|
||||
const googleCredential = await credentialsRepositoryFixture.create(
|
||||
"google_calendar",
|
||||
{ access_token: "mock", refresh_token: "mock", expiry_date: Date.now() + 3600000 },
|
||||
testSetup.organizer.id,
|
||||
"google-calendar"
|
||||
);
|
||||
|
||||
const googleBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `google-meet-booking-${randomString(10)}`,
|
||||
title: "google meet booking",
|
||||
startTime: "2048-09-14T09:00:00.000Z",
|
||||
endTime: "2048-09-14T10:00:00.000Z",
|
||||
eventType: { connect: { id: eventTypeWithAllLocationsId } },
|
||||
status: "ACCEPTED",
|
||||
metadata: {},
|
||||
responses: "null",
|
||||
user: { connect: { id: testSetup.organizer.id } },
|
||||
});
|
||||
|
||||
await bookingReferenceRepositoryFixture.create({
|
||||
type: "google_calendar",
|
||||
uid: "mock-google-calendar-event-uid",
|
||||
booking: { connect: { id: googleBooking.id } },
|
||||
credential: { connect: { id: googleCredential.id } },
|
||||
externalCalendarId: "primary",
|
||||
});
|
||||
|
||||
const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
|
||||
location: {
|
||||
type: "integration",
|
||||
integration: "google-meet",
|
||||
},
|
||||
};
|
||||
|
||||
const updatedBookingResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/bookings/${googleBooking.uid}/location`)
|
||||
.send(updatedBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 =
|
||||
updatedBookingResponse.body;
|
||||
expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
if (!responseDataIsBooking(updatedBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13;
|
||||
expect(updatedBooking).toHaveProperty("id");
|
||||
expect(updatedBooking.location).toEqual(MOCK_GOOGLE_MEET_URL);
|
||||
|
||||
await bookingReferenceRepositoryFixture.deleteByBookingId(googleBooking.id);
|
||||
await bookingsRepositoryFixture.deleteById(googleBooking.id);
|
||||
await credentialsRepositoryFixture.delete(googleCredential.id);
|
||||
});
|
||||
|
||||
it("should fall back to Cal Video when google-meet is requested but no Google Calendar credential exists", async () => {
|
||||
const bookingWithoutGoogleCal = await bookingsRepositoryFixture.create({
|
||||
uid: `no-gcal-booking-${randomString(10)}`,
|
||||
title: "no google cal booking",
|
||||
startTime: "2048-12-14T09:00:00.000Z",
|
||||
endTime: "2048-12-14T10:00:00.000Z",
|
||||
eventType: { connect: { id: eventTypeWithAllLocationsId } },
|
||||
status: "ACCEPTED",
|
||||
metadata: {},
|
||||
responses: "null",
|
||||
user: { connect: { id: testSetup.organizer.id } },
|
||||
});
|
||||
|
||||
const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
|
||||
location: {
|
||||
type: "integration",
|
||||
integration: "google-meet",
|
||||
},
|
||||
};
|
||||
|
||||
const updatedBookingResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/bookings/${bookingWithoutGoogleCal.uid}/location`)
|
||||
.send(updatedBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 =
|
||||
updatedBookingResponse.body;
|
||||
expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
if (!responseDataIsBooking(updatedBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13;
|
||||
expect(updatedBooking).toHaveProperty("id");
|
||||
// No Google Calendar credential → fell back to Cal Video, not Google Meet
|
||||
expect(updatedBooking.location).not.toContain("meet.google.com");
|
||||
expect(["integrations:daily", "https://mock-daily.example.com/mock-meeting"]).toContain(
|
||||
updatedBooking.location
|
||||
);
|
||||
|
||||
await bookingsRepositoryFixture.deleteById(bookingWithoutGoogleCal.id);
|
||||
});
|
||||
|
||||
it("can update location to type integration (office365-video)", async () => {
|
||||
const office365Credential = await credentialsRepositoryFixture.create(
|
||||
"office365_calendar",
|
||||
{ access_token: "mock", refresh_token: "mock", expiry_date: Date.now() + 3600000 },
|
||||
testSetup.organizer.id,
|
||||
"office365-calendar"
|
||||
);
|
||||
|
||||
const msTeamsBooking = await bookingsRepositoryFixture.create({
|
||||
uid: `ms-teams-booking-${randomString(10)}`,
|
||||
title: "ms teams booking",
|
||||
startTime: "2048-10-14T09:00:00.000Z",
|
||||
endTime: "2048-10-14T10:00:00.000Z",
|
||||
eventType: { connect: { id: eventTypeWithAllLocationsId } },
|
||||
status: "ACCEPTED",
|
||||
metadata: {},
|
||||
responses: "null",
|
||||
user: { connect: { id: testSetup.organizer.id } },
|
||||
});
|
||||
|
||||
await bookingReferenceRepositoryFixture.create({
|
||||
type: "office365_calendar",
|
||||
uid: "mock-office365-calendar-event-uid",
|
||||
booking: { connect: { id: msTeamsBooking.id } },
|
||||
credential: { connect: { id: office365Credential.id } },
|
||||
externalCalendarId: "primary",
|
||||
});
|
||||
|
||||
const updatedBookingBody: UpdateBookingLocationInput_2024_08_13 = {
|
||||
location: {
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
};
|
||||
|
||||
const updatedBookingResponse = await request(app.getHttpServer())
|
||||
.patch(`/v2/bookings/${msTeamsBooking.uid}/location`)
|
||||
.send(updatedBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const updatedBookingResponseBody: UpdateBookingLocationOutput_2024_08_13 =
|
||||
updatedBookingResponse.body;
|
||||
expect(updatedBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
if (!responseDataIsBooking(updatedBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
const updatedBooking = updatedBookingResponseBody.data as BookingOutput_2024_08_13;
|
||||
expect(updatedBooking).toHaveProperty("id");
|
||||
expect(updatedBooking.location).toEqual(MOCK_MS_TEAMS_URL);
|
||||
|
||||
await bookingReferenceRepositoryFixture.deleteByBookingId(msTeamsBooking.id);
|
||||
await bookingsRepositoryFixture.deleteById(msTeamsBooking.id);
|
||||
await credentialsRepositoryFixture.delete(office365Credential.id);
|
||||
});
|
||||
|
||||
it("should return 400 when updating to unsupported integration", async () => {
|
||||
const updatedBookingBody = {
|
||||
location: {
|
||||
type: "integration",
|
||||
integration: "unsupported-video-integration",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/v2/bookings/${bookingUid}/location`)
|
||||
.send(updatedBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.status).toEqual(ERROR_STATUS);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (responseDataIsBooking(booking)) {
|
||||
await bookingsRepositoryFixture.deleteById(bookingId);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { bookingWithUserAndEventDetailsSelect } from "@calcom/platform-libraries/bookings";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
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";
|
||||
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
@@ -185,6 +185,20 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingByUidWithUserAndEventDetails(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: { uid },
|
||||
select: bookingWithUserAndEventDetailsSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingByIdWithUserAndEventDetails(id: number) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: { id },
|
||||
select: bookingWithUserAndEventDetailsSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async getRecurringByUid(uid: string) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import type { BookingWithUserAndEventDetails, CalendarEvent } from "@calcom/platform-libraries";
|
||||
import { buildCalEventFromBooking, sendLocationChangeEmailsAndSMS, updateEvent } from "@calcom/platform-libraries";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingLocationCredentialService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-credential.service";
|
||||
|
||||
@Injectable()
|
||||
export class BookingLocationCalendarSyncService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingLocationCalendarSyncService_2024_08_13");
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly credentialService: BookingLocationCredentialService_2024_08_13
|
||||
) {}
|
||||
|
||||
async buildCalEventFromBookingData(
|
||||
booking: BookingWithUserAndEventDetails,
|
||||
location: string,
|
||||
conferenceCredentialId: number | null
|
||||
): Promise<CalendarEvent> {
|
||||
return buildCalEventFromBooking({
|
||||
booking: {
|
||||
title: booking.title,
|
||||
description: booking.description,
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
userPrimaryEmail: booking.userPrimaryEmail,
|
||||
uid: booking.uid,
|
||||
destinationCalendar: booking.destinationCalendar,
|
||||
user: booking.user
|
||||
? {
|
||||
destinationCalendar: booking.user.destinationCalendar,
|
||||
}
|
||||
: null,
|
||||
attendees: booking.attendees.map((attendee: BookingWithUserAndEventDetails["attendees"][number]) => ({
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
timeZone: attendee.timeZone,
|
||||
locale: attendee.locale,
|
||||
})),
|
||||
eventType: booking.eventType
|
||||
? {
|
||||
title: booking.eventType.title,
|
||||
recurringEvent: booking.eventType.recurringEvent,
|
||||
seatsPerTimeSlot: booking.eventType.seatsPerTimeSlot,
|
||||
seatsShowAttendees: booking.eventType.seatsShowAttendees,
|
||||
hideOrganizerEmail: booking.eventType.hideOrganizerEmail,
|
||||
customReplyToEmail: booking.eventType.customReplyToEmail,
|
||||
}
|
||||
: null,
|
||||
iCalUID: booking.iCalUID,
|
||||
iCalSequence: booking.iCalSequence,
|
||||
},
|
||||
organizer: {
|
||||
email: booking.user?.email || "",
|
||||
name: booking.user?.name ?? null,
|
||||
timeZone: booking.user?.timeZone || "UTC",
|
||||
locale: booking.user?.locale ?? null,
|
||||
},
|
||||
location,
|
||||
conferenceCredentialId,
|
||||
organizationId: booking.user?.profiles?.[0]?.organizationId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async sendLocationChangeNotifications(
|
||||
evt: CalendarEvent,
|
||||
bookingUid: string,
|
||||
newLocation: string,
|
||||
eventTypeMetadata?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await sendLocationChangeEmailsAndSMS({ ...evt, location: newLocation }, eventTypeMetadata);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send location change emails for booking uid=${bookingUid}`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async syncCalendarEvent(bookingId: number, newLocation: string): Promise<void> {
|
||||
const booking = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(bookingId);
|
||||
|
||||
if (!booking || !booking.user) {
|
||||
this.logger.log(`syncCalendarEvent - No booking or user found for id=${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarReferences = booking.references.filter(
|
||||
(ref) => ref.type.includes("_calendar") && !ref.deleted
|
||||
);
|
||||
|
||||
if (calendarReferences.length === 0) {
|
||||
this.logger.log(`syncCalendarEvent - No calendar references for booking id=${bookingId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const evt = await this.buildCalEventFromBookingData(booking, newLocation, null);
|
||||
|
||||
for (const reference of calendarReferences) {
|
||||
const credential = await this.credentialService.getCredentialForReference(reference, booking.user.credentials);
|
||||
|
||||
if (!credential) {
|
||||
this.logger.warn(
|
||||
`syncCalendarEvent - No credential found for reference id=${reference.id}, credentialId=${reference.credentialId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateEvent(credential, evt, reference.uid, reference.externalCalendarId);
|
||||
this.logger.log(
|
||||
`syncCalendarEvent - Successfully updated calendar event for reference id=${reference.id}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`syncCalendarEvent - Failed to update calendar for reference id=${reference.id}`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import type { CredentialForCalendarService } from "@calcom/platform-libraries";
|
||||
import { CredentialRepository } from "@calcom/platform-libraries";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class BookingLocationCredentialService_2024_08_13 {
|
||||
async getCredentialForReference(
|
||||
reference: {
|
||||
credentialId: number | null;
|
||||
delegationCredentialId: string | null;
|
||||
type: string;
|
||||
},
|
||||
userCredentials: Array<{
|
||||
id: number;
|
||||
delegationCredentialId: string | null;
|
||||
type: string;
|
||||
}>
|
||||
): Promise<CredentialForCalendarService | null> {
|
||||
const credentialId = this.resolveCredentialId(reference, userCredentials);
|
||||
if (!credentialId) {
|
||||
return null;
|
||||
}
|
||||
return CredentialRepository.findCredentialForCalendarServiceById({ id: credentialId });
|
||||
}
|
||||
|
||||
private resolveCredentialId(
|
||||
reference: {
|
||||
credentialId: number | null;
|
||||
delegationCredentialId: string | null;
|
||||
type: string;
|
||||
},
|
||||
userCredentials: Array<{
|
||||
id: number;
|
||||
delegationCredentialId: string | null;
|
||||
type: string;
|
||||
}>
|
||||
): number | null {
|
||||
if (reference.delegationCredentialId) {
|
||||
const delegationCred = userCredentials.find(
|
||||
(cred) => cred.delegationCredentialId === reference.delegationCredentialId
|
||||
);
|
||||
if (delegationCred) return delegationCred.id;
|
||||
}
|
||||
|
||||
if (reference.credentialId && reference.credentialId > 0) {
|
||||
const localCred = userCredentials.find((cred) => cred.id === reference.credentialId);
|
||||
return localCred?.id ?? reference.credentialId;
|
||||
}
|
||||
|
||||
const typeCred = userCredentials.find((cred) => cred.type === reference.type);
|
||||
return typeCred?.id ?? null;
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
import type { BookingWithUserAndEventDetails, CalendarEvent } from "@calcom/platform-libraries";
|
||||
import { BookingReferenceRepository, updateEvent } from "@calcom/platform-libraries";
|
||||
import { makeUserActor } from "@calcom/platform-libraries/bookings";
|
||||
import { createMeeting, FAKE_DAILY_CREDENTIAL } from "@calcom/platform-libraries/conferencing";
|
||||
import type { Integration_2024_08_13 } from "@calcom/platform-types";
|
||||
import type {
|
||||
BookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types/bookings/2024-08-13/outputs/booking.output";
|
||||
import type { Booking, Prisma } from "@calcom/prisma/client";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingLocationCalendarSyncService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service";
|
||||
import { BookingLocationCredentialService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-credential.service";
|
||||
import { BookingVideoService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-video.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { apiToInternalintegrationsMapping } from "@/ee/event-types/event-types_2024_06_14/transformers/api-to-internal/locations";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export type BookingLocationResponse =
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13[]
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
| GetRecurringSeatedBookingOutput_2024_08_13
|
||||
| GetRecurringSeatedBookingOutput_2024_08_13[];
|
||||
|
||||
export type BookingForLocationUpdate = Pick<
|
||||
Booking,
|
||||
"id" | "uid" | "userId" | "eventTypeId" | "location" | "responses" | "metadata"
|
||||
>;
|
||||
|
||||
type BookingWithRequiredUser = Omit<BookingWithUserAndEventDetails, "user"> & {
|
||||
user: NonNullable<BookingWithUserAndEventDetails["user"]>;
|
||||
};
|
||||
|
||||
function bookingHasUser(booking: BookingWithUserAndEventDetails | null): booking is BookingWithRequiredUser {
|
||||
return booking !== null && booking.user !== null;
|
||||
}
|
||||
|
||||
export type IntegrationHandlerContext = {
|
||||
existingBooking: BookingForLocationUpdate;
|
||||
booking: BookingWithRequiredUser;
|
||||
integrationSlug: string;
|
||||
internalLocation: string;
|
||||
user: ApiAuthGuardUser;
|
||||
existingBookingHost: { organizationId: number | null } | null;
|
||||
inputLocation: { type: "integration"; integration: Integration_2024_08_13 };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BookingLocationIntegrationService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingLocationIntegrationService_2024_08_13");
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly inputService: InputBookingsService_2024_08_13,
|
||||
private readonly bookingVideoService: BookingVideoService_2024_08_13,
|
||||
private readonly bookingEventHandlerService: BookingEventHandlerService,
|
||||
private readonly featuresRepository: PrismaFeaturesRepository,
|
||||
private readonly calendarSyncService: BookingLocationCalendarSyncService_2024_08_13,
|
||||
private readonly credentialService: BookingLocationCredentialService_2024_08_13
|
||||
) {}
|
||||
|
||||
async handleIntegrationLocationUpdate(
|
||||
existingBooking: BookingForLocationUpdate,
|
||||
inputLocation: { type: "integration"; integration: Integration_2024_08_13 },
|
||||
user: ApiAuthGuardUser,
|
||||
existingBookingHost: { organizationId: number | null } | null
|
||||
): Promise<BookingLocationResponse> {
|
||||
if (!existingBookingHost) {
|
||||
throw new NotFoundException(`No user found for booking with uid=${existingBooking.uid}`);
|
||||
}
|
||||
|
||||
const integrationSlug = inputLocation.integration;
|
||||
const internalLocation =
|
||||
apiToInternalintegrationsMapping[integrationSlug as keyof typeof apiToInternalintegrationsMapping];
|
||||
|
||||
if (!internalLocation) {
|
||||
throw new BadRequestException(`Unsupported integration: ${integrationSlug}`);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(existingBooking.id);
|
||||
if (!bookingHasUser(booking)) {
|
||||
throw new NotFoundException(`Could not load booking details for uid=${existingBooking.uid}`);
|
||||
}
|
||||
|
||||
const ctx: IntegrationHandlerContext = {
|
||||
existingBooking,
|
||||
booking,
|
||||
integrationSlug,
|
||||
internalLocation,
|
||||
user,
|
||||
existingBookingHost,
|
||||
inputLocation,
|
||||
};
|
||||
|
||||
switch (integrationSlug) {
|
||||
case "google-meet":
|
||||
return this.handleGoogleMeetLocation(ctx);
|
||||
case "office365-video":
|
||||
return this.handleMSTeamsLocation(ctx);
|
||||
case "cal-video":
|
||||
return this.handleCalVideoLocation(ctx);
|
||||
default:
|
||||
// all other integrations (Zoom, Webex, etc.) use VideoApiAdapter
|
||||
return this.handleVideoApiIntegration(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGoogleMeetLocation(ctx: IntegrationHandlerContext): Promise<BookingLocationResponse> {
|
||||
const hasGoogleCalendar = ctx.booking.references.some(
|
||||
(ref) => ref.type.includes("google_calendar") && !ref.deleted
|
||||
);
|
||||
|
||||
if (!hasGoogleCalendar) {
|
||||
this.logger.log(`Google Meet requested but no Google Calendar found. Falling back to Cal Video.`);
|
||||
return this.handleCalVideoLocation({
|
||||
...ctx,
|
||||
integrationSlug: "cal-video",
|
||||
internalLocation: "integrations:daily",
|
||||
});
|
||||
}
|
||||
|
||||
return this.handleCalendarBasedIntegration(ctx, "google_calendar");
|
||||
}
|
||||
|
||||
private async handleMSTeamsLocation(ctx: IntegrationHandlerContext): Promise<BookingLocationResponse> {
|
||||
const hasOffice365Calendar = ctx.booking.references.some(
|
||||
(ref) => ref.type.includes("office365_calendar") && !ref.deleted
|
||||
);
|
||||
|
||||
if (hasOffice365Calendar) {
|
||||
return this.handleCalendarBasedIntegration(ctx, "office365_calendar");
|
||||
}
|
||||
|
||||
return this.handleVideoApiIntegration(ctx);
|
||||
}
|
||||
|
||||
private async handleCalVideoLocation(ctx: IntegrationHandlerContext): Promise<BookingLocationResponse> {
|
||||
const credential = { ...FAKE_DAILY_CREDENTIAL };
|
||||
|
||||
await this.bookingVideoService.deleteOldVideoMeetingIfNeeded(ctx.existingBooking.id);
|
||||
|
||||
const evt = await this.calendarSyncService.buildCalEventFromBookingData(
|
||||
ctx.booking,
|
||||
ctx.internalLocation,
|
||||
credential.id
|
||||
);
|
||||
const meetingResult = await createMeeting(credential, evt);
|
||||
|
||||
if (!meetingResult.createdEvent) {
|
||||
this.logger.error(
|
||||
`Failed to create Cal Video meeting`,
|
||||
JSON.stringify({ success: meetingResult.success, type: meetingResult.type })
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`Failed to create Cal Video meeting. Please ensure DAILY_API_KEY is set and the daily-video app is enabled.`
|
||||
);
|
||||
}
|
||||
|
||||
const createdEvent = meetingResult.createdEvent;
|
||||
const videoCallUrl = createdEvent.url;
|
||||
const bookingLocation = videoCallUrl || ctx.internalLocation;
|
||||
|
||||
// FAKE_DAILY_CREDENTIAL has id: 0, so we don't include credentialId
|
||||
const newReference = {
|
||||
type: credential.type,
|
||||
uid: createdEvent.id?.toString() || "",
|
||||
meetingId: createdEvent.id?.toString(),
|
||||
meetingPassword: createdEvent.password,
|
||||
meetingUrl: createdEvent.url,
|
||||
};
|
||||
|
||||
await BookingReferenceRepository.replaceBookingReferences({
|
||||
bookingId: ctx.existingBooking.id,
|
||||
newReferencesToCreate: [newReference],
|
||||
});
|
||||
|
||||
if (videoCallUrl) {
|
||||
await this.calendarSyncService.syncCalendarEvent(ctx.existingBooking.id, bookingLocation);
|
||||
}
|
||||
|
||||
return this.updateBookingWithVideoLocation(ctx, videoCallUrl, bookingLocation, evt);
|
||||
}
|
||||
|
||||
private async handleVideoApiIntegration(ctx: IntegrationHandlerContext): Promise<BookingLocationResponse> {
|
||||
const credential = await this.bookingVideoService.findVideoCredentialForIntegration(
|
||||
ctx.integrationSlug,
|
||||
ctx.booking.user?.credentials || []
|
||||
);
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException(
|
||||
`Video integration "${ctx.integrationSlug}" is not connected. Please connect the integration in your settings first.`
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingVideoService.deleteOldVideoMeetingIfNeeded(ctx.existingBooking.id);
|
||||
|
||||
const evt = await this.calendarSyncService.buildCalEventFromBookingData(
|
||||
ctx.booking,
|
||||
ctx.internalLocation,
|
||||
credential.id
|
||||
);
|
||||
const meetingResult = await createMeeting(credential, evt);
|
||||
|
||||
if (!meetingResult.createdEvent) {
|
||||
this.logger.error(
|
||||
`Failed to create video meeting with ${ctx.integrationSlug}`,
|
||||
JSON.stringify({ success: meetingResult.success, type: meetingResult.type })
|
||||
);
|
||||
throw new BadRequestException(
|
||||
`Failed to create video meeting with ${ctx.integrationSlug}. Please ensure the integration is properly configured.`
|
||||
);
|
||||
}
|
||||
|
||||
const createdEvent = meetingResult.createdEvent;
|
||||
const videoCallUrl = createdEvent.url;
|
||||
const bookingLocation = videoCallUrl || ctx.internalLocation;
|
||||
|
||||
const newReference = {
|
||||
type: credential.type,
|
||||
uid: createdEvent.id?.toString() || "",
|
||||
meetingId: createdEvent.id?.toString(),
|
||||
meetingPassword: createdEvent.password,
|
||||
meetingUrl: createdEvent.url,
|
||||
// only include credentialId if it's a valid ID (not 0)
|
||||
...(credential.id > 0 ? { credentialId: credential.id } : {}),
|
||||
};
|
||||
|
||||
await BookingReferenceRepository.replaceBookingReferences({
|
||||
bookingId: ctx.existingBooking.id,
|
||||
newReferencesToCreate: [newReference],
|
||||
});
|
||||
|
||||
if (videoCallUrl) {
|
||||
await this.calendarSyncService.syncCalendarEvent(ctx.existingBooking.id, bookingLocation);
|
||||
}
|
||||
|
||||
return this.updateBookingWithVideoLocation(ctx, videoCallUrl, bookingLocation, evt);
|
||||
}
|
||||
|
||||
private async handleCalendarBasedIntegration(
|
||||
ctx: IntegrationHandlerContext,
|
||||
requiredCalendarType: string
|
||||
): Promise<BookingLocationResponse> {
|
||||
const calendarReference = ctx.booking.references.find(
|
||||
(ref) => ref.type.includes(requiredCalendarType) && !ref.deleted
|
||||
);
|
||||
|
||||
if (!calendarReference) {
|
||||
throw new BadRequestException(
|
||||
`No ${requiredCalendarType.replace("_", " ")} event found for this booking. ${ctx.integrationSlug} requires a ${requiredCalendarType.replace("_", " ")} event to generate the meeting link.`
|
||||
);
|
||||
}
|
||||
|
||||
const calendarCredential = await this.credentialService.getCredentialForReference(
|
||||
calendarReference,
|
||||
ctx.booking.user?.credentials || []
|
||||
);
|
||||
|
||||
if (!calendarCredential) {
|
||||
throw new BadRequestException(
|
||||
`Could not find calendar credentials for ${ctx.integrationSlug}. Please reconnect your calendar.`
|
||||
);
|
||||
}
|
||||
|
||||
const evt = await this.calendarSyncService.buildCalEventFromBookingData(
|
||||
ctx.booking,
|
||||
ctx.internalLocation,
|
||||
null
|
||||
);
|
||||
|
||||
if (ctx.integrationSlug === "google-meet") {
|
||||
evt.conferenceData = {
|
||||
createRequest: {
|
||||
requestId: `${ctx.booking.uid}-meet`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updateResult = await updateEvent(
|
||||
calendarCredential,
|
||||
evt,
|
||||
calendarReference.uid,
|
||||
calendarReference.externalCalendarId
|
||||
);
|
||||
|
||||
let meetingUrl: string | undefined;
|
||||
if (updateResult.updatedEvent) {
|
||||
const updatedEvent = Array.isArray(updateResult.updatedEvent)
|
||||
? updateResult.updatedEvent[0]
|
||||
: updateResult.updatedEvent;
|
||||
meetingUrl = updatedEvent?.hangoutLink || updatedEvent?.url;
|
||||
}
|
||||
|
||||
const bookingLocation = meetingUrl || ctx.internalLocation;
|
||||
|
||||
const updatedMetadata = this.buildUpdatedMetadata(ctx.existingBooking.metadata, meetingUrl);
|
||||
|
||||
const updatedBooking = await this.bookingsRepository.updateBooking(ctx.existingBooking.uid, {
|
||||
location: bookingLocation,
|
||||
metadata: updatedMetadata as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
await this.emitLocationChangeEvents(ctx, bookingLocation, evt);
|
||||
|
||||
return this.bookingsService.getBooking(updatedBooking.uid, ctx.user);
|
||||
}
|
||||
|
||||
private async updateBookingWithVideoLocation(
|
||||
ctx: IntegrationHandlerContext,
|
||||
videoCallUrl: string | undefined,
|
||||
bookingLocation: string,
|
||||
evt: CalendarEvent
|
||||
): Promise<BookingLocationResponse> {
|
||||
const updatedMetadata = this.buildUpdatedMetadata(ctx.existingBooking.metadata, videoCallUrl);
|
||||
|
||||
const bookingFieldsLocation = this.inputService.transformLocation(ctx.inputLocation);
|
||||
const responses = (ctx.existingBooking.responses || {}) as Record<string, unknown>;
|
||||
const { location: _existingLocation, ...rest } = responses;
|
||||
|
||||
const updatedBooking = await this.bookingsRepository.updateBooking(ctx.existingBooking.uid, {
|
||||
location: bookingLocation,
|
||||
responses: { ...rest, location: bookingFieldsLocation },
|
||||
metadata: updatedMetadata as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
await this.emitLocationChangeEvents(ctx, bookingLocation, evt);
|
||||
|
||||
return this.bookingsService.getBooking(updatedBooking.uid, ctx.user);
|
||||
}
|
||||
|
||||
private buildUpdatedMetadata(
|
||||
existingMetadata: unknown,
|
||||
videoCallUrl: string | undefined
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...((existingMetadata || {}) as Record<string, unknown>),
|
||||
videoCallUrl,
|
||||
};
|
||||
}
|
||||
|
||||
private async emitLocationChangeEvents(
|
||||
ctx: IntegrationHandlerContext,
|
||||
bookingLocation: string,
|
||||
evt: CalendarEvent
|
||||
): Promise<void> {
|
||||
const organizationId = ctx.existingBookingHost?.organizationId ?? null;
|
||||
const isBookingAuditEnabled = organizationId
|
||||
? await this.featuresRepository.checkIfTeamHasFeature(organizationId, "booking-audit")
|
||||
: false;
|
||||
|
||||
await this.bookingEventHandlerService.onLocationChanged({
|
||||
bookingUid: ctx.existingBooking.uid,
|
||||
actor: makeUserActor(ctx.user.uuid),
|
||||
organizationId,
|
||||
source: "API_V2",
|
||||
auditData: {
|
||||
location: {
|
||||
old: ctx.existingBooking.location,
|
||||
new: bookingLocation,
|
||||
},
|
||||
},
|
||||
isBookingAuditEnabled,
|
||||
});
|
||||
|
||||
await this.calendarSyncService.sendLocationChangeNotifications(
|
||||
evt,
|
||||
ctx.existingBooking.uid,
|
||||
bookingLocation,
|
||||
ctx.booking.eventType?.metadata as Record<string, unknown> | undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
import { makeUserActor } from "@calcom/platform-libraries/bookings";
|
||||
import type {
|
||||
BookingInputLocation_2024_08_13,
|
||||
UpdateBookingInputLocation_2024_08_13,
|
||||
UpdateBookingLocationInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingLocationCalendarSyncService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-location-calendar-sync.service";
|
||||
import {
|
||||
type BookingForLocationUpdate,
|
||||
BookingLocationIntegrationService_2024_08_13,
|
||||
type BookingLocationResponse,
|
||||
} from "@/ee/bookings/2024-08-13/services/booking-location-integration.service";
|
||||
import { BookingVideoService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-video.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
|
||||
import { BookingEventHandlerService } from "@/lib/services/booking-event-handler.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { EventTypeAccessService } from "@/modules/event-types/services/event-type-access.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
|
||||
@@ -28,15 +40,18 @@ export class BookingLocationService_2024_08_13 {
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly eventTypeAccessService: EventTypeAccessService,
|
||||
private readonly bookingEventHandlerService: BookingEventHandlerService,
|
||||
private readonly featuresRepository: PrismaFeaturesRepository
|
||||
private readonly bookingVideoService: BookingVideoService_2024_08_13,
|
||||
private readonly featuresRepository: PrismaFeaturesRepository,
|
||||
private readonly integrationService: BookingLocationIntegrationService_2024_08_13,
|
||||
private readonly calendarSyncService: BookingLocationCalendarSyncService_2024_08_13
|
||||
) {}
|
||||
|
||||
async updateBookingLocation(
|
||||
bookingUid: string,
|
||||
input: UpdateBookingLocationInput_2024_08_13,
|
||||
user: ApiAuthGuardUser
|
||||
) {
|
||||
const existingBooking = await this.bookingsRepository.getByUidWithEventType(bookingUid);
|
||||
): Promise<BookingLocationResponse> {
|
||||
const existingBooking = await this.bookingsRepository.getBookingByUidWithUserAndEventDetails(bookingUid);
|
||||
if (!existingBooking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
@@ -58,6 +73,12 @@ export class BookingLocationService_2024_08_13 {
|
||||
const { location } = input;
|
||||
|
||||
if (location) {
|
||||
if (location.type !== "integration") {
|
||||
const locationValue = this.getNonIntegrationLocationValue(location);
|
||||
if (locationValue) {
|
||||
await this.calendarSyncService.syncCalendarEvent(existingBooking.id, locationValue);
|
||||
}
|
||||
}
|
||||
return await this.updateLocation(existingBooking, location, user);
|
||||
}
|
||||
|
||||
@@ -65,13 +86,12 @@ export class BookingLocationService_2024_08_13 {
|
||||
}
|
||||
|
||||
private async updateLocation(
|
||||
existingBooking: Booking,
|
||||
existingBooking: BookingForLocationUpdate,
|
||||
inputLocation: UpdateBookingInputLocation_2024_08_13,
|
||||
user: ApiAuthGuardUser
|
||||
) {
|
||||
): Promise<BookingLocationResponse> {
|
||||
const bookingUid = existingBooking.uid;
|
||||
const oldLocation = existingBooking.location;
|
||||
const bookingLocation = this.getLocationValue(inputLocation) ?? existingBooking.location;
|
||||
|
||||
if (!existingBooking.userId) {
|
||||
throw new NotFoundException(`No user found for booking with uid=${bookingUid}`);
|
||||
@@ -87,18 +107,38 @@ export class BookingLocationService_2024_08_13 {
|
||||
throw new NotFoundException(`No user found for booking with uid=${bookingUid}`);
|
||||
}
|
||||
|
||||
const bookingFieldsLocation = this.inputService.transformLocation(
|
||||
inputLocation as BookingInputLocation_2024_08_13
|
||||
);
|
||||
if (inputLocation.type === "integration") {
|
||||
return this.integrationService.handleIntegrationLocationUpdate(
|
||||
existingBooking,
|
||||
inputLocation,
|
||||
user,
|
||||
existingBookingHost
|
||||
);
|
||||
}
|
||||
|
||||
const bookingLocation = this.getNonIntegrationLocationValue(inputLocation);
|
||||
if (!bookingLocation) {
|
||||
throw new BadRequestException(`Missing or invalid location value for type: ${inputLocation.type}`);
|
||||
}
|
||||
|
||||
const bookingFieldsLocation = this.inputService.transformLocation(inputLocation);
|
||||
|
||||
const responses = (existingBooking.responses || {}) as Record<string, unknown>;
|
||||
const { location: _existingLocation, ...rest } = responses;
|
||||
|
||||
const updatedBookingResponses = { ...rest, location: bookingFieldsLocation };
|
||||
const updatedBookingResponses = {
|
||||
...rest,
|
||||
location: bookingFieldsLocation,
|
||||
};
|
||||
|
||||
const metadataWithoutVideoUrl = this.getMetadataWithoutVideoCallUrl(existingBooking.metadata);
|
||||
|
||||
await this.bookingVideoService.deleteOldVideoMeetingIfNeeded(existingBooking.id);
|
||||
|
||||
const updatedBooking = await this.bookingsRepository.updateBooking(bookingUid, {
|
||||
location: bookingLocation,
|
||||
responses: updatedBookingResponses,
|
||||
metadata: metadataWithoutVideoUrl as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
const organizationId = existingBookingHost.organizationId ?? null;
|
||||
@@ -120,10 +160,41 @@ export class BookingLocationService_2024_08_13 {
|
||||
isBookingAuditEnabled,
|
||||
});
|
||||
|
||||
if (bookingLocation) {
|
||||
await this.sendLocationChangeNotifications(existingBooking.id, existingBooking.uid, bookingLocation);
|
||||
}
|
||||
|
||||
return this.bookingsService.getBooking(updatedBooking.uid, user);
|
||||
}
|
||||
|
||||
private getLocationValue(loc: UpdateBookingInputLocation_2024_08_13): string | undefined {
|
||||
private async sendLocationChangeNotifications(
|
||||
bookingId: number,
|
||||
bookingUid: string,
|
||||
bookingLocation: string
|
||||
): Promise<void> {
|
||||
const bookingWithDetails = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(bookingId);
|
||||
|
||||
if (!bookingWithDetails || !bookingWithDetails.user) {
|
||||
this.logger.warn(
|
||||
`Unable to send location change notifications: ${!bookingWithDetails ? "booking details" : "user"} not found for bookingId=${bookingId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const evt = await this.calendarSyncService.buildCalEventFromBookingData(
|
||||
bookingWithDetails,
|
||||
bookingLocation,
|
||||
null
|
||||
);
|
||||
await this.calendarSyncService.sendLocationChangeNotifications(
|
||||
evt,
|
||||
bookingUid,
|
||||
bookingLocation,
|
||||
bookingWithDetails.eventType?.metadata as Record<string, unknown> | undefined
|
||||
);
|
||||
}
|
||||
|
||||
private getNonIntegrationLocationValue(loc: UpdateBookingInputLocation_2024_08_13): string | undefined {
|
||||
if (loc.type === "address") return loc.address;
|
||||
if (loc.type === "link") return loc.link;
|
||||
if (loc.type === "phone") return loc.phone;
|
||||
@@ -132,11 +203,17 @@ export class BookingLocationService_2024_08_13 {
|
||||
if (loc.type === "attendeeDefined") return loc.location;
|
||||
|
||||
this.logger.log(
|
||||
`Booking location service getLocationValue - loc ${JSON.stringify(
|
||||
loc
|
||||
)} was passed but the type is not supported.`
|
||||
`Booking location service getNonIntegrationLocationValue - unsupported type: ${loc.type}`
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// in case of video integrations we need to clear the videoCallUrl from metadata when explicitly setting a new non-integration location
|
||||
// this ensures the frontend shows the new location instead of the old integration URL
|
||||
private getMetadataWithoutVideoCallUrl(metadata: unknown): Record<string, unknown> {
|
||||
const existingMetadata = (metadata || {}) as Record<string, unknown>;
|
||||
const { videoCallUrl: _removedVideoUrl, ...metadataWithoutVideoUrl } = existingMetadata;
|
||||
return metadataWithoutVideoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { CredentialForCalendarService } from "@calcom/platform-libraries";
|
||||
import { CredentialRepository } from "@calcom/platform-libraries";
|
||||
import { deleteMeeting, FAKE_DAILY_CREDENTIAL } from "@calcom/platform-libraries/conferencing";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
|
||||
@Injectable()
|
||||
export class BookingVideoService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingVideoService_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||
|
||||
async deleteOldVideoMeetingIfNeeded(bookingId: number): Promise<void> {
|
||||
const booking = await this.bookingsRepository.getBookingByIdWithUserAndEventDetails(bookingId);
|
||||
if (!booking || !booking.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoReferences = booking.references.filter(
|
||||
(ref) => (ref.type.endsWith("_video") || ref.type.endsWith("_conferencing")) && !ref.deleted && ref.uid
|
||||
);
|
||||
|
||||
for (const reference of videoReferences) {
|
||||
const credential = await this.findCredentialForVideoReference(reference, booking.user.credentials);
|
||||
|
||||
if (credential && reference.uid) {
|
||||
try {
|
||||
await deleteMeeting(credential, reference.uid);
|
||||
this.logger.log(
|
||||
`deleteOldVideoMeetingIfNeeded - Deleted video meeting for reference id=${reference.id}, type=${reference.type}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`deleteOldVideoMeetingIfNeeded - Failed to delete video meeting for reference id=${reference.id}`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async findCredentialForVideoReference(
|
||||
reference: {
|
||||
credentialId: number | null;
|
||||
delegationCredentialId: string | null;
|
||||
type: string;
|
||||
},
|
||||
userCredentials: Array<{
|
||||
id: number;
|
||||
delegationCredentialId: string | null;
|
||||
type: string;
|
||||
}>
|
||||
): Promise<CredentialForCalendarService | null> {
|
||||
if (reference.credentialId && reference.credentialId > 0) {
|
||||
const localCred = userCredentials.find(
|
||||
(cred) =>
|
||||
cred.id === reference.credentialId &&
|
||||
(cred.type.endsWith("_video") || cred.type.endsWith("_conferencing"))
|
||||
);
|
||||
if (localCred) {
|
||||
return CredentialRepository.findCredentialForCalendarServiceById({
|
||||
id: localCred.id,
|
||||
});
|
||||
}
|
||||
|
||||
return CredentialRepository.findCredentialForCalendarServiceById({
|
||||
id: reference.credentialId,
|
||||
});
|
||||
}
|
||||
|
||||
const typeCred = userCredentials.find((cred) => cred.type === reference.type);
|
||||
if (typeCred) {
|
||||
return CredentialRepository.findCredentialForCalendarServiceById({
|
||||
id: typeCred.id,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async findVideoCredentialForIntegration(
|
||||
integrationSlug: string,
|
||||
userCredentials: Array<{
|
||||
id: number;
|
||||
type: string;
|
||||
delegationCredentialId: string | null;
|
||||
}>
|
||||
): Promise<CredentialForCalendarService | null> {
|
||||
if (integrationSlug === "cal-video") {
|
||||
return { ...FAKE_DAILY_CREDENTIAL };
|
||||
}
|
||||
|
||||
const integrationToCredentialTypeMap = {
|
||||
zoom: "zoom_video",
|
||||
"whereby-video": "whereby_video",
|
||||
"webex-video": "webex_video",
|
||||
tandem: "tandem_video",
|
||||
jitsi: "jitsi_video",
|
||||
huddle: "huddle01_video",
|
||||
"office365-video": "office365_video",
|
||||
} as const;
|
||||
|
||||
const credentialType =
|
||||
integrationSlug in integrationToCredentialTypeMap
|
||||
? integrationToCredentialTypeMap[integrationSlug as keyof typeof integrationToCredentialTypeMap]
|
||||
: undefined;
|
||||
|
||||
const matchingCred = userCredentials.find((cred) => {
|
||||
if (credentialType) {
|
||||
return cred.type === credentialType;
|
||||
}
|
||||
const normalizedSlug = integrationSlug.replace(/-/g, "_");
|
||||
return cred.type.includes(normalizedSlug) || cred.type.includes(normalizedSlug.replace("_video", ""));
|
||||
});
|
||||
|
||||
if (!matchingCred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return CredentialRepository.findCredentialForCalendarServiceById({
|
||||
id: matchingCred.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
confirmBookingHandler,
|
||||
distributedTracing,
|
||||
getAllUserBookings,
|
||||
getCalendarLinks,
|
||||
getTranslation,
|
||||
@@ -1240,6 +1241,7 @@ export class BookingsService_2024_08_13 {
|
||||
...requestUser,
|
||||
destinationCalendar: userCalendars?.destinationCalendar ?? null,
|
||||
},
|
||||
traceContext: distributedTracing.createTrace("api_v2_confirm_booking"),
|
||||
},
|
||||
input: {
|
||||
bookingId: booking.id,
|
||||
@@ -1275,6 +1277,7 @@ export class BookingsService_2024_08_13 {
|
||||
...requestUser,
|
||||
destinationCalendar: userCalendars?.destinationCalendar ?? null,
|
||||
},
|
||||
traceContext: distributedTracing.createTrace("api_v2_decline_booking"),
|
||||
},
|
||||
input: {
|
||||
bookingId: booking.id,
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import type { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input";
|
||||
import type { UpdateBookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/update-location.input";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
type BookingRequest = NextApiRequest & {
|
||||
@@ -306,7 +307,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
} as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
transformLocation(location: string | BookingInputLocation_2024_08_13): {
|
||||
transformLocation(location: string | BookingInputLocation_2024_08_13 | UpdateBookingInputLocation_2024_08_13): {
|
||||
value: string;
|
||||
optionValue: string;
|
||||
} {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Booking, BookingReference, Prisma } from "@calcom/prisma/client";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
|
||||
export class BookingReferenceRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
private prismaWriteClient: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(module: TestingModule) {
|
||||
this.prismaReadClient = module.get(PrismaReadService).prisma;
|
||||
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async getById(id: BookingReference["id"]) {
|
||||
return this.prismaReadClient.bookingReference.findFirst({ where: { id } });
|
||||
}
|
||||
|
||||
async getByBookingId(bookingId: Booking["id"]) {
|
||||
return this.prismaReadClient.bookingReference.findMany({ where: { bookingId } });
|
||||
}
|
||||
|
||||
async create(bookingReference: Prisma.BookingReferenceCreateInput) {
|
||||
return this.prismaWriteClient.bookingReference.create({ data: bookingReference });
|
||||
}
|
||||
|
||||
async deleteById(id: BookingReference["id"]) {
|
||||
return this.prismaWriteClient.bookingReference.deleteMany({ where: { id } });
|
||||
}
|
||||
|
||||
async deleteByBookingId(bookingId: Booking["id"]) {
|
||||
return this.prismaWriteClient.bookingReference.deleteMany({ where: { bookingId } });
|
||||
}
|
||||
}
|
||||
@@ -10541,7 +10541,7 @@
|
||||
"patch": {
|
||||
"operationId": "BookingLocationController_2024_08_13_updateBookingLocation",
|
||||
"summary": "Update booking location for an existing booking",
|
||||
"description": "**Current Limitation:** Updating a booking location will update the location in Cal.com, but the corresponding Calendar event will not be updated automatically. The old location will persist in the Calendar event. This is a known limitation that will be addressed in a future update.\n \n <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>",
|
||||
"description": "**Current Limitation:** Updating a booking location will update the location in Cal.com, but the corresponding Calendar event will not be updated automatically. The old location will persist in the Calendar event. This is a known limitation that will be addressed in a future update.\n\n <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -32891,6 +32891,52 @@
|
||||
},
|
||||
"required": ["type", "phone"]
|
||||
},
|
||||
"UpdateBookingInputIntegrationLocation_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integration",
|
||||
"description": "only allowed value for type is `integration`"
|
||||
},
|
||||
"integration": {
|
||||
"type": "string",
|
||||
"example": "cal-video",
|
||||
"enum": [
|
||||
"cal-video",
|
||||
"google-meet",
|
||||
"zoom",
|
||||
"whereby-video",
|
||||
"whatsapp-video",
|
||||
"webex-video",
|
||||
"telegram-video",
|
||||
"tandem",
|
||||
"sylaps-video",
|
||||
"skype-video",
|
||||
"sirius-video",
|
||||
"signal-video",
|
||||
"shimmer-video",
|
||||
"salesroom-video",
|
||||
"roam-video",
|
||||
"riverside-video",
|
||||
"ping-video",
|
||||
"office365-video",
|
||||
"mirotalk-video",
|
||||
"jitsi",
|
||||
"jelly-video",
|
||||
"jelly-conferencing",
|
||||
"huddle",
|
||||
"facetime-video",
|
||||
"element-call-video",
|
||||
"eightxeight-video",
|
||||
"discord-video",
|
||||
"demodesk-video",
|
||||
"campfire-video"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["type", "integration"]
|
||||
},
|
||||
"UpdateBookingLocationInput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32914,6 +32960,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateBookingInputPhoneLocation_2024_08_13"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UpdateBookingInputIntegrationLocation_2024_08_13"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ export { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/B
|
||||
export { BookingEmailAndSmsTaskService } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTaskService";
|
||||
export { BookingEmailAndSmsTriggerDevTasker } from "@calcom/features/bookings/lib/tasker/BookingEmailAndSmsTriggerTasker";
|
||||
export { BookingAttendeesService } from "@calcom/features/bookings/services/BookingAttendeesService";
|
||||
export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
export { PrismaOrgMembershipRepository } from "@calcom/features/membership/repositories/PrismaOrgMembershipRepository";
|
||||
export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
|
||||
export type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
|
||||
export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
export {
|
||||
type BookingWithUserAndEventDetails,
|
||||
bookingWithUserAndEventDetailsSelect,
|
||||
} from "@calcom/prisma/selects/booking";
|
||||
export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
|
||||
|
||||
@@ -3,6 +3,11 @@ export {
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getCalVideoMeetingSessionsByRoomName,
|
||||
createMeeting,
|
||||
updateMeeting,
|
||||
deleteMeeting,
|
||||
} from "@calcom/features/conferencing/lib/videoClient";
|
||||
|
||||
export { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
|
||||
|
||||
export type { CalMeetingParticipant, CalMeetingSession } from "@calcom/app-store/dailyvideo/zod";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import getAllUserBookings from "@calcom/features/bookings/lib/getAllUserBookings";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall";
|
||||
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
|
||||
import { getRoutedUrl } from "@calcom/features/routing-forms/lib/getRoutedUrl";
|
||||
import { getTranslation } from "@calcom/i18n/server";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
@@ -23,13 +23,13 @@ export { slugify } from "@calcom/lib/slugify";
|
||||
export { slugifyLenient } from "@calcom/lib/slugify-lenient";
|
||||
export { getBookingForReschedule };
|
||||
|
||||
export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
export { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
export {
|
||||
DEFAULT_WEBHOOK_VERSION,
|
||||
WebhookVersion,
|
||||
} from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
|
||||
export type { IWebhookProducerService } from "@calcom/features/webhooks/lib/interface/WebhookProducerService";
|
||||
export { getWebhookProducer } from "@calcom/features/di/webhooks/containers/webhook";
|
||||
export {
|
||||
AttributeType,
|
||||
CreationSource,
|
||||
@@ -42,7 +42,7 @@ export {
|
||||
WorkflowTemplates,
|
||||
WorkflowTriggerEvents,
|
||||
} from "@calcom/prisma/enums";
|
||||
export type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
export type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar";
|
||||
|
||||
export { handleMarkNoShow };
|
||||
export { handleCreatePhoneCall };
|
||||
@@ -52,7 +52,10 @@ export type {
|
||||
BookingResponse,
|
||||
} from "@calcom/features/bookings/types";
|
||||
export type { ConnectedCalendar } from "@calcom/features/calendars/lib/CalendarManager";
|
||||
export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
|
||||
export {
|
||||
getBusyCalendarTimes,
|
||||
updateEvent,
|
||||
} from "@calcom/features/calendars/lib/CalendarManager";
|
||||
export type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||
export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
|
||||
export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler";
|
||||
@@ -68,7 +71,11 @@ export { handleCancelBooking };
|
||||
export { dynamicEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
|
||||
export { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||
export { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
export { bookingMetadataSchema, teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
export {
|
||||
bookingMetadataSchema,
|
||||
teamMetadataSchema,
|
||||
userMetadata,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
export { symmetricEncrypt, symmetricDecrypt };
|
||||
|
||||
@@ -117,9 +124,12 @@ export type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/
|
||||
|
||||
export { verifyPhoneNumber, sendVerificationCode };
|
||||
|
||||
export { sendLocationChangeEmailsAndSMS } from "@calcom/emails/email-manager";
|
||||
export { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeUnAuthenticated";
|
||||
export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
|
||||
export { BookingReferenceRepository } from "@calcom/features/bookingReference/repositories/BookingReferenceRepository";
|
||||
export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
|
||||
export { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
export type { OrgMembershipLookup } from "@calcom/features/di/modules/OrgMembershipLookup";
|
||||
export { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService";
|
||||
export { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
@@ -129,7 +139,16 @@ export { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
|
||||
export { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
|
||||
export type { Tasker } from "@calcom/features/tasker/tasker";
|
||||
export { getTasker } from "@calcom/features/tasker/tasker-factory";
|
||||
export { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking";
|
||||
export { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
export { verifyCodeChallenge } from "@calcom/lib/pkce";
|
||||
export { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection";
|
||||
export type { TraceContext } from "@calcom/lib/tracing";
|
||||
export { distributedTracing } from "@calcom/lib/tracing/factory";
|
||||
export {
|
||||
type BookingWithUserAndEventDetails,
|
||||
bookingWithUserAndEventDetailsSelect,
|
||||
} from "@calcom/prisma/selects/booking";
|
||||
export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publicViewer/checkIfUserEmailVerificationRequired.handler";
|
||||
export { verifyCode as verifyCodeAuthenticated } from "@calcom/trpc/server/routers/viewer/organizations/verifyCode.handler";
|
||||
export type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BookingInputAttendeePhoneLocation_2024_08_13,
|
||||
BookingInputLinkLocation_2024_08_13,
|
||||
BookingInputPhoneLocation_2024_08_13,
|
||||
BookingInputIntegrationLocation_2024_08_13,
|
||||
} from "./location.input";
|
||||
|
||||
export class UpdateInputAddressLocation_2024_08_13 extends BookingInputAddressLocation_2024_08_13 {
|
||||
@@ -43,13 +44,16 @@ export class UpdateBookingInputPhoneLocation_2024_08_13 extends BookingInputPhon
|
||||
phone!: string;
|
||||
}
|
||||
|
||||
export class UpdateBookingInputIntegrationLocation_2024_08_13 extends BookingInputIntegrationLocation_2024_08_13 {}
|
||||
|
||||
export type UpdateBookingInputLocation_2024_08_13 =
|
||||
| UpdateInputAddressLocation_2024_08_13
|
||||
| UpdateBookingInputAttendeeAddressLocation_2024_08_13
|
||||
| UpdateBookingInputAttendeeDefinedLocation_2024_08_13
|
||||
| UpdateBookingInputAttendeePhoneLocation_2024_08_13
|
||||
| UpdateBookingInputLinkLocation_2024_08_13
|
||||
| UpdateBookingInputPhoneLocation_2024_08_13;
|
||||
| UpdateBookingInputPhoneLocation_2024_08_13
|
||||
| UpdateBookingInputIntegrationLocation_2024_08_13;
|
||||
|
||||
@ValidatorConstraint({ async: true })
|
||||
class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstraintInterface {
|
||||
@@ -62,6 +66,7 @@ class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstra
|
||||
attendeePhone: UpdateBookingInputAttendeePhoneLocation_2024_08_13,
|
||||
link: UpdateBookingInputLinkLocation_2024_08_13,
|
||||
phone: UpdateBookingInputPhoneLocation_2024_08_13,
|
||||
integration: UpdateBookingInputIntegrationLocation_2024_08_13,
|
||||
};
|
||||
|
||||
async validate(location: { type: string }) {
|
||||
@@ -73,7 +78,7 @@ class UpdateBookingInputLocationValidator_2024_08_13 implements ValidatorConstra
|
||||
|
||||
const ClassType = this.classTypeMap[type];
|
||||
if (!ClassType) {
|
||||
this.validationMessage = `UpdateBookingInputLocationValidator_2024_08_13 - Unsupported booking location type '${type}'. Valid types are address, link, phone, attendeePhone, attendeeAddress, and attendeeDefined.`;
|
||||
this.validationMessage = `UpdateBookingInputLocationValidator_2024_08_13 - Unsupported booking location type '${type}'. Valid types are address, link, phone, attendeePhone, attendeeAddress, attendeeDefined, and integration.`;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -115,7 +120,8 @@ export function ValidateUpdateBookingLocation_2024_08_13(validationOptions?: Val
|
||||
UpdateBookingInputAttendeeDefinedLocation_2024_08_13,
|
||||
UpdateBookingInputAttendeePhoneLocation_2024_08_13,
|
||||
UpdateBookingInputLinkLocation_2024_08_13,
|
||||
UpdateBookingInputPhoneLocation_2024_08_13
|
||||
UpdateBookingInputPhoneLocation_2024_08_13,
|
||||
UpdateBookingInputIntegrationLocation_2024_08_13
|
||||
)
|
||||
export class UpdateBookingLocationInput_2024_08_13 {
|
||||
@IsOptional()
|
||||
@@ -130,6 +136,7 @@ export class UpdateBookingLocationInput_2024_08_13 {
|
||||
{ $ref: getSchemaPath(UpdateBookingInputAttendeePhoneLocation_2024_08_13) },
|
||||
{ $ref: getSchemaPath(UpdateBookingInputLinkLocation_2024_08_13) },
|
||||
{ $ref: getSchemaPath(UpdateBookingInputPhoneLocation_2024_08_13) },
|
||||
{ $ref: getSchemaPath(UpdateBookingInputIntegrationLocation_2024_08_13) },
|
||||
],
|
||||
})
|
||||
location?: UpdateBookingInputLocation_2024_08_13;
|
||||
|
||||
@@ -27,3 +27,106 @@ export const bookingDetailsSelect = {
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.BookingSelect;
|
||||
|
||||
export const bookingWithUserAndEventDetailsSelect = {
|
||||
title: true,
|
||||
description: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
userPrimaryEmail: true,
|
||||
uid: true,
|
||||
iCalUID: true,
|
||||
iCalSequence: true,
|
||||
eventTypeId: true,
|
||||
id: true,
|
||||
userId: true,
|
||||
location: true,
|
||||
responses: true,
|
||||
metadata: true,
|
||||
destinationCalendar: {
|
||||
select: {
|
||||
id: true,
|
||||
integration: true,
|
||||
externalId: true,
|
||||
primaryEmail: true,
|
||||
userId: true,
|
||||
eventTypeId: true,
|
||||
credentialId: true,
|
||||
delegationCredentialId: true,
|
||||
domainWideDelegationCredentialId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
customCalendarReminder: true,
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
references: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
uid: true,
|
||||
deleted: true,
|
||||
credentialId: true,
|
||||
delegationCredentialId: true,
|
||||
externalCalendarId: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
timeZone: true,
|
||||
locale: true,
|
||||
credentials: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
delegationCredentialId: true,
|
||||
},
|
||||
},
|
||||
destinationCalendar: {
|
||||
select: {
|
||||
id: true,
|
||||
integration: true,
|
||||
externalId: true,
|
||||
primaryEmail: true,
|
||||
userId: true,
|
||||
eventTypeId: true,
|
||||
credentialId: true,
|
||||
delegationCredentialId: true,
|
||||
domainWideDelegationCredentialId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
customCalendarReminder: true,
|
||||
},
|
||||
},
|
||||
profiles: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
eventType: {
|
||||
select: {
|
||||
title: true,
|
||||
metadata: true,
|
||||
recurringEvent: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
hideOrganizerEmail: true,
|
||||
customReplyToEmail: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.BookingSelect;
|
||||
|
||||
export type BookingWithUserAndEventDetails = Prisma.BookingGetPayload<{
|
||||
select: typeof bookingWithUserAndEventDetailsSelect;
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user