Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0b35d9e2 | ||
|
|
3b19ede6a3 | ||
|
|
1a0ce47404 | ||
|
|
22f136d19b | ||
|
|
9a40e54159 | ||
|
|
229f2627c8 | ||
|
|
6f6454df40 | ||
|
|
3da9fc5500 | ||
|
|
540bf868b5 | ||
|
|
1674ecc585 | ||
|
|
4920abdaf7 | ||
|
|
a6b98bd35d | ||
|
|
34d4f394fc | ||
|
|
a4ef8d4200 | ||
|
|
9d9662687e | ||
|
|
2d41c719c4 | ||
|
|
b69bd966a6 | ||
|
|
09a49dd0fd | ||
|
|
3dd3372c35 | ||
|
|
0a60864310 | ||
|
|
cb24e5d0aa | ||
|
|
8c5aebdca1 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
fix saving event type settings
|
||||
Vendored
Vendored
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
@@ -37,7 +37,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.222",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -81,5 +81,9 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
if (process?.env?.API_GLOBAL_PREFIX) {
|
||||
app.setGlobalPrefix(process?.env?.API_GLOBAL_PREFIX);
|
||||
}
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
@@ -267,7 +267,14 @@ export class BookingsController_2024_08_13 {
|
||||
@ApiOperation({
|
||||
summary: "Cancel a booking",
|
||||
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
|
||||
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
|
||||
\nCancelling seated bookings:
|
||||
It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.
|
||||
\n1. As an attendee - provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and seatUid in the request body \`{"seatUid": "123-123-123"}\` . This will remove this particular attendance from the booking.
|
||||
\n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL \`/bookings/:bookingUid/cancel\` and cancellationReason in the request body \`{"cancellationReason": "Will travel"}\` and \`Authorization: Bearer token\` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.
|
||||
|
||||
\nCancelling recurring seated bookings:
|
||||
For recurring seated bookings it is not possible to cancel all of them with 1 call
|
||||
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
|
||||
})
|
||||
@ApiBody({
|
||||
|
||||
+206
-78
@@ -14,29 +14,27 @@ import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
CancelBookingInput_2024_08_13,
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
@@ -51,11 +49,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||
|
||||
const userEmail = `seated-bookings-user-${randomString()}@api.com`;
|
||||
let user: User;
|
||||
let apiKeyString: string;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
@@ -63,6 +63,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
|
||||
let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13;
|
||||
|
||||
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
|
||||
const nameAttendeeOne = `Attendee One ${randomString()}`;
|
||||
@@ -70,12 +71,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
)
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
@@ -87,22 +85,21 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
bookingSeatRepositoryFixture = new BookingSeatRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `seated-bookings-organization-${randomString()}`,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
platformOAuthClients: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
const userSchedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
@@ -118,6 +115,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
seatsShowAttendees: true,
|
||||
seatsShowAvailabilityCount: true,
|
||||
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
|
||||
metadata: {
|
||||
disableStandardEmails: {
|
||||
all: {
|
||||
attendee: true,
|
||||
host: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
user.id
|
||||
);
|
||||
@@ -129,19 +134,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it("should book an event type with seats for the first time", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
@@ -333,6 +325,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get("/v2/bookings?sortCreated=asc")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
@@ -350,6 +343,22 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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(),
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Booking with uid=${createdSeatedBooking.uid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
});
|
||||
|
||||
it("should reschedule seated booking", async () => {
|
||||
const body: RescheduleSeatedBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
@@ -409,67 +418,186 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
describe("cancel seated booking", () => {
|
||||
describe("cancel seated booking as attendee", () => {
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelSeatedBookingInput_2024_08_13 = {
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_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).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
if (responseDataIsGetSeatedBooking(responseBody.data)) {
|
||||
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel seated booking as host", () => {
|
||||
it("should book an event type with seats", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: seatedEventTypeId,
|
||||
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();
|
||||
const seatUid = data.seatUid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(
|
||||
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
|
||||
);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
createdSeatedBooking2 = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to cancel without cancellation reason", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toEqual("Cancellation reason is required when you are the host");
|
||||
});
|
||||
|
||||
it("should cancel seated booking", async () => {
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "I will be travelling without internet",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdSeatedBooking2.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: RescheduleBookingOutput_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).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.start).toEqual(createdSeatedBooking2.start);
|
||||
expect(data.end).toEqual(createdSeatedBooking2.end);
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(seatedEventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: seatedEventTypeId,
|
||||
slug: seatedEventSlug,
|
||||
});
|
||||
expect(data.attendees.length).toEqual(0);
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.cancellationReason).toEqual("I will be travelling without internet");
|
||||
|
||||
const seats = await bookingSeatRepositoryFixture.findAllByBookingId(data.id);
|
||||
expect(seats.length).toEqual(0);
|
||||
} else {
|
||||
throw new Error("Invalid response data - expected booking but received array response");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
|
||||
return data.hasOwnProperty("seatUid");
|
||||
}
|
||||
|
||||
function responseDataIsCreateRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
|
||||
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
|
||||
}
|
||||
|
||||
function responseDataIsGetRecurringSeatedBooking(
|
||||
data: any
|
||||
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
|
||||
return Array.isArray(data);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getRecordingsOfCalVideoByRoomName,
|
||||
getAllTranscriptsAccessLinkFromRoomName,
|
||||
getDownloadLinkOfCalVideoByRecordingId,
|
||||
} from "@calcom/platform-libraries";
|
||||
} from "@calcom/platform-libraries/conferencing";
|
||||
|
||||
@Injectable()
|
||||
export class CalVideoService {
|
||||
|
||||
@@ -586,6 +586,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
|
||||
}
|
||||
if (eventType.seatsPerTimeSlot) {
|
||||
throw new BadRequestException(
|
||||
`Booking with uid=${bookingUid} is a seated booking which means you have to provide seatUid in the request body to specify which seat specifically you want to reschedule. First, fetch the booking using https://cal.com/docs/api-reference/v2/bookings/get-a-booking and then within the attendees array you will find the seatUid of the booking you want to reschedule. Second, provide the seatUid in the request body to specify which seat specifically you want to reschedule using the reschedule endpoint https://cal.com/docs/api-reference/v2/bookings/reschedule-a-booking#option-2`
|
||||
);
|
||||
}
|
||||
|
||||
const bookingResponses = safeParse(
|
||||
bookingResponsesSchema,
|
||||
|
||||
@@ -266,6 +266,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
|
||||
+93
@@ -34,6 +34,7 @@ import {
|
||||
ApiSuccessResponse,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GuestsDefaultFieldOutput_2024_06_14,
|
||||
NameDefaultFieldInput_2024_06_14,
|
||||
NotesDefaultFieldInput_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
@@ -535,6 +536,7 @@ describe("Event types Endpoints", () => {
|
||||
body.lockTimeZoneToggleOnBookingPage
|
||||
);
|
||||
expect(createdEventType.color).toEqual(body.color);
|
||||
expect(createdEventType.disableGuests).toEqual(false);
|
||||
|
||||
const expectedBookingFields = [
|
||||
{ ...defaultResponseBookingFieldName, ...nameBookingField },
|
||||
@@ -1041,6 +1043,7 @@ describe("Event types Endpoints", () => {
|
||||
expect(updatedEventType.description).toEqual(eventType.description);
|
||||
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
|
||||
expect(updatedEventType.locations).toEqual(eventType.locations);
|
||||
expect(updatedEventType.disableGuests).toEqual(false);
|
||||
|
||||
const expectedBookingFields = [
|
||||
{ ...defaultResponseBookingFieldName, ...nameBookingField },
|
||||
@@ -1887,6 +1890,96 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle disable guests", () => {
|
||||
let eventTypeWithGuestsDisabledId: number;
|
||||
|
||||
it("should create an event type with guests disabled", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class guests disabled",
|
||||
slug: "coding-class-guests-disabled",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
disableGuests: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(body.title);
|
||||
expect(createdEventType.description).toEqual(body.description);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
|
||||
|
||||
expect(createdEventType.disableGuests).toEqual(true);
|
||||
const guestsBookingField = createdEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(true);
|
||||
|
||||
eventTypeWithGuestsDisabledId = createdEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with guests enabled", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
disableGuests: false,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
|
||||
expect(updatedEventType.disableGuests).toEqual(false);
|
||||
const guestsBookingField = updatedEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(false);
|
||||
|
||||
eventTypeWithGuestsDisabledId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
it("should update an event type with guests disabled", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
disableGuests: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventTypeWithGuestsDisabledId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
|
||||
expect(updatedEventType.disableGuests).toEqual(true);
|
||||
const guestsBookingField = updatedEventType.bookingFields.find(
|
||||
(field) => field.slug === "guests"
|
||||
) as GuestsDefaultFieldOutput_2024_06_14 | undefined;
|
||||
expect(guestsBookingField?.hidden).toEqual(true);
|
||||
|
||||
eventTypeWithGuestsDisabledId = updatedEventType.id;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (eventTypeWithGuestsDisabledId) {
|
||||
await eventTypesRepositoryFixture.delete(eventTypeWithGuestsDisabledId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+39
-2
@@ -32,6 +32,7 @@ import { validateCustomEventName, EventTypeMetaDataSchema } from "@calcom/platfo
|
||||
import {
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
DestinationCalendar_2024_06_14,
|
||||
InputBookingField_2024_06_14,
|
||||
OutputUnknownLocation_2024_06_14,
|
||||
UpdateEventTypeInput_2024_06_14,
|
||||
} from "@calcom/platform-types";
|
||||
@@ -117,16 +118,23 @@ export class InputEventTypesService_2024_06_14 {
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const locationsTransformed = locations?.length ? this.transformInputLocations(locations) : undefined;
|
||||
|
||||
const effectiveBookingFields =
|
||||
disableGuests !== undefined
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locationsTransformed,
|
||||
bookingFields: this.transformInputBookingFields(bookingFields),
|
||||
bookingFields: this.transformInputBookingFields(effectiveBookingFields),
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -167,6 +175,7 @@ export class InputEventTypesService_2024_06_14 {
|
||||
seats,
|
||||
customName,
|
||||
useDestinationCalendarEmail,
|
||||
disableGuests,
|
||||
...rest
|
||||
} = inputEventType;
|
||||
const eventTypeDb = await this.eventTypesRepository.getEventTypeWithMetaData(eventTypeId);
|
||||
@@ -176,11 +185,18 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
const confirmationPolicyTransformed = this.transformInputConfirmationPolicy(confirmationPolicy);
|
||||
|
||||
const effectiveBookingFields =
|
||||
disableGuests !== undefined
|
||||
? this.getBookingFieldsWithGuestsToggled(bookingFields, disableGuests)
|
||||
: bookingFields;
|
||||
|
||||
const eventType = {
|
||||
...rest,
|
||||
length: lengthInMinutes,
|
||||
locations: locations ? this.transformInputLocations(locations) : undefined,
|
||||
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
|
||||
bookingFields: effectiveBookingFields
|
||||
? this.transformInputBookingFields(effectiveBookingFields)
|
||||
: undefined,
|
||||
bookingLimits: bookingLimitsCount ? this.transformInputIntervalLimits(bookingLimitsCount) : undefined,
|
||||
durationLimits: bookingLimitsDuration
|
||||
? this.transformInputIntervalLimits(bookingLimitsDuration)
|
||||
@@ -206,6 +222,27 @@ export class InputEventTypesService_2024_06_14 {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
getBookingFieldsWithGuestsToggled(
|
||||
bookingFields: InputBookingField_2024_06_14[] | undefined,
|
||||
hideGuests: boolean
|
||||
) {
|
||||
const toggledGuestsBookingField: InputBookingField_2024_06_14 = { slug: "guests", hidden: hideGuests };
|
||||
if (!bookingFields) {
|
||||
return [toggledGuestsBookingField];
|
||||
}
|
||||
|
||||
const bookingFieldsCopy = [...bookingFields];
|
||||
|
||||
const guestsBookingField = bookingFieldsCopy.find((field) => "slug" in field && field.slug === "guests");
|
||||
if (guestsBookingField) {
|
||||
Object.assign(guestsBookingField, { hidden: hideGuests });
|
||||
return bookingFieldsCopy;
|
||||
}
|
||||
|
||||
bookingFieldsCopy.push(toggledGuestsBookingField);
|
||||
return bookingFieldsCopy;
|
||||
}
|
||||
|
||||
transformInputLocations(inputLocations: CreateEventTypeInput_2024_06_14["locations"]) {
|
||||
return transformLocationsApiToInternal(inputLocations);
|
||||
}
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ export class CalUnifiedCalendarsController {
|
||||
})
|
||||
@ApiParam({
|
||||
name: "eventUid",
|
||||
description: "The Google Calendar event ID",
|
||||
description:
|
||||
"The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
type: String,
|
||||
})
|
||||
@Get("/:calendar/event/:eventUid")
|
||||
|
||||
@@ -7653,7 +7653,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -8142,7 +8142,7 @@
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID",
|
||||
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -10695,6 +10695,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUidToReschedule",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
|
||||
"example": "abc123def456",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "routingFormId",
|
||||
"required": true,
|
||||
@@ -25447,8 +25457,7 @@
|
||||
"startTs",
|
||||
"status",
|
||||
"duration",
|
||||
"shareToken",
|
||||
"downloadLink"
|
||||
"shareToken"
|
||||
]
|
||||
},
|
||||
"GetBookingRecordingsOutput": {
|
||||
|
||||
@@ -16,4 +16,8 @@ export class BookingSeatRepositoryFixture {
|
||||
async create(bookingSeat: Prisma.BookingSeatCreateInput) {
|
||||
return this.prismaWriteClient.bookingSeat.create({ data: bookingSeat });
|
||||
}
|
||||
|
||||
async findAllByBookingId(bookingId: number) {
|
||||
return this.prismaReadClient.bookingSeat.findMany({ where: { bookingId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"@calcom/platform-libraries/emails": ["../../../packages/platform/libraries/emails.ts"],
|
||||
"@calcom/platform-libraries/schedules": ["../../../packages/platform/libraries/schedules.ts"],
|
||||
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
|
||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"]
|
||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
+14
-5
@@ -3,6 +3,7 @@ import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { AttributeRepository } from "@calcom/lib/server/repository/attribute";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
@@ -26,12 +27,20 @@ const getCachedAttributes = unstable_cache(
|
||||
|
||||
const Page = async () => {
|
||||
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
|
||||
const [org, teams, facetedTeamValues] = await Promise.all([
|
||||
orgCaller.listCurrent(),
|
||||
orgCaller.getTeams(),
|
||||
orgCaller.getFacetedValues(),
|
||||
]);
|
||||
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
|
||||
const attributes = await getCachedAttributes(org.id);
|
||||
const facetedTeamValues = {
|
||||
roles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
|
||||
teams,
|
||||
attributes: attributes.map((attribute) => ({
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
options: Array.from(new Set(attribute.options.map((option) => option.value))).map((value) => ({
|
||||
value,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
|
||||
import { queuedResponseHandler } from "../route";
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/formResponse");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/getResponseToStore");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/formSubmissionUtils");
|
||||
|
||||
const mockQueuedFormResponse = {
|
||||
id: "1",
|
||||
formId: "mock-form-id",
|
||||
form: {
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
teamId: null,
|
||||
position: 1,
|
||||
updatedById: null,
|
||||
settings: {},
|
||||
disabled: false,
|
||||
},
|
||||
chosenRouteId: "mock-chosen-route-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
response: {},
|
||||
actualResponseId: 1,
|
||||
};
|
||||
|
||||
describe("queuedResponseHandler", () => {
|
||||
it("should process a queued form response", async () => {
|
||||
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
|
||||
mockQueuedFormResponse
|
||||
);
|
||||
|
||||
vi.mocked(getSerializableForm).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
position: 1,
|
||||
updatedById: null,
|
||||
disabled: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
|
||||
|
||||
vi.mocked(getResponseToStore).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getResponseToStore>>);
|
||||
|
||||
vi.mocked(onSubmissionOfFormResponse).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
} as unknown as Awaited<ReturnType<typeof onSubmissionOfFormResponse>>);
|
||||
|
||||
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: [],
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
chosenRouteId: "mock-chosen-route-id",
|
||||
} as unknown as Awaited<ReturnType<typeof RoutingFormResponseRepository.recordFormResponse>>);
|
||||
|
||||
const response = await queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
});
|
||||
expect(response).toEqual({
|
||||
formResponseId: "mock-form-id",
|
||||
message: "Processed",
|
||||
});
|
||||
});
|
||||
|
||||
it("if no queued form response is found, should return early", async () => {
|
||||
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(null);
|
||||
const response = await queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
});
|
||||
expect(response).toEqual({
|
||||
formResponseId: null,
|
||||
message: "Already processed",
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw if form has no fields", async () => {
|
||||
vi.mocked(RoutingFormResponseRepository.getQueuedFormResponseFromId).mockResolvedValue(
|
||||
mockQueuedFormResponse
|
||||
);
|
||||
vi.mocked(getSerializableForm).mockResolvedValue({
|
||||
id: "mock-form-id",
|
||||
name: "Test Form",
|
||||
description: "Test Form Description",
|
||||
fields: undefined,
|
||||
routes: [],
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: null,
|
||||
} as unknown as Awaited<ReturnType<typeof getSerializableForm>>);
|
||||
await expect(
|
||||
queuedResponseHandler({
|
||||
queuedFormResponseId: "1",
|
||||
params: {},
|
||||
})
|
||||
).rejects.toThrow("Form has no fields");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
|
||||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir";
|
||||
|
||||
const queuedResponseSchema = z.object({
|
||||
queuedFormResponseId: z.string(),
|
||||
params: z.record(z.string(), z.string().or(z.array(z.string()))),
|
||||
});
|
||||
|
||||
export const queuedResponseHandler = async ({
|
||||
queuedFormResponseId,
|
||||
params,
|
||||
}: {
|
||||
queuedFormResponseId: string;
|
||||
params: Record<string, string | string[]>;
|
||||
}) => {
|
||||
// Get the queued response
|
||||
const queuedFormResponse = await RoutingFormResponseRepository.getQueuedFormResponseFromId(
|
||||
queuedFormResponseId
|
||||
);
|
||||
|
||||
if (!queuedFormResponse) {
|
||||
return {
|
||||
formResponseId: null,
|
||||
message: "Already processed",
|
||||
};
|
||||
}
|
||||
|
||||
const serializableForm = await getSerializableForm({
|
||||
form: queuedFormResponse.form,
|
||||
});
|
||||
|
||||
if (!serializableForm.fields) {
|
||||
throw new Error("Form has no fields");
|
||||
}
|
||||
|
||||
const response = getResponseToStore({
|
||||
formFields: serializableForm.fields,
|
||||
fieldsResponses: params,
|
||||
});
|
||||
|
||||
const formResponse = await RoutingFormResponseRepository.recordFormResponse({
|
||||
formId: queuedFormResponse.formId,
|
||||
queuedFormResponseId: queuedFormResponse.id,
|
||||
// We record new response here as that might be different from the queued response depending on if the user changed something in b/w before clicking CTA and that something wasn't prerendered
|
||||
response,
|
||||
// We use the queuedFormResponse's chosenRouteId because that is what decided routed team members
|
||||
chosenRouteId: queuedFormResponse.chosenRouteId,
|
||||
});
|
||||
|
||||
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
|
||||
await onSubmissionOfFormResponse({
|
||||
form: {
|
||||
...queuedFormResponse.form,
|
||||
...serializableForm,
|
||||
},
|
||||
formResponseInDb: formResponse,
|
||||
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
|
||||
});
|
||||
|
||||
return {
|
||||
formResponseId: formResponse.id,
|
||||
message: "Processed",
|
||||
};
|
||||
};
|
||||
|
||||
export const handler = async (req: NextRequest) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { params, queuedFormResponseId } = queuedResponseSchema.parse(body);
|
||||
const result = await queuedResponseHandler({
|
||||
queuedFormResponseId,
|
||||
params,
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "success", data: result });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
logger.error("Invalid input", safeStringify(error));
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: "Invalid input",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error("Error in queuedResponseHandler", safeStringify(error));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = defaultResponderForAppDir(handler);
|
||||
@@ -134,7 +134,7 @@ const getEventTypes = async (userId: number, teamIds?: number[]) => {
|
||||
.sort((eventTypeA, eventTypeB) => eventTypeB.position - eventTypeA.position),
|
||||
}));
|
||||
} else {
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
|
||||
@@ -157,7 +157,7 @@ export const handleSeatsEventTypeOnBooking = async (
|
||||
} | null;
|
||||
let seatAttendee: seatAttendee = null;
|
||||
if (seatReferenceUid) {
|
||||
seatAttendee = await prisma.bookingSeat.findFirst({
|
||||
seatAttendee = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: seatReferenceUid,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.3.13",
|
||||
"version": "5.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -768,6 +768,13 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
* @deprecated use apiLogin instead
|
||||
*/
|
||||
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
||||
loginOnNewBrowser: async (browser: Browser) => {
|
||||
const newContext = await browser.newContext();
|
||||
const newPage = await newContext.newPage();
|
||||
await login({ ...(await self()), password: user.username }, newPage);
|
||||
// Don't forget to: newContext.close();
|
||||
return [newContext, newPage] as const;
|
||||
},
|
||||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
@@ -1074,9 +1081,11 @@ export async function apiLogin(
|
||||
json: "true",
|
||||
csrfToken,
|
||||
};
|
||||
return page.context().request.post("/api/auth/callback/credentials", {
|
||||
const response = await page.context().request.post("/api/auth/callback/credentials", {
|
||||
data,
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function setupEventWithPrice(eventType: Pick<Prisma.EventType, "id">, slug: string, page: Page) {
|
||||
|
||||
@@ -65,10 +65,12 @@ export async function moveUserToOrg({
|
||||
);
|
||||
}
|
||||
|
||||
const userWithSameUsernameInOrg = await prisma.user.findFirst({
|
||||
const userWithSameUsernameInOrg = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: targetOrgUsername,
|
||||
organizationId: targetOrgId,
|
||||
username_organizationId: {
|
||||
username: targetOrgUsername,
|
||||
organizationId: targetOrgId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe("2FA Tests", async () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
test("should allow a user to enable 2FA and login using 2FA", async ({ page, users }) => {
|
||||
test("should allow a user to enable 2FA and login using 2FA", async ({ page, users, browser }) => {
|
||||
// log in trail user
|
||||
const user = await test.step("Enable 2FA", async () => {
|
||||
const user = await users.create();
|
||||
@@ -60,7 +60,7 @@ test.describe("2FA Tests", async () => {
|
||||
});
|
||||
|
||||
await test.step("Login with 2FA enabled", async () => {
|
||||
await user.login();
|
||||
const [secondContext, secondPage] = await user.loginOnNewBrowser(browser);
|
||||
const userWith2FaSecret = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
@@ -74,13 +74,14 @@ test.describe("2FA Tests", async () => {
|
||||
process.env.CALENDSO_ENCRYPTION_KEY!
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
await fillOtp({ page: secondPage, secret: secret! });
|
||||
await Promise.all([
|
||||
page.press('input[name="2fa6"]', "Enter"),
|
||||
page.waitForResponse("**/api/auth/callback/credentials**"),
|
||||
secondPage.press('input[name="2fa6"]', "Enter"),
|
||||
secondPage.waitForResponse("**/api/auth/callback/credentials**"),
|
||||
]);
|
||||
const shellLocator = page.locator(`[data-testid=dashboard-shell]`);
|
||||
const shellLocator = secondPage.locator(`[data-testid=dashboard-shell]`);
|
||||
await expect(shellLocator).toBeVisible();
|
||||
await secondContext.close();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -368,10 +368,11 @@ test.describe("Bookings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Round robin event type with confirmation required handles cancellation and rebooking correctly with the same details and slot", async ({
|
||||
test("Round robin event type with confirmation required handles rejection and rebooking correctly with the same details and slot", async ({
|
||||
page,
|
||||
users,
|
||||
orgs,
|
||||
browser,
|
||||
}) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
@@ -421,38 +422,41 @@ test.describe("Bookings", () => {
|
||||
const bookingUid = page.url().split("/booking/")[1];
|
||||
expect(bookingUid).not.toBeNull();
|
||||
|
||||
// Login as the host to cancel the booking
|
||||
await page.goto("/auth/logout");
|
||||
const allUsers = await users.get();
|
||||
|
||||
const allUsers = users.get();
|
||||
const hostUser = allUsers.find((mate) => mate.name === firstHost);
|
||||
if (!hostUser) throw new Error("Host not found");
|
||||
|
||||
await hostUser.apiLogin();
|
||||
const [secondContext, secondPage] = await hostUser.apiLoginOnNewBrowser(browser);
|
||||
|
||||
// Cancel the booking
|
||||
await page.goto(`/booking/${bookingUid}`);
|
||||
await page.getByTestId("cancel").click();
|
||||
await page.getByTestId("confirm_cancel").click();
|
||||
await page.waitForResponse((response) => response.url().includes("/api/cancel"));
|
||||
// Reject the booking
|
||||
await secondPage.goto("/bookings/upcoming");
|
||||
await secondPage.click('[data-testid="reject"]');
|
||||
await submitAndWaitForResponse(secondPage, "/api/trpc/bookings/confirm?batch=1", {
|
||||
action: () => secondPage.click('[data-testid="rejection-confirm"]'),
|
||||
});
|
||||
|
||||
// Logout and go back to booking page
|
||||
await page.goto("/auth/logout");
|
||||
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
await secondPage.goto("/auth/logout");
|
||||
await secondPage.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
|
||||
|
||||
// Rebook with the same details
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByTestId("success-page")).toBeVisible();
|
||||
await selectFirstAvailableTimeSlotNextMonth(secondPage);
|
||||
await bookTimeSlot(secondPage);
|
||||
await expect(secondPage.getByTestId("success-page")).toBeVisible();
|
||||
|
||||
// Verify a new host is assigned
|
||||
const newHost = await page.getByTestId("booking-host-name").textContent();
|
||||
const newHost = await secondPage.getByTestId("booking-host-name").textContent();
|
||||
expect(newHost).not.toBeNull();
|
||||
expect(newHost).toBe(firstHost);
|
||||
|
||||
// Verify the booking was successful by checking the new booking UID
|
||||
const newBookingUid = page.url().split("/booking/")[1];
|
||||
const newBookingUid = secondPage.url().split("/booking/")[1];
|
||||
expect(newBookingUid).not.toBeNull();
|
||||
expect(newBookingUid).not.toBe(bookingUid);
|
||||
await secondContext.close();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -602,7 +602,7 @@ async function addWebhooks(webhooks: InputWebhook[]) {
|
||||
async function addWorkflowsToDb(workflows: InputWorkflow[]) {
|
||||
await Promise.all(
|
||||
workflows.map(async (workflow) => {
|
||||
const team = await prismock.team.findFirst({
|
||||
const team = await prismock.team.findUnique({
|
||||
where: {
|
||||
id: workflow.teamId ?? 0,
|
||||
},
|
||||
|
||||
@@ -7321,7 +7321,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n \n \nCancelling seated bookings:\n It is possible to cancel specific seat within a booking as an attendee or all of the seats as the host.\n \n1. As an attendee - provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and seatUid in the request body `{\"seatUid\": \"123-123-123\"}` . This will remove this particular attendance from the booking.\n \n2. As the host - host can cancel booking for all attendees aka for every seat. Provide :bookingUid in the request URL `/bookings/:bookingUid/cancel` and cancellationReason in the request body `{\"cancellationReason\": \"Will travel\"}` and `Authorization: Bearer token` request header where token is event type owner (host) credential. This will cancel the booking for all attendees.\n \n \nCancelling recurring seated bookings:\n For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -7792,7 +7792,7 @@
|
||||
"name": "eventUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "The Google Calendar event ID",
|
||||
"description": "The Google Calendar event ID. You can retrieve this by getting booking references from the following endpoints:\n\n- For team events: https://cal.com/docs/api-reference/v2/orgs-teams-bookings/get-booking-references-for-a-booking\n\n- For user events: https://cal.com/docs/api-reference/v2/bookings/get-booking-references-for-a-booking",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -10205,6 +10205,16 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUidToReschedule",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.",
|
||||
"example": "abc123def456",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "routingFormId",
|
||||
"required": true,
|
||||
@@ -23342,7 +23352,7 @@
|
||||
"example": "Error message"
|
||||
}
|
||||
},
|
||||
"required": ["id", "roomName", "startTs", "status", "duration", "shareToken", "downloadLink"]
|
||||
"required": ["id", "roomName", "startTs", "status", "duration", "shareToken"]
|
||||
},
|
||||
"GetBookingRecordingsOutput": {
|
||||
"type": "object",
|
||||
|
||||
Binary file not shown.
@@ -31,7 +31,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
|
||||
@@ -41,7 +41,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
bookerEmail: string
|
||||
) {
|
||||
try {
|
||||
const booking: PaidBooking | null = await prisma.booking.findFirst({
|
||||
const booking: PaidBooking | null = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
@@ -71,7 +71,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
paymentOption: PaymentOption
|
||||
): Promise<Payment> {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
|
||||
@@ -37,7 +37,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
bookingId: Booking["id"]
|
||||
) {
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
@@ -113,7 +113,7 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
throw new Error("Payment option is not compatible with create method");
|
||||
}
|
||||
try {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
select: {
|
||||
uid: true,
|
||||
title: true,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { onFormSubmission } from "../trpc/utils";
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
|
||||
export type TargetRoutingFormForResponse = SerializableForm<
|
||||
App_RoutingForms_Form & {
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
};
|
||||
team: {
|
||||
parentId: number | null;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* A wrapper over onFormSubmission that handles building the data needed for onFormSubmission
|
||||
*/
|
||||
export const onSubmissionOfFormResponse = async ({
|
||||
form,
|
||||
formResponseInDb,
|
||||
chosenRouteAction,
|
||||
}: {
|
||||
form: TargetRoutingFormForResponse;
|
||||
formResponseInDb: { id: number; response: Prisma.JsonValue };
|
||||
chosenRouteAction: {
|
||||
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
|
||||
value: string;
|
||||
} | null;
|
||||
}) => {
|
||||
if (!form.fields) {
|
||||
// There is no point in submitting a form that doesn't have fields defined
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
const settings = RoutingFormSettings.parse(form.settings);
|
||||
let userWithEmails: string[] = [];
|
||||
|
||||
if (form.teamId && (settings?.sendToAll || settings?.sendUpdatesTo?.length)) {
|
||||
const whereClause: Prisma.MembershipWhereInput = { teamId: form.teamId };
|
||||
if (!settings?.sendToAll) {
|
||||
whereClause.userId = { in: settings.sendUpdatesTo };
|
||||
}
|
||||
const userEmails = await prisma.membership.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
userWithEmails = userEmails.map((userEmail) => userEmail.user.email);
|
||||
}
|
||||
|
||||
await onFormSubmission(
|
||||
{ ...form, fields: form.fields, userWithEmails },
|
||||
formResponseInDb.response as FormResponse,
|
||||
formResponseInDb.id,
|
||||
chosenRouteAction ?? undefined
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
|
||||
import { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse";
|
||||
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
|
||||
import type { zodFields } from "../zod";
|
||||
|
||||
export const getResponseToStore = ({
|
||||
formFields,
|
||||
fieldsResponses,
|
||||
}: {
|
||||
formFields: NonNullable<z.infer<typeof zodFields>>;
|
||||
fieldsResponses: Record<string, string | string[]>;
|
||||
}) => {
|
||||
const response: FormResponse = {};
|
||||
formFields.forEach((field) => {
|
||||
const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || "";
|
||||
|
||||
response[field.id] = {
|
||||
label: field.label,
|
||||
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
|
||||
};
|
||||
});
|
||||
return response;
|
||||
};
|
||||
@@ -6,29 +6,14 @@ import logger from "@calcom/lib/logger";
|
||||
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
|
||||
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import isRouter from "../lib/isRouter";
|
||||
import { onFormSubmission } from "../trpc/utils";
|
||||
import type { FormResponse, SerializableForm } from "../types/types";
|
||||
import routerGetCrmContactOwnerEmail from "./crmRouting/routerGetCrmContactOwnerEmail";
|
||||
|
||||
export type Form = SerializableForm<
|
||||
App_RoutingForms_Form & {
|
||||
user: {
|
||||
id: number;
|
||||
email: string;
|
||||
};
|
||||
team: {
|
||||
parentId: number | null;
|
||||
} | null;
|
||||
}
|
||||
>;
|
||||
import { onSubmissionOfFormResponse, type TargetRoutingFormForResponse } from "./formSubmissionUtils";
|
||||
|
||||
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/handleResponse"] });
|
||||
|
||||
@@ -39,12 +24,14 @@ const _handleResponse = async ({
|
||||
// formFillerId,
|
||||
chosenRouteId,
|
||||
isPreview,
|
||||
queueFormResponse,
|
||||
}: {
|
||||
response: z.infer<typeof ZResponseInputSchema>["response"];
|
||||
form: Form;
|
||||
form: TargetRoutingFormForResponse;
|
||||
formFillerId: string;
|
||||
chosenRouteId: string | null;
|
||||
isPreview: boolean;
|
||||
queueFormResponse?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
if (!form.fields) {
|
||||
@@ -99,26 +86,6 @@ const _handleResponse = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const settings = RoutingFormSettings.parse(form.settings);
|
||||
let userWithEmails: string[] = [];
|
||||
if (form.teamId && (settings?.sendToAll || settings?.sendUpdatesTo?.length)) {
|
||||
const whereClause: Prisma.MembershipWhereInput = { teamId: form.teamId };
|
||||
if (!settings?.sendToAll) {
|
||||
whereClause.userId = { in: settings.sendUpdatesTo };
|
||||
}
|
||||
const userEmails = await prisma.membership.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
userWithEmails = userEmails.map((userEmail) => userEmail.user.email);
|
||||
}
|
||||
|
||||
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
|
||||
let teamMemberIdsMatchingAttributeLogic: number[] | null = null;
|
||||
let crmContactOwnerEmail: string | null = null;
|
||||
@@ -185,41 +152,53 @@ const _handleResponse = async ({
|
||||
} else {
|
||||
// It currently happens for a Router route. Such a route id isn't present in the form.routes
|
||||
}
|
||||
|
||||
let dbFormResponse;
|
||||
let dbFormResponse, queuedFormResponse;
|
||||
if (!isPreview) {
|
||||
dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
// TODO: Why do we not save formFillerId available in the input?
|
||||
// formFillerId,
|
||||
if (queueFormResponse) {
|
||||
queuedFormResponse = await RoutingFormResponseRepository.recordQueuedFormResponse({
|
||||
formId: form.id,
|
||||
response: response,
|
||||
response,
|
||||
chosenRouteId,
|
||||
},
|
||||
});
|
||||
});
|
||||
dbFormResponse = null;
|
||||
} else {
|
||||
dbFormResponse = await RoutingFormResponseRepository.recordFormResponse({
|
||||
formId: form.id,
|
||||
response,
|
||||
chosenRouteId,
|
||||
});
|
||||
queuedFormResponse = null;
|
||||
|
||||
await onFormSubmission(
|
||||
{ ...serializableFormWithFields, userWithEmails },
|
||||
dbFormResponse.response as FormResponse,
|
||||
dbFormResponse.id,
|
||||
chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : undefined) : undefined
|
||||
);
|
||||
await onSubmissionOfFormResponse({
|
||||
form: serializableFormWithFields,
|
||||
formResponseInDb: dbFormResponse,
|
||||
chosenRouteAction: chosenRoute ? ("action" in chosenRoute ? chosenRoute.action : null) : null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
moduleLogger.debug("Dry run mode - Form response not stored and also webhooks and emails not sent");
|
||||
// Create a mock response for dry run
|
||||
dbFormResponse = {
|
||||
id: 0,
|
||||
formId: form.id,
|
||||
response,
|
||||
chosenRouteId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (queueFormResponse) {
|
||||
queuedFormResponse = {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
formId: form.id,
|
||||
response,
|
||||
};
|
||||
} else {
|
||||
// Create a mock response for dry run
|
||||
dbFormResponse = {
|
||||
id: 0,
|
||||
formId: form.id,
|
||||
response,
|
||||
chosenRouteId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isPreview: !!isPreview,
|
||||
formResponse: dbFormResponse,
|
||||
queuedFormResponse,
|
||||
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
|
||||
crmContactOwnerEmail,
|
||||
crmContactOwnerRecordType,
|
||||
|
||||
@@ -101,6 +101,7 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
const {
|
||||
teamMembersMatchingAttributeLogic,
|
||||
formResponse,
|
||||
queuedFormResponse,
|
||||
attributeRoutingConfig,
|
||||
crmContactOwnerEmail,
|
||||
crmContactOwnerRecordType,
|
||||
@@ -116,7 +117,8 @@ function RoutingForm({ form, profile, ...restProps }: Props) {
|
||||
}
|
||||
const allURLSearchParams = getUrlSearchParamsToForward({
|
||||
formResponse: chosenRouteWithFormResponse.response,
|
||||
formResponseId: formResponse.id,
|
||||
formResponseId: formResponse?.id ?? null,
|
||||
queuedFormResponseId: queuedFormResponse?.id ?? null,
|
||||
fields,
|
||||
searchParams: new URLSearchParams(window.location.search),
|
||||
teamMembersMatchingAttributeLogic,
|
||||
|
||||
@@ -26,7 +26,7 @@ export const getServerSideProps = async function getServerSideProps(
|
||||
|
||||
const isEmbed = appPages[1] === "embed";
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
|
||||
+7
@@ -66,6 +66,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
@@ -100,6 +101,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
@@ -151,6 +153,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
@@ -202,6 +205,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
@@ -229,6 +233,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
@@ -272,6 +277,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: {
|
||||
skipContactOwner: true,
|
||||
},
|
||||
@@ -317,6 +323,7 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: [1, 2],
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
|
||||
@@ -21,6 +21,7 @@ type GetUrlSearchParamsToForwardOptions = {
|
||||
>[];
|
||||
searchParams: URLSearchParams;
|
||||
formResponseId: number | null;
|
||||
queuedFormResponseId: string | null;
|
||||
teamMembersMatchingAttributeLogic: number[] | null;
|
||||
attributeRoutingConfig: AttributeRoutingConfig | null;
|
||||
reroutingFormResponses?: FormResponseValueOnly;
|
||||
@@ -37,6 +38,7 @@ export function getUrlSearchParamsToForward({
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic,
|
||||
formResponseId,
|
||||
queuedFormResponseId,
|
||||
attributeRoutingConfig,
|
||||
reroutingFormResponses,
|
||||
teamId,
|
||||
@@ -122,7 +124,8 @@ export function getUrlSearchParamsToForward({
|
||||
...(teamMembersMatchingAttributeLogic
|
||||
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
|
||||
: null),
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId),
|
||||
...(formResponseId ? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) } : null),
|
||||
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
|
||||
...attributeRoutingConfigParams,
|
||||
...(reroutingFormResponses
|
||||
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
|
||||
@@ -151,7 +154,7 @@ export function getUrlSearchParamsToForwardForReroute({
|
||||
attributeRoutingConfig,
|
||||
rescheduleUid,
|
||||
reroutingFormResponses,
|
||||
}: GetUrlSearchParamsToForwardOptions & {
|
||||
}: Omit<GetUrlSearchParamsToForwardOptions, "queuedFormResponseId"> & {
|
||||
rescheduleUid: string;
|
||||
reroutingFormResponses: FormResponseValueOnly;
|
||||
}) {
|
||||
@@ -160,6 +163,8 @@ export function getUrlSearchParamsToForwardForReroute({
|
||||
return getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
formResponseId,
|
||||
// Queued form response id is not available in rerouting
|
||||
queuedFormResponseId: null,
|
||||
fields,
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic,
|
||||
@@ -187,6 +192,8 @@ export function getUrlSearchParamsToForwardForTestPreview({
|
||||
teamMembersMatchingAttributeLogic,
|
||||
// There is no form response being stored in test preview
|
||||
formResponseId: null,
|
||||
// Queued form response id is not available in test preview
|
||||
queuedFormResponseId: null,
|
||||
searchParams,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
|
||||
inputFields: InputFields
|
||||
) {
|
||||
for (const [, connectedForm] of Object.entries(serializedForm.connectedForms)) {
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findFirst({
|
||||
const connectedFormDb = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: connectedForm.id,
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBooking
|
||||
formId: input.formId,
|
||||
},
|
||||
}),
|
||||
prisma.app_RoutingForms_Form.findFirst({
|
||||
prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: input.formId,
|
||||
},
|
||||
@@ -51,7 +51,7 @@ const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBooking
|
||||
|
||||
if (teamId) {
|
||||
// Need to get the credentials for the team and org
|
||||
const orgQuery = await prisma.team.findFirst({
|
||||
const orgQuery = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
|
||||
@@ -1356,7 +1356,7 @@ export default class SalesforceCRMService implements CRM {
|
||||
if (!organizerEmail) {
|
||||
this.log.warn(`No organizer email found for bookingUid ${bookingUid}`);
|
||||
}
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: { uid: bookingUid },
|
||||
select: { createdAt: true },
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function assignmentReasonHandler({
|
||||
}
|
||||
|
||||
async function handleAccountLookupFieldReason(routingFormResponseId: number, teamMemberEmail: string) {
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
where: {
|
||||
id: routingFormResponseId,
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
||||
|
||||
if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) {
|
||||
const userOwner = bookingToReschedule.user;
|
||||
const event = await prisma.eventType.findFirstOrThrow({
|
||||
const event = await prisma.eventType.findUniqueOrThrow({
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
||||
|
||||
if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) {
|
||||
const userOwner = bookingToReschedule.user;
|
||||
const event = await prisma.eventType.findFirstOrThrow({
|
||||
const event = await prisma.eventType.findUniqueOrThrow({
|
||||
select: {
|
||||
title: true,
|
||||
schedulingType: true,
|
||||
|
||||
@@ -24,7 +24,6 @@ export const AttendeeRequestEmail = (props: React.ComponentProps<typeof Attendee
|
||||
title: props.calEvent.title,
|
||||
date,
|
||||
})}
|
||||
callToAction={null}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
|
||||
to: toAddresses.join(","),
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.t("guests_added_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
|
||||
@@ -26,7 +26,8 @@ export default class OrganizerDailyVideoDownloadRecordingEmail extends BaseEmail
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.t("download_recording_subject", {
|
||||
title: this.calEvent.title,
|
||||
|
||||
@@ -38,7 +38,8 @@ export default class OrganizerDailyVideoDownloadTranscriptEmail extends BaseEmai
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.t("download_transcript_email_subject", {
|
||||
title: this.calEvent.title,
|
||||
|
||||
@@ -19,7 +19,8 @@ export default class OrganizerLocationChangeEmail extends OrganizerScheduledEmai
|
||||
to: toAddresses.join(","),
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.t("location_changed_event_type_subject", {
|
||||
eventType: this.calEvent.type,
|
||||
|
||||
@@ -24,7 +24,8 @@ export default class OrganizerRequestEmail extends OrganizerScheduledEmail {
|
||||
to: toAddresses.join(","),
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.t("awaiting_approval")}: ${this.calEvent.title}`,
|
||||
html: await this.getHtmlRequestEmail(template, this.calEvent, this.calEvent.organizer),
|
||||
|
||||
@@ -13,7 +13,8 @@ export default class OrganizerRequestReminderEmail extends OrganizerRequestEmail
|
||||
to: toAddresses.join(","),
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.t("event_awaiting_approval_subject", {
|
||||
title: this.calEvent.title,
|
||||
|
||||
@@ -19,7 +19,8 @@ export default class OrganizerRescheduledEmail extends OrganizerScheduledEmail {
|
||||
to: toAddresses.join(","),
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.calEvent.organizer.language.translate("event_type_has_been_rescheduled_on_time_date", {
|
||||
title: this.calEvent.title,
|
||||
|
||||
@@ -52,7 +52,8 @@ export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
to: toAddresses.join(","),
|
||||
...getReplyToHeader(
|
||||
this.calEvent,
|
||||
this.calEvent.attendees.map(({ email }) => email)
|
||||
this.calEvent.attendees.map(({ email }) => email),
|
||||
true
|
||||
),
|
||||
subject: `${this.newSeat ? `${this.t("new_attendee")}: ` : ""}${this.calEvent.title}`,
|
||||
html: await this.getHtml(
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Embed Playground</title>
|
||||
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
|
||||
<!-- <script src="./src/embed.ts" type="module"></script> -->
|
||||
<script type="text/javascript" src="./playground/lib/playground-init.ts"></script>
|
||||
<script>
|
||||
const url = new URL(document.URL);
|
||||
window.only = url.searchParams.get("only")
|
||||
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
|
||||
|
||||
let popupRescheduleUid = null;
|
||||
let calLink = null;
|
||||
const initialNamespace = (url.searchParams.get("only") || "").split("ns:")[1] || "";
|
||||
@@ -16,29 +11,11 @@
|
||||
popupRescheduleUid = searchParams.get("popupRescheduleUid") || "qm3kwt3aTnVD7vmP9tiT2f";
|
||||
calLink = searchParams.get("calLink");
|
||||
})()
|
||||
function generateRandomHexColor() {
|
||||
// Generate a random integer between 0 and 16777215 (FFFFFF in hex)
|
||||
const randomInt = Math.floor(Math.random() * 16777216);
|
||||
|
||||
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
|
||||
const hexString = randomInt.toString(16).padStart(6, "0");
|
||||
|
||||
// Return the hex string with a '#' prefix
|
||||
return `#${hexString}`;
|
||||
}
|
||||
|
||||
if (!location.search.includes("nonResponsive")) {
|
||||
document.write('<meta name="viewport" content="width=device-width"/>');
|
||||
}
|
||||
function scrollToIframeInPlayground() {
|
||||
const url = new URL(document.URL);
|
||||
// Only run the example specified by only=, avoids distraction and faster to test.
|
||||
const only = (window.only = url.searchParams.get("only") || '');
|
||||
const elementIdentifier = only !== "all" ? only.replace("ns:", "") : null;
|
||||
if (elementIdentifier) {
|
||||
location.hash = "#cal-booking-place-" + elementIdentifier + "-iframe";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function addInlineEmbedInNewNamespaceWithoutReload(selector) {
|
||||
Cal("init", "withoutReloadNamespace", {
|
||||
debug: true,
|
||||
@@ -377,10 +354,10 @@
|
||||
</button>
|
||||
<button
|
||||
onclick="(function () {Cal.ns.second('ui', {styles:{enabledDateButton: {
|
||||
backgroundColor: generateRandomHexColor(),
|
||||
backgroundColor: window.generateRandomHexColor(),
|
||||
},
|
||||
disabledDateButton: {
|
||||
backgroundColor: generateRandomHexColor(),
|
||||
backgroundColor: window.generateRandomHexColor(),
|
||||
},}})})()">
|
||||
Change 'enabledDateButton` and `disabledDateButton` background Color[Deprecated]
|
||||
</button>
|
||||
@@ -474,7 +451,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./playground.ts"></script>
|
||||
<script type="module" src="./playground/lib/playground.ts"></script>
|
||||
<script>
|
||||
if (initialNamespace.startsWith("skeletonDemo")) {
|
||||
document.getElementById("misc-embeds").style.display = "none";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const url = new URL(document.URL);
|
||||
window.only = url.searchParams.get("only");
|
||||
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
|
||||
window.calLink = url.searchParams.get("calLink");
|
||||
// We ensure that email is passed as param.email and not email so that autoforwarding of params plus explicit passing of email param doesn't make it an array of emails
|
||||
const emailQueryParam = url.searchParams.get("param.email");
|
||||
window.params = {
|
||||
email: emailQueryParam,
|
||||
formId: url.searchParams.get("param.formId"),
|
||||
};
|
||||
|
||||
window.generateRandomHexColor = function generateRandomHexColor() {
|
||||
// Generate a random integer between 0 and 16777215 (FFFFFF in hex)
|
||||
const randomInt = Math.floor(Math.random() * 16777216);
|
||||
|
||||
// Convert the integer to a hex string with 6 digits and add leading zeros if necessary
|
||||
const hexString = randomInt.toString(16).padStart(6, "0");
|
||||
|
||||
// Return the hex string with a '#' prefix
|
||||
return `#${hexString}`;
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { GlobalCal, EmbedEvent } from "./src/embed";
|
||||
import type { GlobalCal, EmbedEvent } from "../../src/embed";
|
||||
|
||||
const Cal = window.Cal as GlobalCal;
|
||||
Cal.config = Cal.config || {};
|
||||
@@ -2,14 +2,8 @@
|
||||
|
||||
<head>
|
||||
<title>Embed Playground - Routing Form</title>
|
||||
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
|
||||
<!-- <script src="./src/embed.ts" type="module"></script> -->
|
||||
<script type="text/javascript" src="./playground/lib/playground-init.ts"></script>
|
||||
<script>
|
||||
const url = new URL(document.URL);
|
||||
window.only = url.searchParams.get("only")
|
||||
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
|
||||
window.calLink = url.searchParams.get("calLink");
|
||||
|
||||
if (!location.search.includes("nonResponsive")) {
|
||||
document.write('<meta name="viewport" content="width=device-width"/>');
|
||||
}
|
||||
@@ -103,12 +97,38 @@
|
||||
|
||||
<body>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div id="cal-booking-place-routingFormWithoutPrerender">
|
||||
<a href="?only=ns:routingFormWithoutPrerender">Routing Form Without Prerender Demo</a>
|
||||
<form id="cal-booking-place-routingFormWithoutPrerender-form">
|
||||
<input type="text" name="name" placeholder="John Doe" />
|
||||
<input type="email" name="email" placeholder="john@example.com" />
|
||||
<select name="skills" placeholder="JavaScript, Node.js">
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="Sales">Sales</option>
|
||||
</select>
|
||||
</form>
|
||||
<button id="cal-booking-place-routingFormWithoutPrerender-submit" data-cal-namespace="routingFormWithoutPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
|
||||
<script>
|
||||
requestAnimationFrame(function updateSubmitButtonLink() {
|
||||
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
|
||||
const form = document.getElementById("cal-booking-place-routingFormWithoutPrerender-form");
|
||||
const name = form.querySelector("input[name='name']").value;
|
||||
const email = form.querySelector("input[name='email']").value;
|
||||
const skills = form.querySelector("select[name='skills']").value;
|
||||
if (name && email) {
|
||||
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
|
||||
}
|
||||
requestAnimationFrame(updateSubmitButtonLink);
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<div id="cal-booking-place-routingFormPrerender">
|
||||
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Demo</a>
|
||||
<hr/>
|
||||
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Team Booking Page - Demo</a>
|
||||
<p>1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)</p>
|
||||
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal</p>
|
||||
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal, sending fresh request for headless router, followed by fetching the slots</p>
|
||||
<p>3. Changing the form response and clicking submit, will re-submit the response and reopen the modal with the updated data(update slots call, updated prefill fields)</p>
|
||||
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as threshold of 1 min since last time the modal was shown is met). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
|
||||
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as slotsStaleTimeMs threshold is not breached). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
|
||||
<form id="cal-booking-place-routingFormPrerender-form">
|
||||
<input type="text" name="name" placeholder="John Doe" />
|
||||
<input type="email" name="email" placeholder="john@example.com" />
|
||||
@@ -143,33 +163,80 @@
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<div id="cal-booking-place-routingFormWithoutPrerender">
|
||||
<a href="?only=ns:routingFormWithoutPrerender">Routing Form Without Prerender Demo</a>
|
||||
<form id="cal-booking-place-routingFormWithoutPrerender-form">
|
||||
<input type="text" name="name" placeholder="John Doe" />
|
||||
<input type="email" name="email" placeholder="john@example.com" />
|
||||
<select name="skills" placeholder="JavaScript, Node.js">
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="Sales">Sales</option>
|
||||
</select>
|
||||
</form>
|
||||
<button id="cal-booking-place-routingFormWithoutPrerender-submit" data-cal-namespace="routingFormWithoutPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
|
||||
<script>
|
||||
requestAnimationFrame(function updateSubmitButtonLink() {
|
||||
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
|
||||
const form = document.getElementById("cal-booking-place-routingFormWithoutPrerender-form");
|
||||
const name = form.querySelector("input[name='name']").value;
|
||||
const email = form.querySelector("input[name='email']").value;
|
||||
const skills = form.querySelector("select[name='skills']").value;
|
||||
if (name && email) {
|
||||
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
|
||||
<div id="cal-booking-routing-full-prerender">
|
||||
<hr/>
|
||||
<a href="?only=ns:routing-full-prerender&debug=1¶m.email=test@example.com">Routing Form - Prerender Headless Router itself queuing the form response</a>
|
||||
<p>1. As page is loading, we prerender the headless router for the email passed as param.email</p>
|
||||
<p>2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed</p>
|
||||
<p>3. The prerender of the headless router queues the form response and doesn't really record it. When the actual booking is made, the form response is recorded from the queue</p>
|
||||
<p>4. If the CTA click happens before the slots are considered stale(configurable via options.slotsStaleTimeMs), then it will open the prerendered modal without fetching the slots, otherwise it will fetch the slots(for the routedTeamMembers/contactOwner only) and till then skeleton will be shown</p>
|
||||
<p>5. If the CTA click happens after iframeForceReloadThresholdMs has passed, then fresh headless router request is sent which could be really slow. It is important to do force reload after a certain time because the Routing Form itself could have changed in the meantime or Salesforce ownership might be available or some other change might have occured in Cal.com's side</p>
|
||||
<p>6. slotsStaleTimeMs is set to 10 seconds(default is 1 minute) and iframeForceReloadThresholdMs is set to 30 seconds(default is 15 minutes) and they are considered from the time when the prerender/modal call was made</p>
|
||||
<p>7. To avoid reaching the iframeForceReloadThresholdMs, user could prerender the router again and again judiciously</p>
|
||||
</p>
|
||||
<script>
|
||||
window.buildRouterUrl = ({email, formId}) => {
|
||||
return `router?form=${formId}&email=${email}`;
|
||||
}
|
||||
requestAnimationFrame(updateSubmitButtonLink);
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
<div class="inline-embed-container">
|
||||
<div id="cal-booking-place-routing-full-prerender">
|
||||
<div class="place"></div>
|
||||
<label for="cal-booking-place-routing-full-prerender-input-email">Email</label>
|
||||
<script>
|
||||
document.write(`<input style="width: 400px" type="email" id="cal-booking-place-routing-full-prerender-input-email" value="${window.params.email ?? ''}" />`)
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<h3><script>
|
||||
document.write(`<button id="cta-routing-full-prerender" data-cal-namespace="routingFormFullPrerender" data-cal-origin="${window.calOrigin}" data-cal-link="${window.buildRouterUrl({email:document.getElementById('cal-booking-place-routing-full-prerender-input-email').value, formId:window.params.formId})}" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots"}'>Demo</button>`)
|
||||
</script></h3>
|
||||
<script>
|
||||
(()=>{
|
||||
if (window.only !== "ns:routing-full-prerender") {
|
||||
return;
|
||||
}
|
||||
Cal("init", "routingFormFullPrerender", {
|
||||
debug: true,
|
||||
calOrigin: window.calOrigin,
|
||||
});
|
||||
const prerender = ({email, formId}) => {
|
||||
Cal.ns.routingFormFullPrerender("prerender", {
|
||||
// Email will be automatically passed as query param
|
||||
calLink: window.buildRouterUrl({email, formId}),
|
||||
type: "modal",
|
||||
pageType: "team.event.booking.slots",
|
||||
options: {
|
||||
slotsStaleTimeMs: 10 * 1000,
|
||||
iframeForceReloadThresholdMs: 30 * 1000,
|
||||
},
|
||||
});
|
||||
}
|
||||
document.getElementById('cal-booking-place-routing-full-prerender-input-email').onchange = function onEmailChange(e) {
|
||||
const element = e.target;
|
||||
const email = element.validity.valid ? element.value : null;
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
function updateCtaLink(email) {
|
||||
const element = document.getElementById('cta-routing-full-prerender');
|
||||
const currentLink = element.getAttribute('data-cal-link');
|
||||
const currentLinkObject = new URL(currentLink, 'https://example.com');
|
||||
currentLinkObject.searchParams.set('email', email);
|
||||
element.setAttribute('data-cal-link', currentLinkObject.pathname + currentLinkObject.search);
|
||||
}
|
||||
updateCtaLink(email);
|
||||
prerender({email, formId:window.params.formId});
|
||||
}
|
||||
if (!window.params.email || !window.params.formId) {
|
||||
alert('Pass param.email and param.formId query params')
|
||||
}
|
||||
prerender({email:window.params.email, formId:window.params.formId});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./playground.ts"></script>
|
||||
<script type="module" src="./playground/lib/playground.ts"></script>
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { embedStore, getEmbedBookerState } from "../embed-iframe";
|
||||
|
||||
// Test helper functions
|
||||
type fakeCurrentDocumentUrlParams = {
|
||||
origin?: string;
|
||||
@@ -25,25 +23,25 @@ function mockDocumentUrl(url: URL | string) {
|
||||
return vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
|
||||
}
|
||||
|
||||
function createSearchParams(params: Record<string, string>) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
searchParams.set(key, value);
|
||||
});
|
||||
return searchParams;
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function nextTick() {
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
|
||||
describe("embedStore.router.ensureQueryParamsInUrl", () => {
|
||||
// Mock window.history and URL
|
||||
|
||||
describe("embedStore.router.ensureQueryParamsInUrl", async () => {
|
||||
let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore;
|
||||
const originalHistory = window.history;
|
||||
const originalURL = window.URL;
|
||||
|
||||
function nextTick() {
|
||||
vi.advanceTimersByTime(100);
|
||||
}
|
||||
beforeEach(async () => {
|
||||
// Mock window.history and URL
|
||||
({ embedStore } = await import("../embed-iframe/lib/embedStore"));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Mock requestAnimationFrame and cancelAnimationFrame
|
||||
window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
||||
@@ -75,7 +73,6 @@ describe("embedStore.router.ensureQueryParamsInUrl", () => {
|
||||
it("should add missing parameters to URL", async () => {
|
||||
// Setup
|
||||
fakeCurrentDocumentUrl();
|
||||
|
||||
// Execute
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: {
|
||||
@@ -163,7 +160,13 @@ describe("embedStore.router.ensureQueryParamsInUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmbedBookerState", () => {
|
||||
describe("getEmbedBookerState", async () => {
|
||||
let getEmbedBookerState: typeof import("../embed-iframe").getEmbedBookerState;
|
||||
beforeEach(async () => {
|
||||
fakeCurrentDocumentUrl();
|
||||
({ getEmbedBookerState } = await import("../embed-iframe"));
|
||||
});
|
||||
|
||||
it("should return 'initializing' when bookerState is 'loading'", () => {
|
||||
const result = getEmbedBookerState({
|
||||
bookerState: "loading",
|
||||
@@ -242,3 +245,138 @@ describe("getEmbedBookerState", () => {
|
||||
expect(result).toBe("slotsPending");
|
||||
});
|
||||
});
|
||||
|
||||
describe("methods", async () => {
|
||||
let methods: typeof import("../embed-iframe").methods;
|
||||
let embedStore: typeof import("../embed-iframe/lib/embedStore").embedStore;
|
||||
let isLinkReadyMock: ReturnType<typeof vi.fn> | undefined;
|
||||
let isBookerReadyMock: ReturnType<typeof vi.fn> | undefined;
|
||||
let ensureQueryParamsInUrlMock: ReturnType<typeof vi.fn> | undefined;
|
||||
let recordResponseIfQueuedMock: ReturnType<typeof vi.fn> | undefined;
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
fakeCurrentDocumentUrl();
|
||||
vi.doMock("../embed-iframe/lib/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../embed-iframe/lib/utils")>();
|
||||
return {
|
||||
...actual,
|
||||
isLinkReady: isLinkReadyMock,
|
||||
isBookerReady: isBookerReadyMock,
|
||||
recordResponseIfQueued: recordResponseIfQueuedMock,
|
||||
};
|
||||
});
|
||||
vi.doMock("../embed-iframe/lib/embedStore", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../embed-iframe/lib/embedStore")>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
embedStore: {
|
||||
...actual.embedStore,
|
||||
router: {
|
||||
ensureQueryParamsInUrl: ensureQueryParamsInUrlMock,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
isLinkReadyMock = vi.fn();
|
||||
isBookerReadyMock = vi.fn();
|
||||
ensureQueryParamsInUrlMock = vi.fn().mockImplementation(() => {
|
||||
console.log("Fake ensureQueryParamsInUrl called");
|
||||
return {
|
||||
stopEnsuringQueryParamsInUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
recordResponseIfQueuedMock = vi.fn().mockResolvedValue(1);
|
||||
({ methods } = await import("../embed-iframe"));
|
||||
({ embedStore } = await import("../embed-iframe/lib/embedStore"));
|
||||
});
|
||||
|
||||
describe("methods.connect", async () => {
|
||||
it("should ensure that 'cal.embed.connectVersion' is incremented in query params", async () => {
|
||||
embedStore.renderState = "completed";
|
||||
const currentConnectVersion = 1;
|
||||
embedStore.connectVersion = currentConnectVersion;
|
||||
fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } });
|
||||
isLinkReadyMock?.mockReturnValue(true);
|
||||
isBookerReadyMock?.mockReturnValue(true);
|
||||
await methods.connect({
|
||||
config: {},
|
||||
params: {},
|
||||
});
|
||||
expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({
|
||||
toBeThereParams: {
|
||||
"cal.embed.connectVersion": (currentConnectVersion + 1).toString(),
|
||||
},
|
||||
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should ensure that 'cal.embed.connectVersion' is not incremented in query params when 'cal.embed.noSlotsFetchOnConnect' is true", async () => {
|
||||
embedStore.renderState = "completed";
|
||||
const currentConnectVersion = 1;
|
||||
embedStore.connectVersion = currentConnectVersion;
|
||||
fakeCurrentDocumentUrl({ params: { "cal.embed.connectVersion": "1" } });
|
||||
isLinkReadyMock?.mockReturnValue(true);
|
||||
isBookerReadyMock?.mockReturnValue(true);
|
||||
await methods.connect({
|
||||
config: {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
},
|
||||
params: {},
|
||||
});
|
||||
expect(embedStore.router.ensureQueryParamsInUrl).toHaveBeenCalledWith({
|
||||
toBeThereParams: {
|
||||
"cal.embed.connectVersion": currentConnectVersion.toString(),
|
||||
},
|
||||
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should set/update 'cal.routingFormResponseId' if the current iframe has 'cal.queuedFormResponse' in the query params", async () => {
|
||||
embedStore.renderState = "completed";
|
||||
const currentConnectVersion = 1;
|
||||
embedStore.connectVersion = currentConnectVersion;
|
||||
fakeCurrentDocumentUrl({
|
||||
params: {
|
||||
"cal.queuedFormResponse": "true",
|
||||
"cal.routingFormResponseId": "1",
|
||||
"cal.embed.connectVersion": currentConnectVersion.toString(),
|
||||
},
|
||||
});
|
||||
const convertedRoutingFormResponseId = 101;
|
||||
recordResponseIfQueuedMock?.mockResolvedValue(convertedRoutingFormResponseId);
|
||||
isLinkReadyMock?.mockReturnValue(true);
|
||||
isBookerReadyMock?.mockReturnValue(true);
|
||||
await methods.connect({
|
||||
config: {},
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(ensureQueryParamsInUrlMock).toHaveBeenCalledWith({
|
||||
toBeThereParams: {
|
||||
"cal.embed.connectVersion": (currentConnectVersion + 1).toString(),
|
||||
"cal.routingFormResponseId": convertedRoutingFormResponseId.toString(),
|
||||
},
|
||||
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("methods.parentKnowsIframeReady", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should set renderState to 'completed' on link ready", () => {
|
||||
isLinkReadyMock?.mockReturnValue(true);
|
||||
methods.parentKnowsIframeReady({});
|
||||
expect(embedStore.renderState).not.toBe("completed");
|
||||
nextTick();
|
||||
expect(embedStore.renderState).toBe("completed");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// We can't keep it too high because that might cause stale content to be shown
|
||||
// TODO: We should introduce a slotRefresh mechanism
|
||||
export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 10 * 1000; // 1 minute
|
||||
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
// Configurable via options.slotsStaleTimeMs. We could keep it at a lower end as the impact would be refetch of availability only.
|
||||
export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 60 * 1000; // 1 minute
|
||||
// Keep it high by default to avoid impact on loading time as it would cause a full reload of the iframe user can configure it via options.iframeForceReloadThresholdMs
|
||||
// Also full reload means that response would be resubmitted.
|
||||
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
|
||||
throw new Error(
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
|
||||
import type { Message } from "./embed";
|
||||
import { embedStore, EMBED_IFRAME_STATE } from "./embed-iframe/lib/embedStore";
|
||||
import { runAsap, isBookerReady, isLinkReady, recordResponseIfQueued } from "./embed-iframe/lib/utils";
|
||||
import { sdkActionManager } from "./sdk-event";
|
||||
import type {
|
||||
EmbedThemeConfig,
|
||||
UiConfig,
|
||||
EmbedNonStylesConfig,
|
||||
BookerLayouts,
|
||||
@@ -13,9 +14,10 @@ import type {
|
||||
EmbedBookerState,
|
||||
SlotsQuery,
|
||||
PrefillAndIframeAttrsConfig,
|
||||
SetStyles,
|
||||
setNonStylesConfig,
|
||||
} from "./types";
|
||||
import { useCompatSearchParams } from "./useCompatSearchParams";
|
||||
import { isParamValuePresentInUrlSearchParams } from "./utils";
|
||||
|
||||
// We don't import it from Booker/types because the types from this module are published to npm and we can't import packages that aren't published
|
||||
type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
|
||||
@@ -27,16 +29,12 @@ const eventsAllowedInPrerendering = [
|
||||
// so that iframe height is adjusted according to the content, and iframe is ready to be shown when needed
|
||||
"__dimensionChanged",
|
||||
|
||||
// When this event is fired, the iframe is still in prerender state but is going to be moved out of prerender state
|
||||
"__connectInitiated",
|
||||
|
||||
// For other events, we should consider introducing prerender specific events and not reuse existing events
|
||||
];
|
||||
|
||||
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||
const enum EMBED_IFRAME_STATE {
|
||||
NOT_INITIALIZED,
|
||||
INITIALIZED,
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CalEmbed: {
|
||||
@@ -49,110 +47,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
||||
*/
|
||||
export const embedStore = {
|
||||
connectVersion: 0 as number,
|
||||
/**
|
||||
* Tracks whether the prerender has been completed or not.
|
||||
* NOTE: prerenderState would be "completed" even after the iframe was switched from isPrerendering=true to not Prerendering(which happens after connect)
|
||||
*/
|
||||
prerenderState: null as null | "inProgress" | "completed",
|
||||
|
||||
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
|
||||
router: {
|
||||
/**
|
||||
* When we do the history push, it is possible that
|
||||
* - React might revert that change depending on in what state React is in while initializing
|
||||
* - So, we use a declarative approach to ensure that our requirement is continuously met
|
||||
*/
|
||||
ensureQueryParamsInUrl({
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
}: {
|
||||
toBeThereParams: Record<string, string | string[]>;
|
||||
toRemoveParams: string[];
|
||||
}) {
|
||||
let stopUpdating = false;
|
||||
function updateIfNeeded() {
|
||||
if (stopUpdating) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
const currentUrl = new URL(document.URL);
|
||||
let hasChanged = false;
|
||||
|
||||
// Ensuring toBeThereSearchParams
|
||||
for (const [key, newValue] of Object.entries(toBeThereParams)) {
|
||||
// It checks that the value must be present and if an array no other item should be there except those in newValue
|
||||
hasChanged = !isParamValuePresentInUrlSearchParams({
|
||||
param: key,
|
||||
value: newValue,
|
||||
container: currentUrl.searchParams,
|
||||
});
|
||||
if (hasChanged) {
|
||||
setParamInUrl({ key, value: newValue, url: currentUrl });
|
||||
}
|
||||
}
|
||||
|
||||
removeParamsFromUrl({ keys: toRemoveParams, url: currentUrl });
|
||||
|
||||
hasChanged = hasChanged || toRemoveParams.length > 0;
|
||||
if (hasChanged) {
|
||||
// Avoid unnecessary history push
|
||||
window.history.replaceState({}, "", currentUrl.toString());
|
||||
}
|
||||
runAsap(updateIfNeeded);
|
||||
return {
|
||||
hasChanged,
|
||||
};
|
||||
}
|
||||
const { hasChanged } = updateIfNeeded();
|
||||
return {
|
||||
stopEnsuringQueryParamsInUrl: () => {
|
||||
stopUpdating = true;
|
||||
},
|
||||
hasChanged,
|
||||
};
|
||||
|
||||
function removeParamsFromUrl({ keys, url }: { keys: string[]; url: URL }) {
|
||||
for (const key of keys) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function setParamInUrl({ key, value, url }: { key: string; value: string | string[]; url: URL }) {
|
||||
// Reset and then set the new value, to ensure nothing else remains in value
|
||||
url.searchParams.delete(key);
|
||||
const newValueArray = Array.isArray(value) ? value : [value];
|
||||
newValueArray.forEach((val) => {
|
||||
url.searchParams.append(key, val);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
|
||||
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
|
||||
styles: {} as EmbedStyles | undefined,
|
||||
nonStyles: {} as EmbedNonStylesConfig | undefined,
|
||||
namespace: null as string | null,
|
||||
embedType: undefined as undefined | null | string,
|
||||
// Store all React State setters here.
|
||||
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
|
||||
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
|
||||
// Embed can show itself only after this is set to true
|
||||
parentInformedAboutContentHeight: false,
|
||||
windowLoadEventFired: false,
|
||||
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
|
||||
theme: undefined as UiConfig["theme"],
|
||||
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
|
||||
/**
|
||||
* We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components.
|
||||
*/
|
||||
setUiConfig: [] as ((arg0: UiConfig) => void)[],
|
||||
};
|
||||
|
||||
let isSafariBrowser = false;
|
||||
const isBrowser = typeof window !== "undefined";
|
||||
|
||||
@@ -166,11 +60,6 @@ if (isBrowser) {
|
||||
}
|
||||
}
|
||||
|
||||
function runAsap(fn: (...arg: unknown[]) => void) {
|
||||
// We don't use rAF because it runs slower in Safari plus doesn't run if the iframe is hidden sometimes
|
||||
return setTimeout(fn, 50);
|
||||
}
|
||||
|
||||
function log(...args: unknown[]) {
|
||||
if (isBrowser) {
|
||||
const namespace = getNamespace();
|
||||
@@ -366,31 +255,6 @@ function getEmbedType() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* It is important to be able to check realtime(instead of storing isLinkReady as a variable) if the link is ready, because there is a possibility that booker might have moved to non-ready state from ready state
|
||||
*/
|
||||
function isLinkReady() {
|
||||
if (!embedStore.parentInformedAboutContentHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBookerPage()) {
|
||||
// Let's wait for Booker to be ready before showing the embed
|
||||
// It means that booker has loaded all its data and is ready to show
|
||||
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
|
||||
return isBookerReady();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBookerReady() {
|
||||
return window._embedBookerState === "slotsDone";
|
||||
}
|
||||
|
||||
function isBookerPage() {
|
||||
return !!window._embedBookerState;
|
||||
}
|
||||
|
||||
export const useIsEmbed = (embedSsr?: boolean) => {
|
||||
const [isEmbed, setIsEmbed] = useState(embedSsr);
|
||||
useEffect(() => {
|
||||
@@ -441,8 +305,44 @@ function showPageAsNonEmbed() {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRoutingFormResponseIdInUrl({
|
||||
newlyRecordedResponseId,
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
}: {
|
||||
newlyRecordedResponseId: number;
|
||||
toBeThereParams: Record<string, string | string[]>;
|
||||
toRemoveParams: string[];
|
||||
}) {
|
||||
// Update routingFormResponseId in url only after connect is completed, to keep things simple
|
||||
// Adding cal.routingFormResponseId in query param later shouldn't change anything in UI plus no slot request would go again due ot this.
|
||||
|
||||
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
|
||||
toBeThereParams: {
|
||||
...toBeThereParams,
|
||||
"cal.routingFormResponseId": newlyRecordedResponseId.toString(),
|
||||
},
|
||||
toRemoveParams,
|
||||
});
|
||||
// Immediately stop ensuring query params in url as the page is already ready
|
||||
// We could think about doing it after some time if needed later.
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
}
|
||||
|
||||
async function waitForRenderStateToBeCompleted() {
|
||||
return new Promise<void>((resolve) => {
|
||||
(function tryToConnect() {
|
||||
if (embedStore.renderState !== "completed") {
|
||||
runAsap(tryToConnect);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
|
||||
const methods = {
|
||||
export const methods = {
|
||||
ui: function style(uiConfig: UiConfig) {
|
||||
// TODO: Create automatic logger for all methods. Useful for debugging.
|
||||
log("Method: ui called", uiConfig);
|
||||
@@ -490,27 +390,24 @@ const methods = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
parentKnowsIframeReady: (_unused: unknown) => {
|
||||
log("Method: `parentKnowsIframeReady` called");
|
||||
|
||||
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
|
||||
// Embed background must still remain transparent
|
||||
runAsap(function tryInformingLinkReady() {
|
||||
if (!isLinkReady()) {
|
||||
if (!isLinkReady({ embedStore })) {
|
||||
runAsap(tryInformingLinkReady);
|
||||
return;
|
||||
}
|
||||
|
||||
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
|
||||
// Embed background must still remain transparent
|
||||
makeBodyVisible();
|
||||
if (isPrerendering()) {
|
||||
log("prerenderState is 'completed'");
|
||||
embedStore.prerenderState = "completed";
|
||||
}
|
||||
log("renderState is 'completed'");
|
||||
embedStore.renderState = "completed";
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Connects new config to prerendered page
|
||||
*/
|
||||
connect: function connect({
|
||||
connect: async function connect({
|
||||
config,
|
||||
params,
|
||||
}: {
|
||||
@@ -520,12 +417,23 @@ const methods = {
|
||||
// We can't accept URLSearchParams as it isn't cloneable and thus postMessage doesn't support it
|
||||
params: Record<string, string | string[]>;
|
||||
}) {
|
||||
sdkActionManager?.fire("__connectInitiated", {});
|
||||
log("Method: connect, requested with params", { config, params });
|
||||
const { iframeAttrs: _1, ...queryParamsFromConfig } = config;
|
||||
const connectVersion = (embedStore.connectVersion = embedStore.connectVersion + 1);
|
||||
const {
|
||||
iframeAttrs: _1,
|
||||
"cal.embed.noSlotsFetchOnConnect": noSlotsFetchOnConnect,
|
||||
...queryParamsFromConfig
|
||||
} = config;
|
||||
// We reset it to allow informing parent again through `__dimensionChanged` event about possibly updated dimensions with changes in config
|
||||
embedStore.parentInformedAboutContentHeight = false;
|
||||
|
||||
if (noSlotsFetchOnConnect !== "true") {
|
||||
log("Method: connect, noSlotsFetchOnConnect is false. Requesting slots re-fetch");
|
||||
// Incrementing the version forces the slots call to be made again
|
||||
embedStore.connectVersion = embedStore.connectVersion + 1;
|
||||
}
|
||||
|
||||
const connectVersion = embedStore.connectVersion;
|
||||
// Config is just a typed and more declarative way to pass the query params from the parent(except iframeAttrs which is meant to be consumed by parent and not supposed to passed to child)
|
||||
// So, query params can come directly by providing them to calLink or through config
|
||||
const toBeThereParams = {
|
||||
@@ -535,19 +443,28 @@ const methods = {
|
||||
"cal.embed.connectVersion": connectVersion.toString(),
|
||||
};
|
||||
|
||||
(function tryToConnect() {
|
||||
if (embedStore.prerenderState !== "completed") {
|
||||
runAsap(tryToConnect);
|
||||
return;
|
||||
}
|
||||
const toRemoveParams = ["preload", "prerender", "cal.skipSlotsFetch"];
|
||||
await waitForRenderStateToBeCompleted();
|
||||
|
||||
log("Method: connect, prerenderState is completed. Connecting");
|
||||
connectPreloadedEmbed({
|
||||
// We know after removing iframeAttrs, that it is of this type
|
||||
toBeThereParams,
|
||||
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
|
||||
});
|
||||
})();
|
||||
log("Method: connect, renderState is completed. Connecting");
|
||||
await connectPreloadedEmbed({
|
||||
// We know after removing iframeAttrs, that it is of this type
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
});
|
||||
|
||||
// We now record the response to routingFormResponse and connect that with queuedResponse, as the user actually opened the modal which is confirmed by this connect method call
|
||||
const newlyRecordedResponseId = await recordResponseIfQueued(params);
|
||||
// Allow 0 which is for dry run
|
||||
// Negative values are not possible
|
||||
if (typeof newlyRecordedResponseId !== "number") {
|
||||
return;
|
||||
}
|
||||
await ensureRoutingFormResponseIdInUrl({
|
||||
newlyRecordedResponseId,
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -723,9 +640,7 @@ function initializeAndSetupEmbed() {
|
||||
isPrerendering: isPrerendering(),
|
||||
});
|
||||
|
||||
if (isPrerendering()) {
|
||||
embedStore.prerenderState = "inProgress";
|
||||
}
|
||||
embedStore.renderState = "inProgress";
|
||||
|
||||
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
|
||||
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
|
||||
@@ -766,7 +681,7 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||
* url has the config as params
|
||||
*/
|
||||
function connectPreloadedEmbed({
|
||||
async function connectPreloadedEmbed({
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
}: {
|
||||
@@ -782,7 +697,7 @@ function connectPreloadedEmbed({
|
||||
|
||||
if (isBookerReady() && hasChanged) {
|
||||
// Give some time for react to update state that might lead booker to go to slotsLoading state
|
||||
waitForFrames = 5;
|
||||
waitForFrames = 2;
|
||||
}
|
||||
|
||||
// Booker might alreadyu be in slotsDone state. But we don't know if new getTeamSchedule request would intitiate or not. It would initiate when React updates the state but it might not go depending on if there is no actual state change in useSchedule components
|
||||
@@ -790,17 +705,25 @@ function connectPreloadedEmbed({
|
||||
|
||||
// Firing this event would stop the loader and show the embed
|
||||
// This causes loader to go away later.
|
||||
runAsap(function tryToFireLinkReady() {
|
||||
if (!isLinkReady() || waitForFrames > 0) {
|
||||
waitForFrames--;
|
||||
runAsap(tryToFireLinkReady);
|
||||
return;
|
||||
}
|
||||
// link is ready now, so we could stop doing it.
|
||||
// Also the page is visible to user now.
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
await new Promise<void>((resolve) => {
|
||||
runAsap(function tryToFireLinkReady() {
|
||||
if (!isLinkReady({ embedStore }) || waitForFrames > 0) {
|
||||
waitForFrames--;
|
||||
runAsap(tryToFireLinkReady);
|
||||
return;
|
||||
}
|
||||
// link is ready now, so we could stop doing it.
|
||||
// Also the page is visible to user now.
|
||||
stopEnsuringQueryParamsInUrl();
|
||||
sdkActionManager?.fire("__connectCompleted", {});
|
||||
sdkActionManager?.fire("linkReady", {});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
stopEnsuringQueryParamsInUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const isPrerendering = () => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
EmbedThemeConfig,
|
||||
UiConfig,
|
||||
EmbedNonStylesConfig,
|
||||
EmbedStyles,
|
||||
SetStyles,
|
||||
setNonStylesConfig,
|
||||
} from "../../types";
|
||||
import { isParamValuePresentInUrlSearchParams } from "../../utils";
|
||||
import { runAsap } from "./utils";
|
||||
|
||||
export const enum EMBED_IFRAME_STATE {
|
||||
NOT_INITIALIZED,
|
||||
INITIALIZED,
|
||||
}
|
||||
/**
|
||||
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
|
||||
*/
|
||||
export const embedStore = {
|
||||
connectVersion: 0 as number,
|
||||
/**
|
||||
* Tracks whether the iframe is fully rendered or in progress of rendering.
|
||||
* In case of prerendering as well as non-prerendering, it would be "completed" after the iframe is fully rendered.
|
||||
*/
|
||||
renderState: null as null | "inProgress" | "completed",
|
||||
|
||||
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
|
||||
router: {
|
||||
/**
|
||||
* When we do the history push, it is possible that
|
||||
* - React might revert that change depending on in what state React is in while initializing
|
||||
* - So, we use a declarative approach to ensure that our requirement is continuously met
|
||||
*/
|
||||
ensureQueryParamsInUrl({
|
||||
toBeThereParams,
|
||||
toRemoveParams,
|
||||
}: {
|
||||
toBeThereParams: Record<string, string | string[]>;
|
||||
toRemoveParams: string[];
|
||||
}) {
|
||||
let stopUpdating = false;
|
||||
function updateIfNeeded() {
|
||||
if (stopUpdating) {
|
||||
return { hasChanged: false };
|
||||
}
|
||||
const currentUrl = new URL(document.URL);
|
||||
let hasChanged = false;
|
||||
|
||||
// Ensuring toBeThereSearchParams
|
||||
for (const [key, newValue] of Object.entries(toBeThereParams)) {
|
||||
// It checks that the value must be present and if an array no other item should be there except those in newValue
|
||||
hasChanged = !isParamValuePresentInUrlSearchParams({
|
||||
param: key,
|
||||
value: newValue,
|
||||
container: currentUrl.searchParams,
|
||||
});
|
||||
if (hasChanged) {
|
||||
setParamInUrl({ key, value: newValue, url: currentUrl });
|
||||
}
|
||||
}
|
||||
|
||||
removeParamsFromUrl({ keys: toRemoveParams, url: currentUrl });
|
||||
|
||||
hasChanged = hasChanged || toRemoveParams.length > 0;
|
||||
if (hasChanged) {
|
||||
// Avoid unnecessary history push
|
||||
window.history.replaceState({}, "", currentUrl.toString());
|
||||
}
|
||||
runAsap(updateIfNeeded);
|
||||
return {
|
||||
hasChanged,
|
||||
};
|
||||
}
|
||||
const { hasChanged } = updateIfNeeded();
|
||||
return {
|
||||
stopEnsuringQueryParamsInUrl: () => {
|
||||
stopUpdating = true;
|
||||
},
|
||||
hasChanged,
|
||||
};
|
||||
|
||||
function removeParamsFromUrl({ keys, url }: { keys: string[]; url: URL }) {
|
||||
for (const key of keys) {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function setParamInUrl({ key, value, url }: { key: string; value: string | string[]; url: URL }) {
|
||||
// Reset and then set the new value, to ensure nothing else remains in value
|
||||
url.searchParams.delete(key);
|
||||
const newValueArray = Array.isArray(value) ? value : [value];
|
||||
newValueArray.forEach((val) => {
|
||||
url.searchParams.append(key, val);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
|
||||
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
|
||||
styles: {} as EmbedStyles | undefined,
|
||||
nonStyles: {} as EmbedNonStylesConfig | undefined,
|
||||
namespace: null as string | null,
|
||||
embedType: undefined as undefined | null | string,
|
||||
// Store all React State setters here.
|
||||
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
|
||||
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
|
||||
// Embed can show itself only after this is set to true
|
||||
parentInformedAboutContentHeight: false,
|
||||
windowLoadEventFired: false,
|
||||
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
|
||||
theme: undefined as UiConfig["theme"],
|
||||
uiConfig: undefined as Omit<UiConfig, "styles" | "theme"> | undefined,
|
||||
/**
|
||||
* We maintain a list of all setUiConfig setters that are in use at the moment so that we can update all those components.
|
||||
*/
|
||||
setUiConfig: [] as ((arg0: UiConfig) => void)[],
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
export function runAsap(fn: (...arg: unknown[]) => void) {
|
||||
// We don't use rAF because it runs slower in Safari plus doesn't run if the iframe is hidden sometimes
|
||||
return setTimeout(fn, 50);
|
||||
}
|
||||
|
||||
export function isBookerPage() {
|
||||
return !!window._embedBookerState;
|
||||
}
|
||||
|
||||
export function isBookerReady() {
|
||||
return window._embedBookerState === "slotsDone";
|
||||
}
|
||||
|
||||
/**
|
||||
* It is important to be able to check realtime(instead of storing isLinkReady as a variable) if the link is ready, because there is a possibility that booker might have moved to non-ready state from ready state
|
||||
*/
|
||||
export function isLinkReady({ embedStore }: { embedStore: typeof import("./embedStore").embedStore }) {
|
||||
if (!embedStore.parentInformedAboutContentHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isBookerPage()) {
|
||||
// Let's wait for Booker to be ready before showing the embed
|
||||
// It means that booker has loaded all its data and is ready to show
|
||||
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
|
||||
return isBookerReady();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the queuedFormResponse to the routingFormResponse record to mark it as an actual response now.
|
||||
*/
|
||||
export const recordResponseIfQueued = async (params: Record<string, string | string[]>) => {
|
||||
const url = new URL(document.URL);
|
||||
let routingFormResponseId: number | null = null;
|
||||
const queuedFormResponseIdParam = url.searchParams.get("cal.queuedFormResponseId");
|
||||
const queuedFormResponseId = queuedFormResponseIdParam;
|
||||
if (!queuedFormResponseId) {
|
||||
return null;
|
||||
}
|
||||
// Corresponding dry run value for routingFormResponseId is 0
|
||||
if (queuedFormResponseId === "00000000-0000-0000-0000-000000000000") {
|
||||
return 0;
|
||||
}
|
||||
// form is formId and isn't acutal Form data
|
||||
const { form: _1, ...actualFormData } = params;
|
||||
const res = await fetch(`/api/routing-forms/queued-response`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queuedFormResponseId, params: actualFormData }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const response = await res.json();
|
||||
const formResponseId = response.data.formResponseId;
|
||||
if (formResponseId) {
|
||||
// Now we have the actual routingFormResponseId.
|
||||
routingFormResponseId = formResponseId;
|
||||
}
|
||||
return routingFormResponseId;
|
||||
};
|
||||
@@ -2,6 +2,10 @@ import "../test/__mocks__/windowMatchMedia";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest";
|
||||
|
||||
import {
|
||||
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
|
||||
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
|
||||
} from "./constants";
|
||||
import { submitResponseAndGetRoutingResult } from "./utils";
|
||||
|
||||
vi.mock("./tailwindCss", () => ({
|
||||
@@ -339,6 +343,10 @@ describe("Cal", () => {
|
||||
layout: modalArg.config.layout,
|
||||
});
|
||||
|
||||
// Verify that the embedRenderStartTime and embedConfig are set, which are used by getNextActionForModal to decide if the modal should be reused or not
|
||||
expect(calInstance.embedRenderStartTime).toBeGreaterThan(0);
|
||||
expect(calInstance.embedConfig).toBeDefined();
|
||||
|
||||
expectIframeToHaveMatchingUrl({
|
||||
element: modalBox,
|
||||
expectedIframeUrlObject: {
|
||||
@@ -347,6 +355,44 @@ describe("Cal", () => {
|
||||
...modalArg.config,
|
||||
prerender: "true",
|
||||
embedType: "modal",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create modal having iframe with queueFormResponse=true param when prerendering headless router", () => {
|
||||
const modalArg = {
|
||||
...baseModalArgs,
|
||||
calLink: "router?form=123&email=john@example.com",
|
||||
config: {
|
||||
...baseModalArgs.config,
|
||||
"cal.embed.pageType": "doesntmatter",
|
||||
},
|
||||
calOrigin: null,
|
||||
__prerender: true,
|
||||
};
|
||||
calInstance.api.modal(modalArg);
|
||||
|
||||
const modalBox = expectCalModalBoxToBeInDocument({
|
||||
state: "prerendering",
|
||||
pageType: modalArg.config["cal.embed.pageType"],
|
||||
theme: modalArg.config.theme,
|
||||
layout: modalArg.config.layout,
|
||||
});
|
||||
|
||||
expectIframeToHaveMatchingUrl({
|
||||
element: modalBox,
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `${new URL(modalArg.calLink, "http://example.com").pathname}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...modalArg.config,
|
||||
prerender: "true",
|
||||
embedType: "modal",
|
||||
"cal.queueFormResponse": "true",
|
||||
email: "john@example.com",
|
||||
form: "123",
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
@@ -433,7 +479,9 @@ describe("Cal", () => {
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: expectedConfigAfterPrefilling,
|
||||
config: {
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
@@ -543,7 +591,7 @@ describe("Cal", () => {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfig,
|
||||
// It remains because internally we remove prerender=true in iframe
|
||||
// It remains because internally we remove prerender=true in iframe - through connect flow
|
||||
prerender: "true",
|
||||
"cal.skipSlotsFetch": "true",
|
||||
}),
|
||||
@@ -554,7 +602,9 @@ describe("Cal", () => {
|
||||
expect(calInstance.doInIframe).toHaveBeenCalledWith({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: expectedConfigAfterPrefilling,
|
||||
config: {
|
||||
...expectedConfigAfterPrefilling,
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
});
|
||||
@@ -587,7 +637,9 @@ describe("Cal", () => {
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: `/${modalArg.calLink}`,
|
||||
searchParams: new URLSearchParams(expectedConfigAfterPrefilling),
|
||||
searchParams: new URLSearchParams({
|
||||
...expectedConfigAfterPrefilling,
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
@@ -683,7 +735,9 @@ describe("Cal", () => {
|
||||
expect.objectContaining({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: configWithPrefilledValues,
|
||||
config: {
|
||||
...configWithPrefilledValues,
|
||||
},
|
||||
params: {},
|
||||
},
|
||||
})
|
||||
@@ -768,7 +822,9 @@ describe("Cal", () => {
|
||||
},
|
||||
expectedIframeUrlObject: {
|
||||
pathname: headlessRouterRedirectUrlWithPathOnly,
|
||||
searchParams: new URLSearchParams(configWithPrefilledValuesAndHeadlessRouterRedirectParams),
|
||||
searchParams: new URLSearchParams({
|
||||
...configWithPrefilledValuesAndHeadlessRouterRedirectParams,
|
||||
}),
|
||||
origin: null,
|
||||
},
|
||||
});
|
||||
@@ -1062,7 +1118,7 @@ describe("Cal", () => {
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
previousEmbedRenderStartTime: Date.now() - 1000000, // Much older timestamp
|
||||
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS - 1, // Much older timestamp
|
||||
embedRenderStartTime: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -1158,5 +1214,33 @@ describe("Cal", () => {
|
||||
const result = calInstance.getNextActionForModal(baseArgs);
|
||||
expect(result).toBe("noAction");
|
||||
});
|
||||
|
||||
it("should return connect-no-slots-fetch when reuseFully is true and slots are not stale", () => {
|
||||
const result = calInstance.getNextActionForModal({
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
prerenderOptions: {
|
||||
reuseFully: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toBe("connect-no-slots-fetch");
|
||||
});
|
||||
|
||||
it("should return connect when reuseFully is true but slots are stale", () => {
|
||||
const result = calInstance.getNextActionForModal({
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
prerenderOptions: {
|
||||
reuseFully: true,
|
||||
},
|
||||
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
||||
embedRenderStartTime: Date.now(),
|
||||
},
|
||||
});
|
||||
expect(result).toBe("connect");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,21 @@ type CalConfig = {
|
||||
uiDebug?: boolean;
|
||||
};
|
||||
|
||||
type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
reuseFully?: boolean;
|
||||
};
|
||||
|
||||
type ModalStateData = {
|
||||
embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme;
|
||||
previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null;
|
||||
embedRenderStartTime: number;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
isConnectionInitiated: boolean;
|
||||
prerenderOptions: ModalPrerenderOptions | null;
|
||||
};
|
||||
|
||||
type InitArgConfig = Partial<CalConfig> & {
|
||||
origin?: string;
|
||||
};
|
||||
@@ -309,7 +324,7 @@ export class Cal {
|
||||
config?: PrefillAndIframeAttrsConfig;
|
||||
calOrigin: string | null;
|
||||
}) {
|
||||
log("Loading in iframe", calLink);
|
||||
log("Loading in iframe", calLink, "with config", JSON.stringify(config));
|
||||
iframe.dataset.calLink = calLink;
|
||||
const calConfig = this.getCalConfig();
|
||||
const { iframeAttrs, ...queryParamsFromConfig } = config;
|
||||
@@ -522,13 +537,7 @@ export class Cal {
|
||||
}: {
|
||||
modal: { uid: string };
|
||||
pathWithQueryToLoad: string;
|
||||
stateData: {
|
||||
embedConfig: PrefillAndIframeAttrsConfig;
|
||||
previousEmbedConfig: PrefillAndIframeAttrsConfig | null;
|
||||
isConnectionInitiated: boolean;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
embedRenderStartTime: number;
|
||||
};
|
||||
stateData: ModalStateData;
|
||||
}) {
|
||||
const {
|
||||
embedConfig,
|
||||
@@ -536,6 +545,7 @@ export class Cal {
|
||||
isConnectionInitiated,
|
||||
previousEmbedRenderStartTime,
|
||||
embedRenderStartTime,
|
||||
prerenderOptions,
|
||||
} = stateData;
|
||||
const calConfig = this.getCalConfig();
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
@@ -570,43 +580,59 @@ export class Cal {
|
||||
? embedRenderStartTime - previousEmbedRenderStartTime
|
||||
: 0;
|
||||
const crossedReloadThreshold = previousEmbedRenderStartTime
|
||||
? timeSinceLastRender > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
|
||||
? timeSinceLastRender >
|
||||
(prerenderOptions?.iframeForceReloadThresholdMs ?? EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS)
|
||||
: false;
|
||||
|
||||
const areSlotsStale = previousEmbedRenderStartTime
|
||||
? timeSinceLastRender > EMBED_MODAL_IFRAME_SLOT_STALE_TIME
|
||||
? timeSinceLastRender > (prerenderOptions?.slotsStaleTimeMs ?? EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
|
||||
: false;
|
||||
|
||||
// Note that we don't worry about change in embed config because that is passed on as query params to the iframe and that is already supported by "connect" flow
|
||||
const isResetNeeded = !isSameCalLink || isInFailedState || crossedReloadThreshold;
|
||||
|
||||
const actionToTake = isResetNeeded
|
||||
? "fullReload"
|
||||
: !isSameConfig || !areSameQueryParams || !isConnectionInitiated || areSlotsStale
|
||||
? "connect"
|
||||
: "noAction";
|
||||
const getActionToTake = () => {
|
||||
if (isResetNeeded) {
|
||||
return "fullReload";
|
||||
}
|
||||
|
||||
log("Next Modal Action:", actionToTake, {
|
||||
path: {
|
||||
isSame: isSameCalLink,
|
||||
urlToLoadPath,
|
||||
lastLoadedPathInIframe,
|
||||
},
|
||||
config: {
|
||||
isSame: isSameConfig,
|
||||
previousEmbedConfig,
|
||||
embedConfig,
|
||||
},
|
||||
queryParams: {
|
||||
isSame: areSameQueryParams,
|
||||
lastLoadedUrlInIframeObjectSearchParams,
|
||||
urlToLoadObjectSearchParams,
|
||||
},
|
||||
areSlotsStale,
|
||||
crossedReloadThreshold,
|
||||
isInFailedState,
|
||||
isConnectionInitiated,
|
||||
});
|
||||
if (prerenderOptions?.reuseFully && !areSlotsStale) {
|
||||
return "connect-no-slots-fetch";
|
||||
}
|
||||
|
||||
if (!isSameConfig || !areSameQueryParams || !isConnectionInitiated || areSlotsStale) {
|
||||
return "connect";
|
||||
}
|
||||
return "noAction";
|
||||
};
|
||||
|
||||
const actionToTake = getActionToTake();
|
||||
|
||||
log(
|
||||
"Next Modal Action:",
|
||||
{ actionToTake, prerenderOptions },
|
||||
{
|
||||
path: {
|
||||
isSame: isSameCalLink,
|
||||
urlToLoadPath,
|
||||
lastLoadedPathInIframe,
|
||||
},
|
||||
config: {
|
||||
isSame: isSameConfig,
|
||||
previousEmbedConfig,
|
||||
embedConfig,
|
||||
},
|
||||
queryParams: {
|
||||
isSame: areSameQueryParams,
|
||||
lastLoadedUrlInIframeObjectSearchParams,
|
||||
urlToLoadObjectSearchParams,
|
||||
},
|
||||
areSlotsStale,
|
||||
crossedReloadThreshold,
|
||||
isInFailedState,
|
||||
isConnectionInitiated,
|
||||
}
|
||||
);
|
||||
|
||||
return actionToTake;
|
||||
|
||||
@@ -665,13 +691,7 @@ export class Cal {
|
||||
}: {
|
||||
modal: { uid: string; element: Element; calOrigin: string | null };
|
||||
calLinkUrlObject: URL;
|
||||
stateData: {
|
||||
embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme;
|
||||
previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null;
|
||||
embedRenderStartTime: number;
|
||||
previousEmbedRenderStartTime: number | null;
|
||||
isConnectionInitiated: boolean;
|
||||
};
|
||||
stateData: ModalStateData;
|
||||
}) {
|
||||
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
|
||||
const { embedConfig } = stateData;
|
||||
@@ -755,6 +775,7 @@ class CalApi {
|
||||
static initializedNamespaces = [] as string[];
|
||||
modalUid?: string;
|
||||
prerenderedModalUid?: string;
|
||||
prerenderOptions?: ModalPrerenderOptions;
|
||||
constructor(cal: Cal) {
|
||||
this.cal = cal;
|
||||
}
|
||||
@@ -946,12 +967,27 @@ class CalApi {
|
||||
config = {},
|
||||
calOrigin,
|
||||
__prerender = false,
|
||||
prerenderOptions = {},
|
||||
}: {
|
||||
calLink: string;
|
||||
config?: PrefillAndIframeAttrsConfig;
|
||||
calOrigin?: string;
|
||||
__prerender?: boolean;
|
||||
prerenderOptions?: ModalPrerenderOptions;
|
||||
}) {
|
||||
const calConfig = this.cal.getCalConfig();
|
||||
// Clone so that it doesn't mutate the original config
|
||||
config = { ...config };
|
||||
// calOrigin could have been passed as empty string by the user
|
||||
calOrigin = calOrigin || calConfig.calOrigin;
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
if (__prerender && this.cal.modalBox) {
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.cal.modalBox.remove();
|
||||
}
|
||||
|
||||
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
|
||||
// `this.prerenderedModalUid` is set for a modal created through "prerender"
|
||||
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
|
||||
@@ -959,15 +995,24 @@ class CalApi {
|
||||
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
|
||||
|
||||
const containerEl = document.body;
|
||||
|
||||
this.cal.isPrerendering = !!__prerender;
|
||||
|
||||
if (__prerender) {
|
||||
// TODO: Make `reuseFully` a configurable param as well later.
|
||||
// If someone's preloading the headless router path, we must reuse the iframe fully as is as Router would redirect to a Booking Page and that should be shown as is
|
||||
this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath };
|
||||
// Add prerender query param
|
||||
config.prerender = "true";
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
|
||||
// If we are prerendering a headless router path, we don't want to record the response immediately.
|
||||
if (isHeadlessRouterPath) {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!this.prerenderOptions?.reuseFully) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
}
|
||||
}
|
||||
|
||||
const configWithGuestKeyAndColorScheme = withColorScheme(
|
||||
@@ -978,22 +1023,17 @@ class CalApi {
|
||||
containerEl
|
||||
);
|
||||
|
||||
const calConfig = this.cal.getCalConfig();
|
||||
// calOrigin could have been passed as empty string by the user
|
||||
calOrigin = calOrigin || calConfig.calOrigin;
|
||||
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
|
||||
const previousEmbedConfig = this.cal.embedConfig;
|
||||
|
||||
const embedRenderStartTime = Date.now();
|
||||
const previousEmbedConfig = this.cal.embedConfig;
|
||||
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
|
||||
this.cal.embedRenderStartTime = embedRenderStartTime;
|
||||
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
|
||||
|
||||
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
|
||||
|
||||
// isConnectionPossible
|
||||
if (!!existingModalEl && !!this.cal.iframe) {
|
||||
const calLinkUrlObject = new URL(calLink, calOrigin);
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
log(`Trying to reuse modal ${uid}`);
|
||||
const stateData = {
|
||||
embedConfig: configWithGuestKeyAndColorScheme,
|
||||
@@ -1001,8 +1041,11 @@ class CalApi {
|
||||
embedRenderStartTime,
|
||||
previousEmbedRenderStartTime,
|
||||
isConnectionInitiated,
|
||||
prerenderOptions: this.prerenderOptions ?? null,
|
||||
};
|
||||
if (isHeadlessRouterPath) {
|
||||
|
||||
// If we want to reuse fully then we shouldn't submit response again
|
||||
if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) {
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
|
||||
@@ -1021,6 +1064,10 @@ class CalApi {
|
||||
});
|
||||
|
||||
if (actionToTake === "noAction") {
|
||||
if (__prerender) {
|
||||
// A prerender instruction can never show the modal
|
||||
return;
|
||||
}
|
||||
log(`Reopening modal without any other action needed ${uid}`);
|
||||
// Reopen the modal, nothing else to do
|
||||
existingModalEl.setAttribute("state", "reopened");
|
||||
@@ -1039,11 +1086,18 @@ class CalApi {
|
||||
iframe: this.cal.iframe,
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
});
|
||||
} else if (actionToTake === "connect") {
|
||||
} else if (actionToTake === "connect" || actionToTake === "connect-no-slots-fetch") {
|
||||
this.cal.doInIframe({
|
||||
method: "connect",
|
||||
arg: {
|
||||
config: configWithGuestKeyAndColorScheme,
|
||||
config: {
|
||||
...configWithGuestKeyAndColorScheme,
|
||||
...(actionToTake === "connect-no-slots-fetch"
|
||||
? {
|
||||
"cal.embed.noSlotsFetchOnConnect": "true",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
|
||||
},
|
||||
});
|
||||
@@ -1052,7 +1106,6 @@ class CalApi {
|
||||
|
||||
// We reach here in case of connect or fullReload
|
||||
this.modalUid = uid;
|
||||
this.cal.embedRenderStartTime = embedRenderStartTime;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1165,6 +1218,8 @@ class CalApi {
|
||||
type?: "modal" | "floatingButton";
|
||||
options?: {
|
||||
prerenderIframe?: boolean;
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
};
|
||||
pageType?: EmbedPageType;
|
||||
calOrigin?: string;
|
||||
@@ -1198,7 +1253,8 @@ class CalApi {
|
||||
}
|
||||
|
||||
const config = this.cal.getCalConfig();
|
||||
let prerenderIframe = options.prerenderIframe;
|
||||
const { prerenderIframe: prerenderIframeOption, ...prerenderOptions } = options;
|
||||
let prerenderIframe = prerenderIframeOption;
|
||||
if (type && prerenderIframe === undefined) {
|
||||
prerenderIframe = true;
|
||||
}
|
||||
@@ -1214,6 +1270,7 @@ class CalApi {
|
||||
calLink,
|
||||
calOrigin: calOrigin || config.calOrigin,
|
||||
__prerender: true,
|
||||
prerenderOptions,
|
||||
...(pageType ? { config: { "cal.embed.pageType": pageType } } : {}),
|
||||
});
|
||||
} else {
|
||||
@@ -1225,22 +1282,50 @@ class CalApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usecase - Prerender headless router path(i.e. `/router?form={FORM_ID}¶m1=value1&......`)
|
||||
*
|
||||
* - Loads the headless router path in iframe hidden behind the scenes
|
||||
* - Stores the response in a temporary queue at backend.
|
||||
* - Redirects to appropriate booking page based on the Routing Rules
|
||||
* - Loads the availability as per the team members identified by Routing - Attribute Rules
|
||||
* - At this point, the booking page is ready to be used but is hidden behind the scenes
|
||||
*
|
||||
* A call to `modal` instruction(which automatically happens when an element with `data-cal-link` attribute is clicked) would then show this prerendered booking page sending a request `queued-response` that records the field values now as a response and connect it to the queued-response
|
||||
*/
|
||||
prerender({
|
||||
calLink,
|
||||
type,
|
||||
pageType,
|
||||
calOrigin,
|
||||
options = {},
|
||||
}: {
|
||||
calLink: string;
|
||||
type: "modal" | "floatingButton";
|
||||
pageType?: EmbedPageType;
|
||||
calOrigin?: string;
|
||||
options?: {
|
||||
/**
|
||||
* Time in milliseconds after which the prerendered slots/availability should be considered stale and would be requested again when the modal is opened. This could slow down the booking page load by the time taken by slots loading request which shouldn't be too high
|
||||
*
|
||||
* Default value is 1 min
|
||||
*/
|
||||
slotsStaleTimeMs?: number;
|
||||
/**
|
||||
* Time in milliseconds after which the iframe would be forcefully reloaded when the modal is opened and thus booking page load could slow down drastically showing the skeleton loader in the meantime
|
||||
* To avoid reaching this threshold, the user could prerender before this time is reached in usecases where prerender has been done long time ago.
|
||||
*
|
||||
* Default value is 15 mins
|
||||
*/
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
};
|
||||
}) {
|
||||
this.preload({
|
||||
calLink,
|
||||
type,
|
||||
pageType,
|
||||
calOrigin,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export type EventDataMap = {
|
||||
};
|
||||
};
|
||||
linkReady: Record<string, never>;
|
||||
__connectInitiated: Record<string, never>;
|
||||
__connectCompleted: Record<string, never>;
|
||||
bookingSuccessfulV2: {
|
||||
uid: string | undefined;
|
||||
title: string | undefined;
|
||||
|
||||
@@ -67,6 +67,8 @@ export type KnownConfig = {
|
||||
// Prefixing with cal.embed because there could be more query params that aren't required by embed and are used for things like prefilling booking form, configuring dry run, and some other params simply to be forwarded to the booking success redirect URL.
|
||||
// There are some cal. prefixed query params as well, not meant for embed specifically, but in general for cal.com
|
||||
"cal.embed.pageType"?: EmbedPageType;
|
||||
// If true, then when doing a "connect" with pre-rendered iframe, we won't fetch slots. This is useful when we are reusing the iframe fully as is.
|
||||
"cal.embed.noSlotsFetchOnConnect"?: "true" | "false";
|
||||
};
|
||||
|
||||
export type EmbedBookerState =
|
||||
@@ -97,3 +99,6 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
|
||||
id?: string;
|
||||
};
|
||||
} & KnownConfig;
|
||||
|
||||
export type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
export type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
|
||||
|
||||
@@ -438,7 +438,7 @@ export const getOptions = ({
|
||||
encode: async ({ token, maxAge, secret }) => {
|
||||
log.debug("jwt:encode", safeStringify({ token, maxAge }));
|
||||
if (token?.sub && isNumber(token.sub)) {
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: Number(token.sub) },
|
||||
select: { metadata: true },
|
||||
});
|
||||
@@ -489,7 +489,7 @@ export const getOptions = ({
|
||||
} as JWT;
|
||||
}
|
||||
const autoMergeIdentities = async () => {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
where: { email: token.email! },
|
||||
select: {
|
||||
@@ -855,7 +855,7 @@ export const getOptions = ({
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
// with the new email address. If it does, for now we return an error. If
|
||||
// not, update the email of their account and log them in.
|
||||
const userWithNewEmail = await prisma.user.findFirst({
|
||||
const userWithNewEmail = await prisma.user.findUnique({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
@@ -875,12 +875,9 @@ export const getOptions = ({
|
||||
// a new account. If an account already exists with the incoming email
|
||||
// address return an error for now.
|
||||
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
const existingUserWithEmail = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: {
|
||||
equals: user.email,
|
||||
mode: "insensitive",
|
||||
},
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
password: true,
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function isAuthorized(token: string, requiredScopes: string
|
||||
}
|
||||
|
||||
if (decodedToken.userId) {
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: decodedToken.userId,
|
||||
},
|
||||
@@ -35,7 +35,7 @@ export default async function isAuthorized(token: string, requiredScopes: string
|
||||
}
|
||||
|
||||
if (decodedToken.teamId) {
|
||||
const team = await prisma.team.findFirst({
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: decodedToken.teamId,
|
||||
},
|
||||
|
||||
@@ -83,11 +83,12 @@ const usernameCheck = async (usernameRaw: string) => {
|
||||
|
||||
const username = slugify(usernameRaw);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
username,
|
||||
// Simply remove it when we drop organizationId column
|
||||
organizationId: null,
|
||||
username_organizationId: {
|
||||
username,
|
||||
organizationId: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const prefillAvatar = async ({ email }: IPrefillAvatar) => {
|
||||
if (!base64Image) return;
|
||||
|
||||
const avatar = await resizeBase64Image(base64Image);
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: email },
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { validateAndGetCorrectedUsernameInTeam } from "@calcom/lib/validateUsern
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export async function findTokenByToken({ token }: { token: string }) {
|
||||
const foundToken = await prisma.verificationToken.findFirst({
|
||||
const foundToken = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ function getResponsesFromOldBooking(
|
||||
}
|
||||
|
||||
async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) {
|
||||
const rawBooking = await prisma.booking.findFirst({
|
||||
const rawBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
@@ -125,7 +125,7 @@ export default getBooking;
|
||||
|
||||
export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
let rescheduleUid: string | null = null;
|
||||
const theBooking = await prisma.booking.findFirst({
|
||||
const theBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
@@ -226,7 +226,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
* @returns booking with masked attendee emails
|
||||
*/
|
||||
export const getBookingForSeatedEvent = async (uid: string) => {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
@@ -253,7 +253,7 @@ export const getBookingForSeatedEvent = async (uid: string) => {
|
||||
if (!booking || booking.eventTypeId === null) return null;
|
||||
|
||||
// Validate booking event type has seats enabled
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
id: booking.eventTypeId,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const getUserBooking = async (uid: string) => {
|
||||
const bookingInfo = await prisma.booking.findFirst({
|
||||
const bookingInfo = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: uid,
|
||||
},
|
||||
|
||||
@@ -154,7 +154,7 @@ async function handler(input: CancelBookingInput) {
|
||||
|
||||
const webhooks = await getWebhooks(subscriberOptions);
|
||||
|
||||
const organizer = await prisma.user.findFirstOrThrow({
|
||||
const organizer = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: bookingToDelete.userId,
|
||||
},
|
||||
|
||||
@@ -500,7 +500,7 @@ export async function handleConfirmation(args: {
|
||||
orgId,
|
||||
oAuthClientId: platformClientParams?.platformClientId,
|
||||
});
|
||||
const bookingWithPayment = await prisma.booking.findFirst({
|
||||
const bookingWithPayment = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[checkActiveBookingsLimitForBooker]"] });
|
||||
|
||||
export const checkActiveBookingsLimitForBooker = async ({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker,
|
||||
@@ -57,6 +60,7 @@ const checkActiveBookingsLimit = async ({
|
||||
});
|
||||
|
||||
if (bookingsCount >= maxActiveBookingsPerBooker) {
|
||||
log.warn(`Maximum booking limit reached for ${bookerEmail} for event type ${eventTypeId}`);
|
||||
throw new ErrorWithCode(ErrorCode.BookerLimitExceeded, ErrorCode.BookerLimitExceeded);
|
||||
}
|
||||
};
|
||||
@@ -104,6 +108,7 @@ const checkActiveBookingsLimitAndOfferReschedule = async ({
|
||||
const lastBooking = bookingsCount[bookingsCount.length - 1];
|
||||
|
||||
if (bookingsCount.length >= maxActiveBookingsPerBooker) {
|
||||
log.warn(`Maximum booking limit reached for ${bookerEmail} for event type ${eventTypeId}`);
|
||||
throw new ErrorWithCode(
|
||||
ErrorCode.BookerLimitExceededReschedule,
|
||||
ErrorCode.BookerLimitExceededReschedule,
|
||||
|
||||
@@ -136,7 +136,7 @@ const handleDeleteCredential = async ({
|
||||
credential.app?.categories.includes(AppCategories.calendar) &&
|
||||
eventType.destinationCalendar?.credential?.appId === credential.appId
|
||||
) {
|
||||
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||
const destinationCalendar = await prisma.destinationCalendar.findUnique({
|
||||
where: {
|
||||
id: eventType.destinationCalendar?.id,
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ async function checkLicense(
|
||||
let licenseKey = process.env.CALCOM_LICENSE_KEY;
|
||||
if (!licenseKey) {
|
||||
/** We try to check on DB only if env is undefined */
|
||||
const deployment = await prisma.deployment.findFirst({ where: { id: 1 } });
|
||||
const deployment = await prisma.deployment.findUnique({ where: { id: 1 } });
|
||||
licenseKey = deployment?.licenseKey ?? undefined;
|
||||
}
|
||||
if (!licenseKey) return false;
|
||||
|
||||
@@ -33,7 +33,7 @@ async function syncCustomAttributesToUser({
|
||||
};
|
||||
directoryId: string;
|
||||
}) {
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: userEmail,
|
||||
},
|
||||
@@ -62,7 +62,7 @@ const handleUserEvents = async (event: DirectorySyncEvent, organizationId: numbe
|
||||
const eventData = event.data as User;
|
||||
const userEmail = eventData.email;
|
||||
// Check if user exists in DB
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: userEmail,
|
||||
},
|
||||
|
||||
@@ -25,10 +25,12 @@ export const getServerSideProps = async ({ req }: GetServerSidePropsContext) =>
|
||||
}
|
||||
|
||||
// Check if logged in user has OWNER/ADMIN role in organization
|
||||
const membership = await prisma.membership.findFirst({
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId: session?.user.id,
|
||||
teamId: session?.user.profile.organizationId,
|
||||
userId_teamId: {
|
||||
userId: session?.user.id,
|
||||
teamId: session?.user.profile.organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { uid } = querySchema.parse(context.query);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const rawPayment = await prisma.payment.findFirst({
|
||||
const rawPayment = await prisma.payment.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ export default class AssignmentReasonRecorder {
|
||||
teamId: number;
|
||||
}) {
|
||||
// Get the routing form data
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
where: {
|
||||
id: routingFormResponseId,
|
||||
},
|
||||
@@ -186,7 +186,7 @@ export default class AssignmentReasonRecorder {
|
||||
reassignReason?: string;
|
||||
reassignmentType: RRReassignmentType;
|
||||
}) {
|
||||
const reassignedBy = await prisma.user.findFirst({
|
||||
const reassignedBy = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: reassignById,
|
||||
},
|
||||
|
||||
@@ -239,7 +239,7 @@ export const getMessageBody = async (referenceId: string) => {
|
||||
|
||||
async function isLockedForSMSSending(userId?: number | null, teamId?: number | null) {
|
||||
if (teamId) {
|
||||
const team = await prisma.team.findFirst({
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
@@ -269,7 +269,7 @@ async function isLockedForSMSSending(userId?: number | null, teamId?: number | n
|
||||
return true;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
|
||||
@@ -119,7 +119,7 @@ const processWorkflowStep = async (
|
||||
const limitGuestsDate = new Date("2025-01-13");
|
||||
|
||||
if (workflow.userId) {
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: workflow.userId,
|
||||
},
|
||||
|
||||
@@ -339,58 +339,54 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCalVideo && (
|
||||
{isCalVideo && !isPlatform && (
|
||||
<div className="bg-muted mt-2 space-y-2 rounded-lg p-4">
|
||||
<div className="w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.disableRecordingForGuests"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableRecordingForGuests}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_recording_for_guests")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.disableRecordingForOrganizer"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableRecordingForOrganizer}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_recording_for_organizer")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.enableAutomaticTranscription"
|
||||
defaultValue={!!eventType.calVideoSettings?.enableAutomaticTranscription}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("enable_automatic_transcription")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name="calVideoSettings.disableRecordingForGuests"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableRecordingForGuests}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_recording_for_guests")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="calVideoSettings.disableRecordingForOrganizer"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableRecordingForOrganizer}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_recording_for_organizer")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="calVideoSettings.enableAutomaticTranscription"
|
||||
defaultValue={!!eventType.calVideoSettings?.enableAutomaticTranscription}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("enable_automatic_transcription")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={t("enter_redirect_url_on_exit_description")}
|
||||
|
||||
@@ -910,6 +910,8 @@ export const EventAdvancedTab = ({
|
||||
disabled={seatsLocked.disabled}
|
||||
//For old events if value > MAX_SEATS_PER_TIME_SLOT
|
||||
value={value > MAX_SEATS_PER_TIME_SLOT ? MAX_SEATS_PER_TIME_SLOT : value ?? 1}
|
||||
step={1}
|
||||
placeholder="1"
|
||||
min={1}
|
||||
max={MAX_SEATS_PER_TIME_SLOT}
|
||||
containerClassName={classNames(
|
||||
@@ -921,8 +923,7 @@ export const EventAdvancedTab = ({
|
||||
labelClassName={customClassNames?.seatsOptions?.seatsInput?.label}
|
||||
addOnSuffix={t("seats")}
|
||||
onChange={(e) => {
|
||||
let enteredValue = Number(e.target.value);
|
||||
if (enteredValue < 1) enteredValue = 1;
|
||||
const enteredValue = parseInt(e.target.value);
|
||||
onChange(Math.min(enteredValue, MAX_SEATS_PER_TIME_SLOT));
|
||||
}}
|
||||
data-testid="seats-per-time-slot"
|
||||
|
||||
@@ -170,10 +170,12 @@ export class FeaturesRepository implements IFeaturesRepository {
|
||||
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
|
||||
try {
|
||||
// Early return if team has feature directly assigned
|
||||
const teamHasFeature = await db.teamFeatures.findFirst({
|
||||
const teamHasFeature = await db.teamFeatures.findUnique({
|
||||
where: {
|
||||
teamId,
|
||||
featureId,
|
||||
teamId_featureId: {
|
||||
teamId,
|
||||
featureId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (teamHasFeature) return true;
|
||||
|
||||
@@ -625,10 +625,12 @@ class EventsInsights {
|
||||
sessionUserId: number;
|
||||
teamId: number;
|
||||
}) => {
|
||||
const isOwnerAdminOfTeam = await prisma.membership.findFirst({
|
||||
const isOwnerAdminOfTeam = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId: sessionUserId,
|
||||
teamId,
|
||||
userId_teamId: {
|
||||
userId: sessionUserId,
|
||||
teamId,
|
||||
},
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["OWNER", "ADMIN"],
|
||||
@@ -659,10 +661,12 @@ class EventsInsights {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isOwnerAdminOfParentTeam = await prisma.membership.findFirst({
|
||||
const isOwnerAdminOfParentTeam = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId: sessionUserId,
|
||||
teamId: team.parentId,
|
||||
userId_teamId: {
|
||||
userId: sessionUserId,
|
||||
teamId: team.parentId,
|
||||
},
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["OWNER", "ADMIN"],
|
||||
|
||||
@@ -16,7 +16,7 @@ export default async function handleSendingAttendeeNoShowDataToApps(
|
||||
attendees: NoShowAttendees
|
||||
) {
|
||||
// Get event type metadata
|
||||
const eventTypeQuery = await prisma.booking.findFirst({
|
||||
const eventTypeQuery = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
@@ -62,7 +62,7 @@ async function handleCRMNoShow(
|
||||
) {
|
||||
// Handle checking if no she in CrmService
|
||||
|
||||
const credential = await prisma.credential.findFirst({
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: appData.credentialId,
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export const useSchedule = ({
|
||||
const _shouldServeCache = _cacheParam ? _cacheParam === "true" : undefined;
|
||||
const utils = trpc.useUtils();
|
||||
const routingFormResponseIdParam = searchParams?.get("cal.routingFormResponseId");
|
||||
const queuedFormResponseId = searchParams?.get("cal.queuedFormResponseId");
|
||||
const email = searchParams?.get("email");
|
||||
// We allow skipping the schedule fetch as a requirement for prerendering in iframe through embed as when the pre-rendered iframe is connected, then we would fetch the availability, which would be upto-date
|
||||
// Also, a reuse through Headless Router could completely change the availability as different team members are selected and thus it is unnecessary to fetch the schedule
|
||||
@@ -73,7 +74,7 @@ export const useSchedule = ({
|
||||
const routingFormResponseId = routingFormResponseIdParam
|
||||
? parseInt(routingFormResponseIdParam, 10)
|
||||
: undefined;
|
||||
const embedConnectVersion = searchParams?.get("cal.embed.connectVersion") || "";
|
||||
const embedConnectVersion = searchParams?.get("cal.embed.connectVersion") || "0";
|
||||
const input = {
|
||||
isTeamEvent,
|
||||
usernameList: getUsernameList(username ?? ""),
|
||||
@@ -94,7 +95,7 @@ export const useSchedule = ({
|
||||
routedTeamMemberIds,
|
||||
skipContactOwner,
|
||||
_shouldServeCache,
|
||||
routingFormResponseId,
|
||||
...(queuedFormResponseId ? { queuedFormResponseId } : { routingFormResponseId }),
|
||||
email,
|
||||
// Ensures that connectVersion causes a refresh of the data
|
||||
...(embedConnectVersion ? { embedConnectVersion } : {}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type Prisma } from "@prisma/client";
|
||||
import db from "@calcom/prisma";
|
||||
|
||||
import { type TaskTypes } from "./tasker";
|
||||
import { scanWorkflowBodySchema } from "./tasks/scanWorkflowBody";
|
||||
|
||||
const whereSucceeded: Prisma.TaskWhereInput = {
|
||||
succeededAt: { not: null },
|
||||
@@ -187,4 +188,24 @@ export class Task {
|
||||
// },
|
||||
// });
|
||||
}
|
||||
|
||||
static async hasNewerScanTaskForStepId(workflowStepId: number, createdAt: string) {
|
||||
const tasks = await db.$queryRaw<{ payload: string }[]>`
|
||||
SELECT "payload"
|
||||
FROM "Task"
|
||||
WHERE "type" = 'scanWorkflowBody'
|
||||
AND "succeededAt" IS NULL
|
||||
AND (payload::jsonb ->> 'workflowStepId')::int = ${workflowStepId}
|
||||
`;
|
||||
|
||||
return tasks.some((task) => {
|
||||
try {
|
||||
const parsed = scanWorkflowBodySchema.parse(JSON.parse(task.payload));
|
||||
if (!parsed.createdAt) return false;
|
||||
return new Date(parsed.createdAt) > new Date(createdAt);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
const buildCalendarEvent: (bookingUid: string) => Promise<CalendarEvent> = async (bookingUid: string) => {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from "zod";
|
||||
|
||||
import { getTemplateBodyForAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import compareReminderBodyToTemplate from "@calcom/features/ee/workflows/lib/compareReminderBodyToTemplate";
|
||||
import { Task } from "@calcom/features/tasker/repository";
|
||||
import { lockUser, LockReason } from "@calcom/lib/autoLock";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
@@ -11,18 +12,39 @@ import { scheduleWorkflowNotifications } from "@calcom/trpc/server/routers/viewe
|
||||
|
||||
export const scanWorkflowBodySchema = z.object({
|
||||
userId: z.number(),
|
||||
workflowStepIds: z.array(z.number()),
|
||||
// deprecated: use workflowStepId instead
|
||||
workflowStepIds: z.array(z.number()).optional(),
|
||||
workflowStepId: z.number().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["[tasker] scanWorkflowBody"] });
|
||||
|
||||
export async function scanWorkflowBody(payload: string) {
|
||||
const { workflowStepIds, userId } = scanWorkflowBodySchema.parse(JSON.parse(payload));
|
||||
const { workflowStepIds, userId, createdAt, workflowStepId } = scanWorkflowBodySchema.parse(
|
||||
JSON.parse(payload)
|
||||
);
|
||||
|
||||
const stepIdsToScan: number[] = workflowStepIds ? workflowStepIds : [];
|
||||
|
||||
if (workflowStepId && !workflowStepIds) {
|
||||
if (createdAt) {
|
||||
const hasNewerTask = await Task.hasNewerScanTaskForStepId(workflowStepId, createdAt);
|
||||
|
||||
if (hasNewerTask) return;
|
||||
|
||||
stepIdsToScan.push(workflowStepId);
|
||||
} else {
|
||||
stepIdsToScan.push(workflowStepId);
|
||||
}
|
||||
}
|
||||
|
||||
if (stepIdsToScan.length === 0) return;
|
||||
|
||||
const workflowSteps = await prisma.workflowStep.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
in: stepIdsToScan,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -116,7 +138,7 @@ export async function scanWorkflowBody(payload: string) {
|
||||
await prisma.workflowStep.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
in: stepIdsToScan,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
@@ -130,7 +152,7 @@ export async function scanWorkflowBody(payload: string) {
|
||||
steps: {
|
||||
some: {
|
||||
id: {
|
||||
in: workflowStepIds,
|
||||
in: stepIdsToScan,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -142,7 +164,7 @@ export async function scanWorkflowBody(payload: string) {
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
log.warn(`Workflow with steps ${workflowStepIds} not found`);
|
||||
log.warn(`Workflow with steps ${stepIdsToScan} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "@calcom/lib/csvUtils";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -109,8 +110,18 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState
|
||||
export type UserListTableProps = {
|
||||
org: RouterOutputs["viewer"]["organizations"]["listCurrent"];
|
||||
teams: RouterOutputs["viewer"]["organizations"]["getTeams"];
|
||||
facetedTeamValues?: RouterOutputs["viewer"]["organizations"]["getFacetedValues"];
|
||||
attributes?: RouterOutputs["viewer"]["attributes"]["list"];
|
||||
facetedTeamValues?: {
|
||||
roles: MembershipRole[];
|
||||
teams: RouterOutputs["viewer"]["organizations"]["getTeams"];
|
||||
attributes: {
|
||||
id: string;
|
||||
name: string;
|
||||
options: {
|
||||
value: string;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export function UserListTable(props: UserListTableProps) {
|
||||
|
||||
@@ -186,7 +186,7 @@ export default class EventManager {
|
||||
// Fallback to cal video if no location is set
|
||||
if (!evt.location) {
|
||||
// See if cal video is enabled & has keys
|
||||
const calVideo = await prisma.app.findFirst({
|
||||
const calVideo = await prisma.app.findUnique({
|
||||
where: {
|
||||
slug: "daily-video",
|
||||
},
|
||||
@@ -461,7 +461,7 @@ export default class EventManager {
|
||||
}
|
||||
|
||||
// Get details of existing booking.
|
||||
const booking = await prisma.booking.findFirst({
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid: rescheduleUid,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ export const bulkUpdateTeamEventsToDefaultLocation = async ({
|
||||
teamId: number;
|
||||
prisma: PrismaClient;
|
||||
}) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { metadata: true },
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ function getResponsesFromOldBooking(
|
||||
}
|
||||
|
||||
async function getBooking(prisma: PrismaClient, uid: string) {
|
||||
const rawBooking = await prisma.booking.findFirst({
|
||||
const rawBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user