Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64bfda9dbe | ||
|
|
aacbbc1785 | ||
|
|
3714d5faab | ||
|
|
12cb94950a | ||
|
|
795fd788e2 | ||
|
|
956fd6ae2f | ||
|
|
04e60611ed | ||
|
|
bc99e9e504 | ||
|
|
2f35428232 | ||
|
|
00aac89ed7 | ||
|
|
f390e91bd7 | ||
|
|
e413359c8c | ||
|
|
601df7f808 | ||
|
|
e160a4add0 | ||
|
|
3bea03f4b1 | ||
|
|
19dbde8a3c | ||
|
|
a5fe10189f | ||
|
|
7075384313 | ||
|
|
25bf43cddd | ||
|
|
2880a681a8 | ||
|
|
bbcd0b7e78 | ||
|
|
916ed13bea | ||
|
|
11a06324be | ||
|
|
bcb340d97e | ||
|
|
aabef47ff1 | ||
|
|
e27de47d9f | ||
|
|
7900be3a30 | ||
|
|
ddc5838c7b | ||
|
|
6a13d7c5c6 | ||
|
|
4f0b35d9e2 | ||
|
|
3b19ede6a3 | ||
|
|
1a0ce47404 | ||
|
|
22f136d19b | ||
|
|
9a40e54159 | ||
|
|
229f2627c8 |
@@ -44,7 +44,7 @@ jobs:
|
||||
git push
|
||||
|
||||
- name: Draft release
|
||||
run: gh release create v$VERSION --generate-notes --draft
|
||||
run: gh release create v$VERSION --generate-notes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.next_version }}
|
||||
|
||||
Vendored
Vendored
Vendored
Vendored
@@ -343,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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,6 +54,23 @@ import {
|
||||
} from "@calcom/platform-constants";
|
||||
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||
|
||||
export interface CalendarState {
|
||||
accessToken: string;
|
||||
origin: string;
|
||||
redir?: string;
|
||||
isDryRun?: boolean;
|
||||
}
|
||||
|
||||
const calendarStateSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().optional(),
|
||||
isDryRun: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => val === "true"),
|
||||
});
|
||||
|
||||
@Controller({
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -181,32 +198,31 @@ export class CalendarsController {
|
||||
@Query("code") code: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir, isDryRun } = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().nullish().optional(),
|
||||
isDryRun: z.string().nullish().optional(),
|
||||
})
|
||||
.parse({
|
||||
let stateObj: CalendarState;
|
||||
|
||||
try {
|
||||
// First try to parse as JSON
|
||||
stateObj = JSON.parse(state) as CalendarState;
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, try URL params
|
||||
const stateParams = new URLSearchParams(state);
|
||||
|
||||
const parsedState = calendarStateSchema.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
redir: stateParams.get("redir") || undefined,
|
||||
isDryRun: stateParams.get("isDryRun"),
|
||||
});
|
||||
|
||||
stateObj = parsedState;
|
||||
}
|
||||
|
||||
const { accessToken, origin, redir, isDryRun } = stateObj;
|
||||
switch (calendar) {
|
||||
case OFFICE_365_CALENDAR:
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
|
||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||
case GOOGLE_CALENDAR:
|
||||
return await this.googleCalendarService.save(
|
||||
code,
|
||||
accessToken,
|
||||
origin,
|
||||
redir ?? "",
|
||||
isDryRun === "true"
|
||||
);
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import type { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
@@ -65,12 +66,18 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
const state: CalendarState = {
|
||||
accessToken,
|
||||
origin,
|
||||
redir,
|
||||
isDryRun,
|
||||
};
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
state: JSON.stringify(state),
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
@@ -60,6 +61,13 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||
|
||||
const state: CalendarState = {
|
||||
accessToken,
|
||||
origin,
|
||||
redir,
|
||||
isDryRun,
|
||||
};
|
||||
|
||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||
const params = {
|
||||
response_type: "code",
|
||||
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
state: JSON.stringify(state),
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -211,6 +211,18 @@ export class CalendarEventHost {
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
nullable: true,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
description: "Host's response to the invitation",
|
||||
example: CalendarEventResponseStatus.ACCEPTED,
|
||||
})
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
|
||||
+20
-20
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
|
||||
}
|
||||
}
|
||||
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
|
||||
if (googleEvent.organizer) {
|
||||
return [
|
||||
{
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
},
|
||||
];
|
||||
} else if (googleEvent.attendees) {
|
||||
// If no explicit organizer, find the first attendee with organizer flag
|
||||
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
|
||||
if (organizerAttendee) {
|
||||
return [
|
||||
{
|
||||
email: organizerAttendee.email,
|
||||
name: organizerAttendee.displayName,
|
||||
},
|
||||
];
|
||||
}
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse) {
|
||||
const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
|
||||
[];
|
||||
|
||||
const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
|
||||
if (organizerAttendees?.length) {
|
||||
organizerAttendees.forEach((organizer) => {
|
||||
hosts.push({
|
||||
email: organizer.email,
|
||||
name: organizer.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
hosts.push({
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
responseStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
return hosts;
|
||||
}
|
||||
|
||||
private transformLocations(
|
||||
|
||||
@@ -26400,7 +26400,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -26460,6 +26460,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -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);
|
||||
@@ -105,7 +105,7 @@ const CustomEmailTextField = ({
|
||||
<DropdownItem
|
||||
StartIcon="trash"
|
||||
color="destructive"
|
||||
className="disabled:opacity-40"
|
||||
className="rounded-t-none disabled:opacity-40"
|
||||
onClick={handleItemDelete}
|
||||
disabled={emailPrimary}
|
||||
data-testid="secondary-email-delete-button">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||
@@ -11,13 +12,15 @@ export const getTemporaryOrgRedirect = async ({
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
prismaClient,
|
||||
}: {
|
||||
slugs: string[] | string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
prismaClient?: PrismaClient;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
const prisma = prismaClient ?? (await import("@calcom/prisma")).default;
|
||||
slugs = slugs instanceof Array ? slugs : [slugs];
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
orgDomainConfig,
|
||||
whereClauseForOrgWithSlugOrRequestedSlug,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
@@ -30,7 +32,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
@@ -41,6 +43,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
prismaClient: prisma,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
@@ -48,43 +51,49 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
}
|
||||
|
||||
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||
const [orgId, booking] = await Promise.all([
|
||||
isOrgContext ? getOrgId(currentOrgDomain) : Promise.resolve(null),
|
||||
rescheduleUid ? getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (!team || !team.eventTypes?.[0]) {
|
||||
const team = await getTeamData(teamSlug, orgId);
|
||||
|
||||
if (!team) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
const eventData = team.eventTypes[0];
|
||||
const eventData = await getEventTypeData(meetingSlug, team.id);
|
||||
|
||||
if (!eventData) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
if (rescheduleUid && eventData.disableRescheduling) {
|
||||
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
|
||||
}
|
||||
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypeId = eventData.id;
|
||||
const eventHostsUserData = await getUsersData(
|
||||
team.isPrivate,
|
||||
eventTypeId,
|
||||
eventData.hosts.map((h) => h.user)
|
||||
);
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const name = team.parent?.name ?? team.name ?? null;
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const eventHostsUserData = getEventHosts(
|
||||
team.isPrivate,
|
||||
eventData.hosts.map((h) => h.user),
|
||||
eventData.users ?? []
|
||||
);
|
||||
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
@@ -95,11 +104,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Handle string[] type from query params
|
||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||
|
||||
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
||||
? crmContactOwnerRecordType[0]
|
||||
: crmContactOwnerRecordType;
|
||||
|
||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
@@ -122,7 +129,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
@@ -172,116 +178,149 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
};
|
||||
|
||||
const getTeamWithEventsData = async (
|
||||
teamSlug: string,
|
||||
meetingSlug: string,
|
||||
isValidOrgDomain: boolean,
|
||||
currentOrgDomain: string | null
|
||||
) => {
|
||||
const getOrgId = async (orgSlug: string): Promise<number | null> => {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org?.id ?? null;
|
||||
};
|
||||
|
||||
const getTeamData = async (teamSlug: string, orgId: number | null) => {
|
||||
const teamSelectFields = {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
isOrganization: true,
|
||||
parent: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (orgId !== null) {
|
||||
const publishedTeam = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug_parentId: {
|
||||
slug: teamSlug,
|
||||
parentId: orgId,
|
||||
},
|
||||
isOrganization: false,
|
||||
},
|
||||
select: teamSelectFields,
|
||||
});
|
||||
if (publishedTeam) return publishedTeam;
|
||||
}
|
||||
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
parentId: orgId ?? null,
|
||||
isOrganization: false,
|
||||
OR: [
|
||||
{ slug: teamSlug },
|
||||
{
|
||||
metadata: {
|
||||
path: ["requestedSlug"],
|
||||
equals: teamSlug,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: teamSelectFields,
|
||||
orderBy: {
|
||||
slug: { sort: "asc", nulls: "last" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getEventTypeData = async (meetingSlug: string, teamId: number) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
where: {
|
||||
// Use the EventType_teamId_slug_key unique index
|
||||
teamId_slug: {
|
||||
teamId: teamId,
|
||||
slug: meetingSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
parent: {
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
slug: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Include users for when hosts is empty
|
||||
users: {
|
||||
take: 1,
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
slug: meetingSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getUsersData = async (
|
||||
const getEventHosts = (
|
||||
isPrivateTeam: boolean,
|
||||
eventTypeId: number,
|
||||
hosts: Pick<User, "username" | "name">[],
|
||||
users: Pick<User, "username" | "name">[]
|
||||
) => {
|
||||
if (!isPrivateTeam && users.length > 0) {
|
||||
return users
|
||||
if (isPrivateTeam) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (hosts.length > 0) {
|
||||
return hosts
|
||||
.filter((user) => user.username)
|
||||
.map((user) => ({
|
||||
username: user.username ?? "",
|
||||
name: user.name ?? "",
|
||||
}));
|
||||
}
|
||||
if (!isPrivateTeam && users.length === 0) {
|
||||
const { users: data } = await prisma.eventType.findUniqueOrThrow({
|
||||
where: { id: eventTypeId },
|
||||
select: {
|
||||
users: {
|
||||
take: 1,
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return data.length > 0
|
||||
? [
|
||||
{
|
||||
username: data[0].username ?? "",
|
||||
name: data[0].name ?? "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (users.length > 0) {
|
||||
return [
|
||||
{
|
||||
username: users[0].username ?? "",
|
||||
name: users[0].name ?? "",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -23,6 +23,8 @@ type CalVideoSettings = {
|
||||
disableRecordingForGuests: boolean;
|
||||
disableRecordingForOrganizer: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
disableTranscriptionForGuests: boolean;
|
||||
disableTranscriptionForOrganizer: boolean;
|
||||
};
|
||||
|
||||
const shouldEnableRecordButton = ({
|
||||
@@ -57,6 +59,25 @@ const shouldEnableAutomaticTranscription = ({
|
||||
return !!calVideoSettings.enableAutomaticTranscription;
|
||||
};
|
||||
|
||||
const shouldEnableTranscriptionButton = ({
|
||||
hasTeamPlan,
|
||||
calVideoSettings,
|
||||
isOrganizer,
|
||||
}: {
|
||||
hasTeamPlan: boolean;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
isOrganizer: boolean;
|
||||
}) => {
|
||||
if (!hasTeamPlan) return false;
|
||||
if (!calVideoSettings) return true;
|
||||
|
||||
if (isOrganizer) {
|
||||
return !calVideoSettings.disableTranscriptionForOrganizer;
|
||||
}
|
||||
|
||||
return !calVideoSettings.disableTranscriptionForGuests;
|
||||
};
|
||||
|
||||
const checkIfUserIsHost = async ({
|
||||
booking,
|
||||
sessionUserId,
|
||||
@@ -222,6 +243,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
});
|
||||
const showTranscriptionButton = shouldEnableTranscriptionButton({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -245,6 +271,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit: isOrganizer
|
||||
? undefined
|
||||
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
||||
|
||||
@@ -234,7 +234,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
orgSlug: currentOrgDomain,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
profile,
|
||||
eventType,
|
||||
|
||||
@@ -546,7 +546,7 @@ export default function Success(props: PageProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
|
||||
@@ -653,7 +653,7 @@ export const InfiniteEventTypeList = ({
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
className="w-full rounded-t-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -750,7 +750,7 @@ export const InfiniteEventTypeList = ({
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
className="w-full rounded-t-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -73,9 +73,11 @@ export interface DailyCustomTrayButton {
|
||||
export const CalAiTranscribe = ({
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
}: {
|
||||
showRecordingButton: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
showTranscriptionButton: boolean;
|
||||
}) => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
@@ -101,7 +103,11 @@ export const CalAiTranscribe = ({
|
||||
recording,
|
||||
}
|
||||
: {}),
|
||||
transcription,
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
|
||||
overrideName,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit,
|
||||
} = props;
|
||||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||||
@@ -80,12 +81,16 @@ export default function JoinCall(props: PageProps) {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -114,6 +119,7 @@ export default function JoinCall(props: PageProps) {
|
||||
<CalAiTranscribe
|
||||
showRecordingButton={showRecordingButton}
|
||||
enableAutomaticTranscription={enableAutomaticTranscription}
|
||||
showTranscriptionButton={showTranscriptionButton}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ zIndex: 2, position: "relative" }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.1",
|
||||
"version": "5.4.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"disable_recording_for_organizer": "Disable recording for organizer",
|
||||
"disable_transcription_for_guests": "Disable transcription for guests",
|
||||
"disable_transcription_for_organizer": "Disable transcription for organizer",
|
||||
"event_request_declined_recurring": "Your recurring event request has been declined",
|
||||
"event_request_cancelled": "Your scheduled event was canceled",
|
||||
"event_request_reassigned": "Your scheduled event was reassigned",
|
||||
@@ -3238,6 +3240,10 @@
|
||||
"requires_credits": "Requires credits",
|
||||
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
||||
"available_credits": "Available credits",
|
||||
"choose_restriction_schedule": "Add restriction schedule",
|
||||
"choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.",
|
||||
"use_booker_timezone": "Use booker's timezone",
|
||||
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
||||
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
||||
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
||||
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
||||
@@ -3251,5 +3257,8 @@
|
||||
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
|
||||
"duration_min_error": "Duration must be at least {{min}} minute(s)",
|
||||
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
|
||||
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -66,18 +66,18 @@
|
||||
"rejection_reason_title": "Odbij zahtev za rezervaciju?",
|
||||
"rejection_reason_description": "Da li ste sigurni da želite da odbijete rezervaciju? Obavestićemo osobu koja je pokušala da rezerviše. Možete navesti razlog ispod.",
|
||||
"rejection_confirmation": "Odbij rezervaciju",
|
||||
"manage_this_event": "Upravljaj ovim dogadjajem",
|
||||
"manage_this_event": "Upravljaj ovim događajem",
|
||||
"invite_team_member": "Pozovite člana tima",
|
||||
"invite_team_individual_segment": "Pozovite jednu osobu",
|
||||
"invite_team_bulk_segment": "Pozovite grupno",
|
||||
"invite_team_notifcation_badge": "Poz.",
|
||||
"your_event_has_been_scheduled": "Vaš dogadjaj je odložen",
|
||||
"your_event_has_been_scheduled": "Vaš događaj je zakazan",
|
||||
"your_event_has_been_scheduled_recurring": "Vaš ponavljajući događaj je zakazan",
|
||||
"accept_our_license": "Prihvatite našu dozvolu tako što ćete promeniti .env varijablu <1>NEXT_PUBLIC_LICENSE_CONSENT</1> u '{{agree}}'.",
|
||||
"remove_banner_instructions": "Da uklonite baner, molim vas da otvorite vaš .env file i promenite <1>NEXT_PUBLIC_LICENSE_CONSENT</1> varijablu u '{{agree}}'.",
|
||||
"error_message": "Poruka o grešci je bila: '{{errorMessage}}'",
|
||||
"refund_failed_subject": "Povraćaj novca neuspešan: {{name}} - {{date}} - {{eventType}}",
|
||||
"refund_failed": "Povraćaj novca za dogadjaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
||||
"refund_failed": "Povraćaj novca za događaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
||||
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
|
||||
"a_refund_failed": "Povraćaj novca neuspešan",
|
||||
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
|
||||
@@ -115,23 +115,23 @@
|
||||
"open_env": "Otvorite .env i pristanite na našu Licencu",
|
||||
"env_changed": "Promenio/la sam moj .env",
|
||||
"accept_license": "Prihvati Licencu",
|
||||
"still_waiting_for_approval": "Dogadjaj još čeka na odobrenje",
|
||||
"event_is_still_waiting": "Zahtev na dogadjaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"still_waiting_for_approval": "Događaj još čeka na odobrenje",
|
||||
"event_is_still_waiting": "Zahtev na događaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"no_result_found_for": "Nema rezultata za \"{{searchTerm}}\"",
|
||||
"no_more_results": "Nema više rezultata",
|
||||
"no_results": "Nema rezultata",
|
||||
"load_more_results": "Učitaj više rezultata",
|
||||
"integration_meeting_id": "{{integrationName}} ID sastanka: {{meetingId}}",
|
||||
"confirmed_event_type_subject": "Potvrdjeno: {{eventType}} sa {{name}} datuma {{date}}",
|
||||
"new_event_request": "Novi zahtev na dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"new_event_request": "Novi zahtev na događaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"confirm_or_reject_request": "Potvrdite ili odbijte zahtev",
|
||||
"check_bookings_page_to_confirm_or_reject": "Proverite stranicu rezervacija da potvrdite ili odbijete rezervaciju.",
|
||||
"check_in_assistant": "Asistent za prijavu",
|
||||
"check_in_assistant_description": "Obavlja odlazni poziv da proveri da li zakazani termin odgovara ili želite da ga pomerite.",
|
||||
"create_your_own_prompt": "Kreirajte sopstveni upit i koristite ga za odlazne pozive.",
|
||||
"event_awaiting_approval": "Dogadjaj čeka vaše odobrenje",
|
||||
"event_awaiting_approval": "Događaj čeka vaše odobrenje",
|
||||
"event_awaiting_approval_recurring": "Ponavljajući događaj čeka na vaše odobrenje",
|
||||
"someone_requested_an_event": "Neko zahteva da rezerviše dogadjaj na vašem kalendaru.",
|
||||
"someone_requested_an_event": "Neko zahteva da rezerviše događaj na vašem kalendaru.",
|
||||
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
|
||||
"password_reset_email_sent": "Ako ovaj imejl postoji našem sistemu, trebalo bi da primite imejl za resetovanje.",
|
||||
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
|
||||
@@ -145,7 +145,7 @@
|
||||
"your_meeting_has_been_booked": "Sastanak je zakazan",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
|
||||
"event_type_has_been_updated": "Vaš {{title}} je ažuriran.",
|
||||
"event_has_been_rescheduled": "Ažurirano - Vaš dogadjaj je odložen",
|
||||
"event_has_been_rescheduled": "Ažurirano - Vaš događaj je odložen",
|
||||
"event_has_been_updated": "Događaj je ažuriran",
|
||||
"your_event_has_been_rescheduled": "Vaš događaj je ponovo zakazan",
|
||||
"request_reschedule_subtitle": "{{organizer}} je otkazao/la rezervaciju i tražio/la je da odaberete drugo vreme.",
|
||||
@@ -157,17 +157,17 @@
|
||||
"ics_event_title": "{{eventType}} sa {{name}}",
|
||||
"please_book_a_time_sometime_later": "Izgleda da su svi naši stručnjaci trenutno zauzeti. Molimo vas da zakažete poziv u vreme koje vam najviše odgovara i bićemo spremni da vam pomognemo.",
|
||||
"schedule_instead": "Zakažite umesto toga",
|
||||
"new_event_subject": "Novi dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"new_event_subject": "Novi događaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"join_by_entrypoint": "Pridruži se putem {{entryPoint}}",
|
||||
"notes": "Beleške",
|
||||
"manage_my_bookings": "Upravljaj svojim rezervacijama",
|
||||
"need_to_make_a_change": "Trebate napraviti promenu?",
|
||||
"new_event_scheduled": "Novi dogadjaj je zakazan.",
|
||||
"new_event_scheduled": "Novi događaj je zakazan.",
|
||||
"new_event_scheduled_recurring": "Novi ponavljajući događaj je zakazan.",
|
||||
"invitee_email": "E-pošta od pozvane osobe",
|
||||
"invitee_timezone": "Vremenska zona pozvane osobe",
|
||||
"time_left": "Preostalo vreme",
|
||||
"event_type": "Tip Dogadjaja",
|
||||
"event_type": "Tip Događaja",
|
||||
"duplicate_event_type": "Duplirani tip događaja",
|
||||
"enter_meeting": "Pridruži se sastanku",
|
||||
"video_call_provider": "Provajder video poziva",
|
||||
@@ -205,7 +205,7 @@
|
||||
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
||||
"hey_there": "Zdravo",
|
||||
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
|
||||
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je dogadjaj zakazan ili otkazan.",
|
||||
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je događaj zakazan ili otkazan.",
|
||||
"confirm_delete_webhook": "Da, obriši webhook",
|
||||
"edit_webhook": "Uredi Webhook",
|
||||
"delete_webhook": "Obriši Webhook",
|
||||
@@ -229,7 +229,7 @@
|
||||
"add_to_homescreen": "Dodaj ovu aplikaciju na telefon za brži pristup i poboljšano korisničko iskustvo.",
|
||||
"upcoming": "Predstojeći",
|
||||
"recurring": "Ponavljajući",
|
||||
"past": "Prošli dogadjaji",
|
||||
"past": "Prošli događaji",
|
||||
"choose_a_file": "Odaberi datoteku...",
|
||||
"upload_image": "Otpremi sliku",
|
||||
"upload_target": "Prikači {{target}}",
|
||||
@@ -343,7 +343,7 @@
|
||||
"connect_your_calendar": "Povežite vaš kalendar",
|
||||
"connect_your_video_app": "Povežite svoje video aplikacije",
|
||||
"connect_your_video_app_instructions": "Povežite svoje video aplikacije da biste ih koristili u vašim tipovima događaja.",
|
||||
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove dogadjaje kad se zakažu.",
|
||||
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove događaje kad se zakažu.",
|
||||
"set_up_later": "Postavi kasnije",
|
||||
"current_time": "Trenutno vreme",
|
||||
"details": "Detalji",
|
||||
@@ -422,7 +422,7 @@
|
||||
"error_end_time_before_start_time": "Krajnje vreme ne može biti pre početnog vremena",
|
||||
"error_end_time_next_day": "Krajnje vreme ne može biti veće od 24 sata",
|
||||
"back_to_bookings": "Nazad na rezervacije",
|
||||
"free_to_pick_another_event_type": "Slobodno izaberite drugi dogadjaj u bilo kom trenutku.",
|
||||
"free_to_pick_another_event_type": "Slobodno izaberite drugi događaj u bilo kom trenutku.",
|
||||
"cancelled": "Otkazano",
|
||||
"no_booking": "Nema rezervacije",
|
||||
"awaiting_host": "Čeka se domaćin",
|
||||
@@ -450,7 +450,7 @@
|
||||
"event_between_users": "{{eventName}} sa {{host}} i {{attendeeName}}",
|
||||
"bookings": "Rezervacije",
|
||||
"booking_not_found": "Rezervacija nije pronađena",
|
||||
"bookings_description": "Vidite nadolazeće i prošle dogadjaje rezervisane preko linkova vaših tipova događaja.",
|
||||
"bookings_description": "Vidite nadolazeće i prošle događaje rezervisane preko linkova vaših tipova događaja.",
|
||||
"upcoming_bookings": "Čim neko rezerviše termin sa vama to će se pokazati ovde.",
|
||||
"recurring_bookings": "Čim neko zakaže ponavljajući sastanak sa vama, to će se pokazati ovde.",
|
||||
"past_bookings": "Vaše prethodne rezervacije će se pojaviti ovde.",
|
||||
@@ -522,7 +522,7 @@
|
||||
"booking_paid": "Rezervacija je plaćena",
|
||||
"ooo_created": "Kreirano odsustvo iz kancelarije",
|
||||
"booking_no_show_updated": "Ažurirano odsustvo sa rezervacije",
|
||||
"event_triggers": "Okidači Dogadjaja",
|
||||
"event_triggers": "Okidači Događaja",
|
||||
"subscriber_url": "URL pretplatnika",
|
||||
"create_new_webhook": "Napravite novi webhook",
|
||||
"webhooks": "Webhooks",
|
||||
@@ -737,7 +737,7 @@
|
||||
"remove_member": "Ukloni člana",
|
||||
"manage_your_team": "Upravljaj svojim timom",
|
||||
"no_teams": "Još uvek nemate nijedan tim.",
|
||||
"no_teams_description": "Timovi služe za rezervacije dogadjaja koje delite sa vašim kolegama.",
|
||||
"no_teams_description": "Timovi služe za rezervacije događaja koje delite sa vašim kolegama.",
|
||||
"submit": "Podnesi",
|
||||
"confirm_remove_attribute": "Da, ukloni atribut",
|
||||
"remove_attribute": "Ukloni atribut",
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import {
|
||||
createBookingScenario,
|
||||
Timezones,
|
||||
TestData,
|
||||
type ScenarioData,
|
||||
} from "../../utils/bookingScenario/bookingScenario";
|
||||
|
||||
import { describe, test, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { expect } from "./expects";
|
||||
import { setupAndTeardown } from "./setupAndTeardown";
|
||||
|
||||
// Mock the FeaturesRepository to enable restriction-schedule feature
|
||||
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||
checkIfTeamHasFeature: vi.fn().mockResolvedValue(true),
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||
getAllFeatures: vi.fn().mockResolvedValue([]),
|
||||
getFeatureFlagMap: vi.fn().mockResolvedValue({}),
|
||||
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
|
||||
})),
|
||||
}));
|
||||
|
||||
type ScheduleScenario = {
|
||||
eventTypes: ScenarioData["eventTypes"];
|
||||
users: ScenarioData["users"];
|
||||
apps: ScenarioData["apps"];
|
||||
selectedSlots?: ScenarioData["selectedSlots"];
|
||||
};
|
||||
|
||||
const getTestScheduleInput = ({
|
||||
yesterdayDateString,
|
||||
plus5DateString,
|
||||
}: {
|
||||
yesterdayDateString: string;
|
||||
plus5DateString: string;
|
||||
}): z.infer<typeof getScheduleSchema> => ({
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: `${yesterdayDateString}T18:30:00.000Z`,
|
||||
endTime: `${plus5DateString}T18:29:59.999Z`,
|
||||
timeZone: Timezones["+5:30"],
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
});
|
||||
|
||||
const getBaseScenarioData = (): ScheduleScenario => ({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 60,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
apps: [],
|
||||
});
|
||||
|
||||
// Helper function to set up team for the tests
|
||||
async function setupTeamAndFeatures() {
|
||||
// Create the team (needed for event type configuration)
|
||||
await prismock.team.create({
|
||||
data: {
|
||||
id: 1,
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("getSchedule", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
describe("Restriction Schedule", () => {
|
||||
test("should respect date override rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Date with override
|
||||
const plus5DateString = "2025-06-05";
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
restrictionScheduleId: 50,
|
||||
useBookerTimezone: false,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
scenarioData.users[0].schedules.push({
|
||||
id: 50,
|
||||
name: "Restriction Schedule - Only Date Override",
|
||||
timeZone: "Asia/Dubai",
|
||||
availability: [
|
||||
{
|
||||
date: "2025-06-02T00:00:00.000Z",
|
||||
days: [],
|
||||
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus5DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on intersection of:
|
||||
// - User availability: 9 AM - 5 PM London
|
||||
// - Restriction: 10 AM - 6 PM Dubai
|
||||
const expectedUtcSlots = [
|
||||
"2025-06-02T08:00:00.000Z", // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
|
||||
"2025-06-02T09:00:00.000Z", // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
|
||||
"2025-06-02T10:00:00.000Z", // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
|
||||
"2025-06-02T11:00:00.000Z", // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
|
||||
"2025-06-02T12:00:00.000Z", // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
|
||||
"2025-06-02T13:00:00.000Z", // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
|
||||
"2025-06-02T14:00:00.000Z", // 3 PM London, 6 PM Dubai, 7:30 PM Kolkata
|
||||
];
|
||||
|
||||
expect(result).toHaveTimeSlots(expectedUtcSlots, {
|
||||
dateString: plus2DateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-03" });
|
||||
});
|
||||
|
||||
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Monday
|
||||
const plus6DateString = "2025-06-06"; // Friday
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
restrictionScheduleId: 50,
|
||||
useBookerTimezone: false,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
scenarioData.users[0].schedules.push({
|
||||
id: 50,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Asia/Dubai",
|
||||
availability: [
|
||||
{
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on intersection of:
|
||||
// - User availability: 9 AM - 5 PM London
|
||||
// - Restriction: 10 AM - 6 PM Dubai, weekdays only
|
||||
const getExpectedSlotsForDate = (dateString: string) => [
|
||||
`${dateString}T08:00:00.000Z`, // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
|
||||
`${dateString}T09:00:00.000Z`, // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
|
||||
`${dateString}T10:00:00.000Z`, // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
|
||||
`${dateString}T11:00:00.000Z`, // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
|
||||
`${dateString}T12:00:00.000Z`, // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
|
||||
`${dateString}T13:00:00.000Z`, // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
|
||||
];
|
||||
|
||||
// Verify weekday slots
|
||||
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||
dateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify weekend is disabled
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
|
||||
});
|
||||
|
||||
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=true)", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Monday
|
||||
const plus6DateString = "2025-06-06"; // Friday
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
restrictionScheduleId: 50,
|
||||
useBookerTimezone: true,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
scenarioData.users[0].schedules.push({
|
||||
id: 50,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Asia/Dubai",
|
||||
availability: [
|
||||
{
|
||||
days: [1, 2, 3, 4, 5],
|
||||
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on:
|
||||
// - User availability: 9 AM - 5 PM London (1:30 PM - 9:30 PM Kolkata)
|
||||
// - Restriction: 10 AM - 6 PM Kolkata
|
||||
const getExpectedSlotsForDate = (dateString: string) => [
|
||||
`${dateString}T08:00:00.000Z`, // 1:30 PM Kolkata, 9 AM London
|
||||
`${dateString}T09:00:00.000Z`, // 2:30 PM Kolkata, 10 AM London
|
||||
`${dateString}T10:00:00.000Z`, // 3:30 PM Kolkata, 11 AM London
|
||||
`${dateString}T11:00:00.000Z`, // 4:30 PM Kolkata, 12 PM London
|
||||
`${dateString}T12:00:00.000Z`, // 5:30 PM Kolkata, 1 PM London
|
||||
];
|
||||
|
||||
// Verify weekday slots
|
||||
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||
dateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify weekend is disabled
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
|
||||
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
|
||||
});
|
||||
|
||||
test("should return all slots when no restriction schedule is applied", async () => {
|
||||
await setupTeamAndFeatures();
|
||||
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||
|
||||
const plus2DateString = "2025-06-02"; // Monday
|
||||
const plus6DateString = "2025-06-06"; // Friday
|
||||
|
||||
const scenarioData: ScheduleScenario = {
|
||||
...getBaseScenarioData(),
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
length: 60,
|
||||
teamId: 1,
|
||||
useBookerTimezone: false,
|
||||
hosts: [{ userId: 101 }],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
...TestData.users.example,
|
||||
id: 101,
|
||||
schedules: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Test Schedule",
|
||||
timeZone: "Europe/London",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
membership: { role: "ADMIN", accepted: true },
|
||||
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await createBookingScenario(scenarioData);
|
||||
|
||||
const result = await getSchedule({
|
||||
input: {
|
||||
eventTypeId: 1,
|
||||
eventTypeSlug: "",
|
||||
startTime: "2025-06-01T00:00:00.000Z",
|
||||
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
isTeamEvent: true,
|
||||
orgSlug: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Expected slots based on user's availability: 9 AM - 5 PM London
|
||||
const getExpectedSlotsForDate = (dateString: string) => [
|
||||
`${dateString}T08:00:00.000Z`, // 9 AM London, 1:30 PM Kolkata
|
||||
`${dateString}T09:00:00.000Z`, // 10 AM London, 2:30 PM Kolkata
|
||||
`${dateString}T10:00:00.000Z`, // 11 AM London, 3:30 PM Kolkata
|
||||
`${dateString}T11:00:00.000Z`, // 12 PM London, 4:30 PM Kolkata
|
||||
`${dateString}T12:00:00.000Z`, // 1 PM London, 5:30 PM Kolkata
|
||||
`${dateString}T13:00:00.000Z`, // 2 PM London, 6:30 PM Kolkata
|
||||
`${dateString}T14:00:00.000Z`, // 3 PM London, 7:30 PM Kolkata
|
||||
`${dateString}T15:00:00.000Z`, // 4 PM London, 8:30 PM Kolkata
|
||||
`${dateString}T16:00:00.000Z`, // 5 PM London, 9:30 PM Kolkata
|
||||
];
|
||||
|
||||
// Verify weekday slots
|
||||
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||
dateString,
|
||||
doExactMatch: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Verify a late evening slot (8:30 PM Kolkata, 4 PM London) is present
|
||||
const lateEveningSlot = `${plus2DateString}T15:00:00.000Z`;
|
||||
expect(result.slots[plus2DateString].map((s) => s.time)).toContain(lateEveningSlot);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -147,6 +147,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
userId: 4,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -203,6 +205,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
scheduleId: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
@@ -317,6 +321,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
workflows: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -372,6 +378,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
useBookerTimezone: false,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
@@ -469,6 +476,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
parentId: 1,
|
||||
userId: 5,
|
||||
users: {
|
||||
@@ -493,6 +502,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
customReplyToEmail: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
|
||||
@@ -213,9 +213,6 @@ export type InputEventType = {
|
||||
userId?: number;
|
||||
minimumBookingNotice?: number;
|
||||
useEventLevelSelectedCalendars?: boolean;
|
||||
/**
|
||||
* These user ids are `ScenarioData["users"]["id"]`
|
||||
*/
|
||||
users?: { id: number }[];
|
||||
hosts?: InputHost[];
|
||||
schedulingType?: SchedulingType;
|
||||
@@ -237,6 +234,25 @@ export type InputEventType = {
|
||||
owner?: number;
|
||||
metadata?: any;
|
||||
rescheduleWithSameRoundRobinHost?: boolean;
|
||||
restrictionSchedule?: {
|
||||
create: {
|
||||
name: string;
|
||||
timeZone: string;
|
||||
availability: {
|
||||
createMany: {
|
||||
data: {
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
date: string | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
user: { connect: { id: number } };
|
||||
};
|
||||
};
|
||||
useBookerTimezone?: boolean;
|
||||
restrictionScheduleId?: number;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||
|
||||
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||
@@ -310,19 +326,31 @@ export async function addEventTypesToDb(
|
||||
"users" | "workflows" | "destinationCalendar" | "schedule"
|
||||
> & {
|
||||
id?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
users?: any[];
|
||||
userId?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hosts?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
workflows?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
destinationCalendar?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schedule?: any;
|
||||
metadata?: any;
|
||||
team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean };
|
||||
restrictionSchedule?: {
|
||||
create: {
|
||||
name: string;
|
||||
timeZone: string;
|
||||
availability: {
|
||||
createMany: {
|
||||
data: {
|
||||
days: number[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
date: string | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
user: { connect: { id: number } };
|
||||
};
|
||||
};
|
||||
})[]
|
||||
) {
|
||||
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
||||
@@ -338,11 +366,6 @@ export async function addEventTypesToDb(
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship.
|
||||
* HACK STARTS
|
||||
*/
|
||||
log.silly("Fixed possible prismock bug by creating schedule separately");
|
||||
for (let i = 0; i < eventTypes.length; i++) {
|
||||
const eventType = eventTypes[i];
|
||||
const createdEventType = allEventTypes[i];
|
||||
@@ -350,8 +373,6 @@ export async function addEventTypesToDb(
|
||||
if (eventType.schedule) {
|
||||
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
|
||||
await prismock.schedule.create({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
data: {
|
||||
...eventType.schedule.create,
|
||||
eventType: {
|
||||
@@ -363,6 +384,24 @@ export async function addEventTypesToDb(
|
||||
});
|
||||
}
|
||||
|
||||
// Handle restrictionSchedule creation and connection
|
||||
if (eventType.restrictionSchedule) {
|
||||
const createdRestrictionSchedule = await prismock.schedule.create({
|
||||
data: {
|
||||
...eventType.restrictionSchedule.create,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: createdEventType.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prismock.eventType.update({
|
||||
where: { id: createdEventType.id },
|
||||
data: { restrictionScheduleId: createdRestrictionSchedule.id },
|
||||
});
|
||||
}
|
||||
|
||||
if (eventType.team?.id) {
|
||||
const createdTeam = await prismock.team.create({
|
||||
data: {
|
||||
@@ -379,9 +418,6 @@ export async function addEventTypesToDb(
|
||||
});
|
||||
}
|
||||
}
|
||||
/***
|
||||
* HACK ENDS
|
||||
*/
|
||||
|
||||
log.silly(
|
||||
"TestData: All EventTypes in DB are",
|
||||
@@ -454,6 +490,22 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
|
||||
},
|
||||
}
|
||||
: eventType.schedule,
|
||||
restrictionScheduleId: eventType.restrictionScheduleId ?? undefined,
|
||||
useBookerTimezone: eventType.useBookerTimezone ?? false,
|
||||
restrictionSchedule: eventType.restrictionSchedule
|
||||
? {
|
||||
create: {
|
||||
name: eventType.restrictionSchedule.create.name,
|
||||
timeZone: eventType.restrictionSchedule.create.timeZone,
|
||||
availability: {
|
||||
createMany: {
|
||||
data: eventType.restrictionSchedule.create.availability.createMany.data,
|
||||
},
|
||||
},
|
||||
user: { connect: { id: eventType.userId || (eventType.users && eventType.users[0]?.id) || 1 } },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
|
||||
schedulingType: eventType.schedulingType,
|
||||
parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined,
|
||||
|
||||
@@ -24102,7 +24102,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": ["accepted", "pending", "declined", "needsAction"]
|
||||
},
|
||||
"CalendarEventAttendee": {
|
||||
@@ -24150,6 +24150,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
|
||||
@@ -98,7 +98,8 @@ export default class GoogleCalendarService implements Calendar {
|
||||
organizer: true,
|
||||
// Tried changing the display name to the user but GCal will not let you do that. It will only display the name of the external calendar. Leaving this in just incase it works in the future.
|
||||
displayName: event.organizer.name,
|
||||
email: hostExternalCalendarId ?? selectedHostDestinationCalendar?.externalId ?? event.organizer.email,
|
||||
// We use || instead of ?? here to handle empty strings
|
||||
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
|
||||
},
|
||||
...eventAttendees,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||
|
||||
@@ -20,13 +20,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
isEmbed: false,
|
||||
};
|
||||
|
||||
it("should return CAL_URL for non-migrated user", () => {
|
||||
it("should return WEBAPP_URL for non-migrated user", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "user/event",
|
||||
form: { ...defaultForm, nonOrgUsername: "user" },
|
||||
});
|
||||
expect(result).toBe(`${CAL_URL}/user/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/user/event?`);
|
||||
});
|
||||
|
||||
it("should return user origin for migrated user", () => {
|
||||
@@ -34,13 +34,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
expect(result).toBe("https://user.cal.com/user/event?");
|
||||
});
|
||||
|
||||
it("should return CAL_URL for non-migrated team", () => {
|
||||
it("should return WEBAPP_URL for non-migrated team", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "team/team1/event",
|
||||
form: { ...defaultForm, nonOrgTeamslug: "team1" },
|
||||
});
|
||||
expect(result).toBe(`${CAL_URL}/team/team1/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/team/team1/event?`);
|
||||
});
|
||||
|
||||
it("should return team origin for migrated team", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const enrichFormWithMigrationData = <
|
||||
@@ -45,12 +45,12 @@ export const enrichFormWithMigrationData = <
|
||||
? getOrgFullOrigin(formOwnerOrgSlug, {
|
||||
protocol: true,
|
||||
})
|
||||
: CAL_URL,
|
||||
: WEBAPP_URL,
|
||||
teamOrigin: form.team?.parent?.slug
|
||||
? getOrgFullOrigin(form.team.parent.slug, {
|
||||
protocol: true,
|
||||
})
|
||||
: CAL_URL,
|
||||
: WEBAPP_URL,
|
||||
nonOrgUsername,
|
||||
nonOrgTeamslug,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
|
||||
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
|
||||
|
||||
import isRouter from "../lib/isRouter";
|
||||
import routerGetCrmContactOwnerEmail from "./crmRouting/routerGetCrmContactOwnerEmail";
|
||||
import type { TargetRoutingFormForResponse } from "./formSubmissionUtils";
|
||||
import { onSubmissionOfFormResponse } from "./formSubmissionUtils";
|
||||
import { handleResponse } from "./handleResponse";
|
||||
|
||||
vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({
|
||||
findTeamMembersMatchingAttributeLogic: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/repository/formResponse", () => ({
|
||||
RoutingFormResponseRepository: {
|
||||
recordQueuedFormResponse: vi.fn(),
|
||||
recordFormResponse: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./crmRouting/routerGetCrmContactOwnerEmail", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./formSubmissionUtils", () => ({
|
||||
onSubmissionOfFormResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/isRouter", () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/sentryWrapper", () => ({
|
||||
withReporting: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
const mockForm: TargetRoutingFormForResponse = {
|
||||
id: "form-id",
|
||||
name: "Test Form",
|
||||
fields: [
|
||||
{
|
||||
id: "name",
|
||||
label: "Name",
|
||||
type: "text" as const,
|
||||
required: true,
|
||||
identifier: "name",
|
||||
placeholder: undefined,
|
||||
selectText: undefined,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "Email",
|
||||
type: "email" as const,
|
||||
required: true,
|
||||
identifier: "email",
|
||||
placeholder: undefined,
|
||||
selectText: undefined,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: "guests",
|
||||
label: "Guests",
|
||||
type: "text" as const,
|
||||
required: false,
|
||||
identifier: "guests",
|
||||
placeholder: undefined,
|
||||
selectText: undefined,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
user: {
|
||||
id: 1,
|
||||
email: "test@example.com",
|
||||
},
|
||||
team: {
|
||||
parentId: 2,
|
||||
},
|
||||
createdAt: "2023-01-01T00:00:00.000Z",
|
||||
updatedAt: "2023-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
disabled: false,
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
connectedForms: [],
|
||||
routers: [],
|
||||
teamMembers: [],
|
||||
position: 0,
|
||||
updatedById: null,
|
||||
};
|
||||
|
||||
const mockResponse: z.infer<typeof ZResponseInputSchema>["response"] = {
|
||||
name: { value: "John Doe", label: "Name" },
|
||||
email: { value: "john.doe@example.com", label: "Email" },
|
||||
};
|
||||
|
||||
describe("handleResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should throw a TRPCError for missing required fields", async () => {
|
||||
await expect(
|
||||
handleResponse({
|
||||
response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing
|
||||
form: mockForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
})
|
||||
).rejects.toThrow(/Missing required fields Name/);
|
||||
});
|
||||
|
||||
it("should throw a TRPCError for invalid email", async () => {
|
||||
await expect(
|
||||
handleResponse({
|
||||
response: {
|
||||
name: { value: "John Doe", label: "Name" },
|
||||
email: { value: "invalid-email", label: "Email" },
|
||||
},
|
||||
form: mockForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
})
|
||||
).rejects.toThrow(/Invalid value for fields 'Email' with value 'invalid-email' should be valid email/);
|
||||
});
|
||||
|
||||
it("should record form response when not in preview and not queued", async () => {
|
||||
const dbFormResponse = {
|
||||
id: 1,
|
||||
formId: mockForm.id,
|
||||
response: mockResponse,
|
||||
chosenRouteId: null,
|
||||
formFillerId: "user1",
|
||||
routedToBookingUid: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse);
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
expect(RoutingFormResponseRepository.recordFormResponse).toHaveBeenCalledWith({
|
||||
formId: mockForm.id,
|
||||
response: mockResponse,
|
||||
chosenRouteId: null,
|
||||
});
|
||||
expect(onSubmissionOfFormResponse).toHaveBeenCalledWith({
|
||||
form: mockForm,
|
||||
formResponseInDb: dbFormResponse,
|
||||
chosenRouteAction: null,
|
||||
});
|
||||
expect(result.formResponse).toEqual(dbFormResponse);
|
||||
expect(result.queuedFormResponse).toBeNull();
|
||||
});
|
||||
|
||||
it("should queue form response when queueFormResponse is true", async () => {
|
||||
const queuedResponse = {
|
||||
id: "queued-id",
|
||||
formId: mockForm.id,
|
||||
response: mockResponse,
|
||||
chosenRouteId: null,
|
||||
actualResponseId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
vi.mocked(RoutingFormResponseRepository.recordQueuedFormResponse).mockResolvedValue(queuedResponse);
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: false,
|
||||
queueFormResponse: true,
|
||||
});
|
||||
|
||||
expect(RoutingFormResponseRepository.recordQueuedFormResponse).toHaveBeenCalledWith({
|
||||
formId: mockForm.id,
|
||||
response: mockResponse,
|
||||
chosenRouteId: null,
|
||||
});
|
||||
expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
|
||||
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
|
||||
expect(result.queuedFormResponse).toEqual(queuedResponse);
|
||||
expect(result.formResponse).toBeNull();
|
||||
});
|
||||
|
||||
describe("Preview mode", () => {
|
||||
it("should send formResponse with id=0 and quueueFormResponse isn't true", async () => {
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
|
||||
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
|
||||
expect(result.isPreview).toBe(true);
|
||||
expect(result.formResponse).toBeDefined();
|
||||
expect(result.formResponse?.id).toBe(0);
|
||||
});
|
||||
|
||||
it("should send queuedFormResponse with id=00000000-0000-0000-0000-000000000000 when queueFormResponse is true", async () => {
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: mockForm,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: null,
|
||||
isPreview: true,
|
||||
queueFormResponse: true,
|
||||
});
|
||||
|
||||
expect(RoutingFormResponseRepository.recordQueuedFormResponse).not.toHaveBeenCalled();
|
||||
expect(result.isPreview).toBe(true);
|
||||
expect(result.queuedFormResponse).toBeDefined();
|
||||
expect(result.queuedFormResponse?.id).toBe("00000000-0000-0000-0000-000000000000");
|
||||
expect(result.formResponse).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle chosen route and call routing logic", async () => {
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
queryValue: { type: "group", children1: {} } as any,
|
||||
action: {
|
||||
type: "customPageMessage" as const,
|
||||
value: "Thank you for your submission!",
|
||||
},
|
||||
attributeRoutingConfig: null,
|
||||
attributesQueryValue: { type: "group", children1: {} } as any,
|
||||
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||
isFallback: false,
|
||||
};
|
||||
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
||||
|
||||
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
|
||||
email: "owner@example.com",
|
||||
recordType: "contact",
|
||||
crmAppSlug: "hubspot",
|
||||
});
|
||||
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
|
||||
checkedFallback: false,
|
||||
fallbackAttributeLogicBuildingWarnings: [],
|
||||
mainAttributeLogicBuildingWarnings: [],
|
||||
timeTaken: {
|
||||
ttGetAttributesForLogic: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
expect(routerGetCrmContactOwnerEmail).toHaveBeenCalled();
|
||||
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalled();
|
||||
expect(result.crmContactOwnerEmail).toBe("owner@example.com");
|
||||
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
|
||||
});
|
||||
|
||||
it("should throw error if chosen route is a router", async () => {
|
||||
const chosenRoute = {
|
||||
id: "route1",
|
||||
name: "A router",
|
||||
description: "Router description",
|
||||
isRouter: true as const,
|
||||
routes: [],
|
||||
};
|
||||
vi.mocked(isRouter).mockReturnValue(true);
|
||||
const formWithRoute: TargetRoutingFormForResponse = {
|
||||
...mockForm,
|
||||
routes: [chosenRoute as any],
|
||||
};
|
||||
|
||||
await expect(
|
||||
handleResponse({
|
||||
response: mockResponse,
|
||||
form: formWithRoute,
|
||||
formFillerId: "user1",
|
||||
chosenRouteId: "route1",
|
||||
isPreview: false,
|
||||
})
|
||||
).rejects.toThrow(/Chosen route is a router/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,15 @@ import slugify from "@calcom/lib/slugify";
|
||||
import type { FormResponse, NonRouterRoute, Field } from "../types/types";
|
||||
import getFieldIdentifier from "./getFieldIdentifier";
|
||||
|
||||
/**
|
||||
* Substitues variables in the target URL identified by routeValue with values from response
|
||||
* e.g. {firstName} is replaced with value of the field with identifier firstName
|
||||
*
|
||||
* @param routeValue - The target URL with variables to be substituted
|
||||
* @param response - The form response containing the values to be substituted
|
||||
* @param fields - The fields of the form
|
||||
* @returns The URL with variables substituted
|
||||
*/
|
||||
export const substituteVariables = (
|
||||
routeValue: NonRouterRoute["action"]["value"],
|
||||
response: FormResponse,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+29
@@ -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,8 +323,31 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: [1, 2],
|
||||
formResponseId: 1,
|
||||
queuedFormResponseId: null,
|
||||
attributeRoutingConfig: null,
|
||||
});
|
||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||
});
|
||||
|
||||
describe("Dry Run", () => {
|
||||
it("should add cal.routingFormResponseId=0 when formResponseId is 0", () => {
|
||||
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||
const expectedParams = {
|
||||
"cal.routingFormResponseId": "0",
|
||||
query1: "value1",
|
||||
query2: "value2",
|
||||
};
|
||||
|
||||
const result = getUrlSearchParamsToForward({
|
||||
formResponse: {},
|
||||
fields: [],
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponseId: 0,
|
||||
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,10 @@ export function getUrlSearchParamsToForward({
|
||||
...(teamMembersMatchingAttributeLogic
|
||||
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
|
||||
: null),
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId),
|
||||
...(typeof formResponseId === "number"
|
||||
? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) }
|
||||
: null),
|
||||
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
|
||||
...attributeRoutingConfigParams,
|
||||
...(reroutingFormResponses
|
||||
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
|
||||
@@ -151,7 +156,7 @@ export function getUrlSearchParamsToForwardForReroute({
|
||||
attributeRoutingConfig,
|
||||
rescheduleUid,
|
||||
reroutingFormResponses,
|
||||
}: GetUrlSearchParamsToForwardOptions & {
|
||||
}: Omit<GetUrlSearchParamsToForwardOptions, "queuedFormResponseId"> & {
|
||||
rescheduleUid: string;
|
||||
reroutingFormResponses: FormResponseValueOnly;
|
||||
}) {
|
||||
@@ -160,6 +165,8 @@ export function getUrlSearchParamsToForwardForReroute({
|
||||
return getUrlSearchParamsToForward({
|
||||
formResponse,
|
||||
formResponseId,
|
||||
// Queued form response id is not available in rerouting
|
||||
queuedFormResponseId: null,
|
||||
fields,
|
||||
searchParams,
|
||||
teamMembersMatchingAttributeLogic,
|
||||
@@ -187,6 +194,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/client";
|
||||
|
||||
import { _onFormSubmission } from "./utils";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
||||
default: vi.fn(() => Promise.resolve(1)),
|
||||
}));
|
||||
vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({
|
||||
default: vi.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
|
||||
sendGenericWebhookPayload: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
vi.mock("@calcom/features/tasker", () => {
|
||||
const tasker = {
|
||||
create: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
return { default: Promise.resolve(tasker) };
|
||||
});
|
||||
|
||||
const mockSendEmail = vi.fn(() => Promise.resolve());
|
||||
const mockResponseEmailConstructor = vi.fn();
|
||||
vi.mock("../emails/templates/response-email", () => ({
|
||||
default: class MockResponseEmail {
|
||||
sendEmail = mockSendEmail;
|
||||
constructor(...args: unknown[]) {
|
||||
mockResponseEmailConstructor(...args);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("_onFormSubmission", () => {
|
||||
const mockForm = {
|
||||
id: "form-1",
|
||||
name: "Test Form",
|
||||
fields: [
|
||||
{ id: "field-1", identifier: "email", label: "Email", type: "email" },
|
||||
{ id: "field-2", identifier: "name", label: "Name", type: "text" },
|
||||
],
|
||||
user: { id: 1, email: "test@example.com" },
|
||||
teamId: null,
|
||||
settings: { emailOwnerOnSubmission: true },
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
"field-1": { label: "Email", value: "test@response.com" },
|
||||
"field-2": { label: "Name", value: "Test Name" },
|
||||
};
|
||||
|
||||
const responseId = 123;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Webhooks", () => {
|
||||
it("should call FORM_SUBMITTED webhooks", async () => {
|
||||
vi.mocked(getWebhooks).mockResolvedValueOnce([{ id: "wh-1", secret: "secret" } as any]);
|
||||
|
||||
await _onFormSubmission(mockForm as any, mockResponse, responseId);
|
||||
|
||||
expect(getWebhooks).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
orgId: 1,
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||
});
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should schedule FORM_SUBMITTED_NO_EVENT webhooks via tasker", async () => {
|
||||
const tasker = await (await import("@calcom/features/tasker")).default;
|
||||
const mockWebhook = { id: "wh-no-event-1", secret: "secret" };
|
||||
const chosenAction = { type: "customPageMessage" as const, value: "test" };
|
||||
|
||||
vi.mocked(getWebhooks).mockImplementation(async (options) => {
|
||||
if (options.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT) {
|
||||
return [mockWebhook as any];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
await _onFormSubmission(mockForm as any, mockResponse, responseId, chosenAction);
|
||||
|
||||
expect(getWebhooks).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
|
||||
})
|
||||
);
|
||||
expect(tasker.create).toHaveBeenCalledWith(
|
||||
"triggerFormSubmittedNoEventWebhook",
|
||||
{
|
||||
responseId,
|
||||
form: mockForm,
|
||||
responses: {
|
||||
email: {
|
||||
value: "test@response.com",
|
||||
response: "test@response.com",
|
||||
},
|
||||
name: { value: "Test Name", response: "Test Name" },
|
||||
},
|
||||
redirect: chosenAction,
|
||||
webhook: mockWebhook,
|
||||
},
|
||||
{ scheduledAt: expect.any(Date) }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Response Email", () => {
|
||||
it("should send response email to team members for a team form", async () => {
|
||||
const teamForm = {
|
||||
...mockForm,
|
||||
teamId: 1,
|
||||
userWithEmails: ["team-member1@example.com", "team-member2@example.com"],
|
||||
user: { id: 1, email: "test@example.com" },
|
||||
};
|
||||
|
||||
await _onFormSubmission(teamForm as any, mockResponse, responseId);
|
||||
|
||||
expect(mockResponseEmailConstructor).toHaveBeenCalledWith({
|
||||
form: teamForm,
|
||||
toAddresses: ["team-member1@example.com", "team-member2@example.com"],
|
||||
orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]],
|
||||
});
|
||||
expect(mockSendEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send response email to owner when enabled", async () => {
|
||||
const ownerForm = {
|
||||
...mockForm,
|
||||
settings: { emailOwnerOnSubmission: true },
|
||||
};
|
||||
|
||||
await _onFormSubmission(ownerForm as any, mockResponse, responseId);
|
||||
|
||||
expect(mockResponseEmailConstructor).toHaveBeenCalledWith({
|
||||
form: ownerForm,
|
||||
toAddresses: [ownerForm.user.email],
|
||||
orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]],
|
||||
});
|
||||
expect(mockSendEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not send response email to owner when disabled", async () => {
|
||||
const ownerForm = {
|
||||
...mockForm,
|
||||
settings: { emailOwnerOnSubmission: false },
|
||||
};
|
||||
|
||||
await _onFormSubmission(ownerForm as any, mockResponse, responseId);
|
||||
|
||||
expect(mockResponseEmailConstructor).not.toHaveBeenCalled();
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export function getFieldResponse({
|
||||
* Not called in preview mode or dry run mode
|
||||
* It takes care of sending webhooks and emails for form submissions
|
||||
*/
|
||||
async function _onFormSubmission(
|
||||
export async function _onFormSubmission(
|
||||
form: Ensure<
|
||||
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
|
||||
"fields"
|
||||
|
||||
@@ -156,6 +156,11 @@ Cal.config = Cal.config || {};
|
||||
Cal.config.forwardQueryParams=true
|
||||
```
|
||||
|
||||
#### Enabling Logging
|
||||
You can enable logging for the embed by adding the `cal.embed.logging=1` query parameter to the URL of the page where the embed is placed. This is useful for debugging issues with the embed. It will log all important things in parent as well as in the iframe(i.e. child)
|
||||
|
||||
For example, if your page is `https.example.com/contact`, you can enable logging by visiting `https.example.com/contact?cal.embed.logging=1`.
|
||||
|
||||
### Advanced Features
|
||||
|
||||
#### Routing Prerendering System
|
||||
|
||||
@@ -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;
|
||||
@@ -334,8 +349,10 @@ export class Cal {
|
||||
|
||||
urlInstance.searchParams.set("embed", this.namespace);
|
||||
|
||||
if (calConfig.debug) {
|
||||
urlInstance.searchParams.set("debug", `${calConfig.debug}`);
|
||||
const pageParams = this.getQueryParamsFromPage();
|
||||
// cal.embed.logging=1 enabled logging in parent and by setting debug=true in iframe, it enables logging in iframe(child) as well
|
||||
if (calConfig.debug || pageParams["cal.embed.logging"] === "1") {
|
||||
urlInstance.searchParams.set("debug", "true");
|
||||
}
|
||||
|
||||
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
|
||||
@@ -522,13 +539,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 +547,7 @@ export class Cal {
|
||||
isConnectionInitiated,
|
||||
previousEmbedRenderStartTime,
|
||||
embedRenderStartTime,
|
||||
prerenderOptions,
|
||||
} = stateData;
|
||||
const calConfig = this.getCalConfig();
|
||||
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
|
||||
@@ -570,43 +582,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 +693,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 +777,7 @@ class CalApi {
|
||||
static initializedNamespaces = [] as string[];
|
||||
modalUid?: string;
|
||||
prerenderedModalUid?: string;
|
||||
prerenderOptions?: ModalPrerenderOptions;
|
||||
constructor(cal: Cal) {
|
||||
this.cal = cal;
|
||||
}
|
||||
@@ -946,12 +969,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 +997,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 +1025,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 +1043,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 +1066,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 +1088,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 +1108,6 @@ class CalApi {
|
||||
|
||||
// We reach here in case of connect or fullReload
|
||||
this.modalUid = uid;
|
||||
this.cal.embedRenderStartTime = embedRenderStartTime;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1165,6 +1220,8 @@ class CalApi {
|
||||
type?: "modal" | "floatingButton";
|
||||
options?: {
|
||||
prerenderIframe?: boolean;
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
};
|
||||
pageType?: EmbedPageType;
|
||||
calOrigin?: string;
|
||||
@@ -1198,7 +1255,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 +1272,7 @@ class CalApi {
|
||||
calLink,
|
||||
calOrigin: calOrigin || config.calOrigin,
|
||||
__prerender: true,
|
||||
prerenderOptions,
|
||||
...(pageType ? { config: { "cal.embed.pageType": pageType } } : {}),
|
||||
});
|
||||
} else {
|
||||
@@ -1225,22 +1284,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>>;
|
||||
|
||||
@@ -156,7 +156,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
let attendeeEmail: string | null = null;
|
||||
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
|
||||
if (!theBooking) {
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
const bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
where: {
|
||||
referenceUid: uid,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from "tslog";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
|
||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
|
||||
import { getUsersAvailability } from "@calcom/lib/getUserAvailability";
|
||||
@@ -11,6 +12,7 @@ import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"
|
||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { getEventTypeResponse } from "./getEventTypesFromDB";
|
||||
import type { BookingType } from "./originalRescheduledBookingUtils";
|
||||
@@ -121,6 +123,93 @@ const _ensureAvailableUsers = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (eventType.restrictionScheduleId) {
|
||||
try {
|
||||
const restrictionSchedule = await prisma.schedule.findUnique({
|
||||
where: { id: eventType.restrictionScheduleId },
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
userId: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
defaultScheduleId: true,
|
||||
travelSchedules: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!restrictionSchedule) {
|
||||
loggerWithEventDetails.error(`Restriction schedule ${eventType.restrictionScheduleId} not found`);
|
||||
throw new Error(ErrorCode.RestrictionScheduleNotFound);
|
||||
}
|
||||
|
||||
const restrictionTimezone = eventType.useBookerTimezone
|
||||
? input.timeZone
|
||||
: restrictionSchedule.timeZone!;
|
||||
|
||||
if (!eventType.useBookerTimezone && !restrictionSchedule.timeZone) {
|
||||
loggerWithEventDetails.error(
|
||||
`No timezone is set for the restriction schedule and useBookerTimezone is false`
|
||||
);
|
||||
throw new Error(ErrorCode.BookingNotAllowedByRestrictionSchedule);
|
||||
}
|
||||
|
||||
const restrictionAvailability = restrictionSchedule.availability.map((rule) => ({
|
||||
days: rule.days,
|
||||
startTime: rule.startTime,
|
||||
endTime: rule.endTime,
|
||||
date: rule.date,
|
||||
}));
|
||||
|
||||
const isDefaultSchedule = restrictionSchedule.user.defaultScheduleId === restrictionSchedule.id;
|
||||
const travelSchedules =
|
||||
isDefaultSchedule && !eventType.useBookerTimezone
|
||||
? restrictionSchedule.user.travelSchedules.map((schedule) => ({
|
||||
startDate: dayjs(schedule.startDate),
|
||||
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
|
||||
timeZone: schedule.timeZone,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const { dateRanges: restrictionRanges } = buildDateRanges({
|
||||
availability: restrictionAvailability,
|
||||
timeZone: restrictionTimezone,
|
||||
dateFrom: startDateTimeUtc,
|
||||
dateTo: endDateTimeUtc,
|
||||
travelSchedules,
|
||||
});
|
||||
|
||||
if (!hasDateRangeForBooking(restrictionRanges, startDateTimeUtc, endDateTimeUtc)) {
|
||||
loggerWithEventDetails.error(
|
||||
`Booking outside restriction schedule availability.`,
|
||||
piiFreeInputDataForLogging
|
||||
);
|
||||
throw new Error(ErrorCode.BookingNotAllowedByRestrictionSchedule);
|
||||
}
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error(`Error checking restriction schedule.`, piiFreeInputDataForLogging);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
usersAvailability.forEach(({ oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes }, index) => {
|
||||
const user = eventType.users[index];
|
||||
|
||||
@@ -161,6 +250,7 @@ const _ensureAvailableUsers = async (
|
||||
loggerWithEventDetails.error(`No available users found.`, piiFreeInputDataForLogging);
|
||||
throw new Error(ErrorCode.NoAvailableUsersFound);
|
||||
}
|
||||
|
||||
// make sure TypeScript understands availableUsers is at least one.
|
||||
return availableUsers.length === 1 ? [availableUsers[0]] : [availableUsers[0], ...availableUsers.slice(1)];
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
id: true,
|
||||
customInputs: true,
|
||||
disableGuests: true,
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
users: {
|
||||
select: {
|
||||
credentials: {
|
||||
|
||||
@@ -160,6 +160,7 @@ export default async function handleChildrenEventTypes({
|
||||
newUserIds.map((userId) => {
|
||||
return prisma.eventType.create({
|
||||
data: {
|
||||
instantMeetingScheduleId: eventType.instantMeetingScheduleId ?? undefined,
|
||||
profileId: profileId ?? null,
|
||||
...managedEventTypeValues,
|
||||
...unlockedEventTypeValues,
|
||||
@@ -187,6 +188,8 @@ export default async function handleChildrenEventTypes({
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
allowReschedulingCancelledBookings:
|
||||
managedEventTypeValues.allowReschedulingCancelledBookings ?? false,
|
||||
},
|
||||
@@ -255,6 +258,8 @@ export default async function handleChildrenEventTypes({
|
||||
...updatePayloadFiltered,
|
||||
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
|
||||
scheduleId: eventType.scheduleId || null,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
hashedLink:
|
||||
"multiplePrivateLinks" in unlockedFieldProps
|
||||
? undefined
|
||||
|
||||
@@ -197,6 +197,9 @@ async function createOrganizationWithNonExistentUserAsOwner({
|
||||
);
|
||||
const owner = await findUserToBeOrgOwner(email);
|
||||
if (!owner) {
|
||||
// Can happen when the organization was created earlier and the webhook had failed and when the webhook got fired again in next subscription update, then the email was deleted already
|
||||
// The fix would be to change the email in Onboarding record to new owner of the organization
|
||||
// TODO: Identify the owner of the organization from Membership table and use that email instead here.
|
||||
throw new Error(`Org exists but owner could not be found for email: ${email}`);
|
||||
}
|
||||
return { organization, owner };
|
||||
|
||||
@@ -128,6 +128,7 @@ export default function OtherTeamListItem(props: Props) {
|
||||
color="destructive"
|
||||
type="button"
|
||||
StartIcon="trash"
|
||||
className="rounded-t-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
|
||||
@@ -112,6 +112,7 @@ function AttributeItem({
|
||||
type="button"
|
||||
StartIcon="trash-2"
|
||||
color="destructive"
|
||||
className="rounded-t-none"
|
||||
onClick={() => setAttributeToDelete(attribute)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
|
||||
@@ -183,6 +183,11 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
|
||||
|
||||
const mutation = trpc.viewer.organizations.update.useMutation({
|
||||
onError: (err) => {
|
||||
// Handle JSON parsing errors from body size limit exceeded
|
||||
if (err.message.includes("Unexpected token") && err.message.includes("Body excee")) {
|
||||
showToast(t("converted_image_size_limit_exceed"), "error");
|
||||
return;
|
||||
}
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
onSuccess: async (res) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
|
||||
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -18,7 +18,7 @@ const querySchema = z.object({
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { uid } = querySchema.parse(context.query);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
const rawPayment = await prisma.payment.findUnique({
|
||||
where: {
|
||||
@@ -59,7 +59,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
orgSlug: currentOrgDomain,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -272,6 +272,7 @@ export default function TeamListItem(props: Props) {
|
||||
color="destructive"
|
||||
type="button"
|
||||
StartIcon="trash"
|
||||
className="rounded-t-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { forwardRef } from "react";
|
||||
|
||||
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useEmbedBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TextArea } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -188,7 +188,7 @@ ${getEmbedTypeSpecificString({
|
||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||
{ calLink: string; embedType: EmbedType; previewState: PreviewState; namespace: string }
|
||||
>(function Preview({ calLink, embedType }, ref) {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const bookerUrl = useEmbedBookerUrl();
|
||||
const iframeSrc = `${EMBED_PREVIEW_HTML_URL}?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${embedLibUrl}&bookerUrl=${bookerUrl}`;
|
||||
if (ref instanceof Function || !ref) {
|
||||
return null;
|
||||
@@ -329,7 +329,7 @@ const getInstructionString = ({
|
||||
};
|
||||
|
||||
function useGetEmbedSnippetString(namespace: string | null) {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const bookerUrl = useEmbedBookerUrl();
|
||||
// TODO: Import this string from @calcom/embed-snippet
|
||||
// Right now the problem is that embed-snippet export is not minified and has comments which makes it unsuitable for giving it to users.
|
||||
// If we can minify that during build time and then import the built code here, that could work
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useEmbedBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const useEmbedTypes = () => {
|
||||
@@ -319,6 +319,6 @@ export const useEmbedTypes = () => {
|
||||
};
|
||||
|
||||
export const useEmbedCalOrigin = () => {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const bookerUrl = useEmbedBookerUrl();
|
||||
return bookerUrl;
|
||||
};
|
||||
|
||||
@@ -388,6 +388,39 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.disableTranscriptionForGuests"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForGuests}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_transcription_for_guests")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.disableTranscriptionForOrganizer"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForOrganizer}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_transcription_for_organizer")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={t("enter_redirect_url_on_exit_description")}
|
||||
defaultValue={eventType.calVideoSettings?.redirectUrlOnExit || ""}
|
||||
|
||||
+309
-40
@@ -18,11 +18,13 @@ import type {
|
||||
Host,
|
||||
SelectClassNames,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import CheckboxField from "@calcom/features/form/components/CheckboxField";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { weekStartNum } from "@calcom/lib/weekstart";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -113,6 +115,12 @@ type EventTypeScheduleProps = {
|
||||
eventType: EventTypeSetup;
|
||||
teamMembers: TeamMember[];
|
||||
customClassNames?: UserAvailabilityCustomClassNames;
|
||||
fieldName?: "schedule" | "restrictionSchedule";
|
||||
scheduleQueryData?: ScheduleQueryData;
|
||||
restrictionScheduleQueryData?: ScheduleQueryData;
|
||||
isSchedulePending?: boolean;
|
||||
isRestrictionSchedulePending?: boolean;
|
||||
restrictionScheduleRedirectUrl?: string;
|
||||
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
|
||||
Omit<EventTypeTeamScheduleProps, "customClassNames">;
|
||||
|
||||
@@ -120,7 +128,7 @@ export type EventAvailabilityTabBaserProps = {
|
||||
isTeamEvent: boolean;
|
||||
};
|
||||
|
||||
type UseCommonScheduleSettingsToggle = Omit<EventTypeScheduleProps, "customClassNames"> & {
|
||||
type UseTeamEventScheduleSettingsToggle = Omit<EventTypeScheduleProps, "customClassNames"> & {
|
||||
customClassNames?: EventAvailabilityTabCustomClassNames;
|
||||
};
|
||||
|
||||
@@ -252,10 +260,12 @@ const EventTypeScheduleDetails = memo(
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
</div>
|
||||
{!!scheduleQueryData?.id &&
|
||||
!scheduleQueryData.isManaged &&
|
||||
!scheduleQueryData.readOnly &&
|
||||
@@ -278,11 +288,161 @@ const EventTypeScheduleDetails = memo(
|
||||
|
||||
EventTypeScheduleDetails.displayName = "EventTypeScheduleDetails";
|
||||
|
||||
type EventTypeRestrictionScheduleDetailsProps = {
|
||||
scheduleQueryData?: Pick<ScheduleQueryData, "timeZone" | "id" | "isManaged" | "readOnly"> & {
|
||||
schedule: Array<Pick<ScheduleQueryData["schedule"][number], "days" | "startTime" | "endTime">>;
|
||||
};
|
||||
isSchedulePending?: boolean;
|
||||
user?: Pick<RouterOutputs["viewer"]["me"]["get"], "timeFormat" | "weekStart">;
|
||||
restrictionScheduleRedirectUrl?: string;
|
||||
customClassNames?: AvailabilityTableCustomClassNames;
|
||||
useBookerTimezone?: boolean;
|
||||
};
|
||||
|
||||
const EventTypeRestrictionScheduleDetails = memo(
|
||||
({
|
||||
scheduleQueryData,
|
||||
isSchedulePending,
|
||||
user,
|
||||
restrictionScheduleRedirectUrl,
|
||||
customClassNames,
|
||||
useBookerTimezone: initialUseBookerTimezone,
|
||||
}: EventTypeRestrictionScheduleDetailsProps) => {
|
||||
const timeFormat = user?.timeFormat;
|
||||
const { t, i18n } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { setValue, watch } = formMethods;
|
||||
|
||||
const weekStart = weekStartNum(user?.weekStart);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
scheduleQueryData?.schedule?.filter((item) => item.days.includes((dayNum + weekStart) % 7)) || [];
|
||||
|
||||
const useBookerTimezone = watch("useBookerTimezone");
|
||||
|
||||
useEffect(() => {
|
||||
if (useBookerTimezone === undefined) {
|
||||
setValue("useBookerTimezone", initialUseBookerTimezone || false, { shouldDirty: false });
|
||||
}
|
||||
}, [useBookerTimezone, setValue, initialUseBookerTimezone]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={classNames("border-subtle space-y-4 border-x p-6", customClassNames?.tableContainer)}>
|
||||
<ol className={classNames("table border-collapse text-sm", customClassNames?.table)}>
|
||||
{weekdayNames(i18n.language, weekStart, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li
|
||||
key={day}
|
||||
className={classNames(
|
||||
"my-6 flex border-transparent last:mb-2",
|
||||
customClassNames?.tableRow
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default",
|
||||
customClassNames?.day
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isSchedulePending ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classNames(
|
||||
"text-default flex items-center leading-4",
|
||||
customClassNames?.dayAvailabilityContainer
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
"w-16 sm:w-28 sm:text-left",
|
||||
customClassNames?.dayAvailabilityFrom
|
||||
)}>
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className={classNames("ms-4", customClassNames?.dayAvailabilitySeperator)}>
|
||||
-
|
||||
</span>
|
||||
<div className={classNames("ml-6 sm:w-28", customClassNames?.dayAvailabilityTo)}>
|
||||
{format(dayRange.endTime, timeFormat === 12)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={classNames("text-subtle ml-6 sm:ml-0", customClassNames?.dayUnavailable)}>
|
||||
{t("unavailable")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"text-default flex items-center justify-center text-sm sm:justify-start",
|
||||
useBookerTimezone && "text-muted line-through"
|
||||
)}>
|
||||
<Icon
|
||||
name="globe"
|
||||
className={classNames("h-3.5 w-3.5 ltr:mr-2 rtl:ml-2", useBookerTimezone && "text-muted")}
|
||||
/>
|
||||
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<div className="ltr:mr-2 rtl:ml-2">
|
||||
<CheckboxField
|
||||
checked={useBookerTimezone}
|
||||
disabled={isSchedulePending}
|
||||
description={t("use_booker_timezone")}
|
||||
informationIconText={t("use_booker_timezone_info")}
|
||||
onChange={(e) => {
|
||||
setValue("useBookerTimezone", e.target.checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!scheduleQueryData?.id &&
|
||||
!scheduleQueryData.isManaged &&
|
||||
!scheduleQueryData.readOnly &&
|
||||
!!restrictionScheduleRedirectUrl && (
|
||||
<Button
|
||||
href={restrictionScheduleRedirectUrl}
|
||||
disabled={isSchedulePending}
|
||||
color="minimal"
|
||||
EndIcon="external-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EventTypeRestrictionScheduleDetails.displayName = "EventTypeRestrictionScheduleDetails";
|
||||
|
||||
const EventTypeSchedule = ({
|
||||
eventType,
|
||||
schedulesQueryData,
|
||||
isSchedulesPending,
|
||||
customClassNames,
|
||||
fieldName = "schedule",
|
||||
scheduleQueryData,
|
||||
restrictionScheduleQueryData,
|
||||
isSchedulePending,
|
||||
isRestrictionSchedulePending,
|
||||
restrictionScheduleRedirectUrl,
|
||||
...rest
|
||||
}: EventTypeScheduleProps) => {
|
||||
const { t } = useLocale();
|
||||
@@ -291,14 +451,21 @@ const EventTypeSchedule = ({
|
||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const { watch, setValue } = formMethods;
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
// Map the fieldName to the actual form field name
|
||||
const formFieldName = fieldName === "restrictionSchedule" ? "restrictionScheduleId" : fieldName;
|
||||
const scheduleId = watch(formFieldName);
|
||||
|
||||
useEffect(() => {
|
||||
// after data is loaded.
|
||||
if (schedulesQueryData && scheduleId !== 0 && !scheduleId) {
|
||||
const newValue = isManagedEventType ? 0 : schedulesQueryData.find((schedule) => schedule.isDefault)?.id;
|
||||
let newValue;
|
||||
if (fieldName === "restrictionSchedule") {
|
||||
newValue =
|
||||
eventType.restrictionScheduleId || schedulesQueryData.find((schedule) => schedule.isDefault)?.id;
|
||||
} else {
|
||||
newValue = isManagedEventType ? 0 : schedulesQueryData.find((schedule) => schedule.isDefault)?.id;
|
||||
}
|
||||
if (!newValue && newValue !== 0) return;
|
||||
setValue("schedule", newValue, {
|
||||
setValue(formFieldName, newValue, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
@@ -349,6 +516,29 @@ const EventTypeSchedule = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
fieldName === "restrictionSchedule" &&
|
||||
eventType.restrictionScheduleId &&
|
||||
!schedulesQueryData.find((schedule) => schedule.id === eventType.restrictionScheduleId)
|
||||
) {
|
||||
options.push({
|
||||
value: eventType.restrictionScheduleId,
|
||||
label: eventType.restrictionScheduleName ?? t("default_schedule_name"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
const filteredOptions =
|
||||
fieldName === "restrictionSchedule"
|
||||
? options.filter((option) => option.value !== eventType.schedule)
|
||||
: options;
|
||||
|
||||
const currentScheduleQueryData =
|
||||
fieldName === "schedule" ? scheduleQueryData : restrictionScheduleQueryData;
|
||||
const isCurrentSchedulePending =
|
||||
fieldName === "schedule" ? isSchedulePending : isRestrictionSchedulePending;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -363,22 +553,27 @@ const EventTypeSchedule = ({
|
||||
customClassNames?.availabilitySelect?.label
|
||||
)}>
|
||||
{t("availability")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("schedule")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator(formFieldName)}
|
||||
</label>
|
||||
<Controller
|
||||
name="schedule"
|
||||
name={formFieldName}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const optionValue: AvailabilityOption | undefined = options.find(
|
||||
const optionValue: AvailabilityOption | undefined = filteredOptions.find(
|
||||
(option) => option.value === value
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isDisabled={shouldLockDisableProps("schedule").disabled}
|
||||
options={filteredOptions}
|
||||
isDisabled={shouldLockDisableProps(formFieldName).disabled}
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
if (selected) onChange(selected.value);
|
||||
if (selected) {
|
||||
onChange(selected.value);
|
||||
if (fieldName === "restrictionSchedule" && selected.value) {
|
||||
setValue("restrictionScheduleId", selected.value, { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"block w-full min-w-0 flex-1 rounded-sm text-sm",
|
||||
@@ -393,16 +588,34 @@ const EventTypeSchedule = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{scheduleId !== 0 ? (
|
||||
<EventTypeScheduleDetails {...rest} customClassNames={customClassNames?.availabilityTable} />
|
||||
{scheduleId !== 0 && (fieldName === "schedule" || fieldName === "restrictionSchedule") ? (
|
||||
fieldName === "restrictionSchedule" ? (
|
||||
<EventTypeRestrictionScheduleDetails
|
||||
{...rest}
|
||||
scheduleQueryData={currentScheduleQueryData}
|
||||
isSchedulePending={isCurrentSchedulePending}
|
||||
customClassNames={customClassNames?.availabilityTable}
|
||||
useBookerTimezone={eventType.useBookerTimezone}
|
||||
restrictionScheduleRedirectUrl={restrictionScheduleRedirectUrl}
|
||||
/>
|
||||
) : (
|
||||
<EventTypeScheduleDetails
|
||||
{...rest}
|
||||
scheduleQueryData={currentScheduleQueryData}
|
||||
isSchedulePending={isCurrentSchedulePending}
|
||||
customClassNames={customClassNames?.availabilityTable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
isManagedEventType && (
|
||||
isManagedEventType &&
|
||||
fieldName === "schedule" && (
|
||||
<p className="!mt-2 ml-1 text-sm text-gray-600">{t("members_default_schedule_description")}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamMemberSchedule = ({
|
||||
host,
|
||||
index,
|
||||
@@ -575,35 +788,91 @@ const useCommonScheduleState = (initialScheduleId: number | null) => {
|
||||
};
|
||||
};
|
||||
|
||||
const UseCommonScheduleSettingsToggle = ({
|
||||
const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null) => {
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
const [restrictScheduleForHosts, setRestrictScheduleForHosts] = useState(!!initialRestrictionScheduleId);
|
||||
|
||||
// Toggle function for restriction schedule
|
||||
const toggleRestrictScheduleState = (checked: boolean) => {
|
||||
setRestrictScheduleForHosts(checked);
|
||||
if (!checked) {
|
||||
setValue("restrictionScheduleId", null, { shouldDirty: true });
|
||||
setValue("useBookerTimezone", false, { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
restrictScheduleForHosts,
|
||||
toggleRestrictScheduleState,
|
||||
};
|
||||
};
|
||||
|
||||
const UseTeamEventScheduleSettingsToggle = ({
|
||||
eventType,
|
||||
customClassNames,
|
||||
...rest
|
||||
}: UseCommonScheduleSettingsToggle) => {
|
||||
}: UseTeamEventScheduleSettingsToggle) => {
|
||||
const { t } = useLocale();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { useHostSchedulesForTeamEvent, toggleScheduleState } = useCommonScheduleState(eventType.schedule);
|
||||
const { restrictScheduleForHosts, toggleRestrictScheduleState } = useRestrictionScheduleState(
|
||||
eventType.restrictionScheduleId
|
||||
);
|
||||
|
||||
// Check if team has restriction schedule feature enabled
|
||||
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
|
||||
{
|
||||
teamId: eventType.team?.id || 0,
|
||||
feature: "restriction-schedule",
|
||||
},
|
||||
{
|
||||
enabled: !!eventType.team?.id,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<SettingsToggle
|
||||
checked={!useHostSchedulesForTeamEvent}
|
||||
onCheckedChange={toggleScheduleState}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
{/* handles the state for which 'schedule' ID is set, as it's unknown until the Select dropdown is loaded */}
|
||||
<EventTypeSchedule
|
||||
customClassNames={customClassNames?.userAvailability}
|
||||
eventType={eventType}
|
||||
{...rest}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
{useHostSchedulesForTeamEvent && (
|
||||
<div className="lg:ml-14">
|
||||
<TeamAvailability
|
||||
teamMembers={rest.teamMembers}
|
||||
hostSchedulesQuery={rest.hostSchedulesQuery}
|
||||
customClassNames={customClassNames?.teamAvailability}
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<SettingsToggle
|
||||
checked={!useHostSchedulesForTeamEvent}
|
||||
onCheckedChange={toggleScheduleState}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
<EventTypeSchedule
|
||||
customClassNames={customClassNames?.userAvailability}
|
||||
eventType={eventType}
|
||||
fieldName="schedule"
|
||||
{...rest}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
{useHostSchedulesForTeamEvent && (
|
||||
<div className="lg:ml-14">
|
||||
<TeamAvailability
|
||||
teamMembers={rest.teamMembers}
|
||||
hostSchedulesQuery={rest.hostSchedulesQuery}
|
||||
customClassNames={customClassNames?.teamAvailability}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isPlatform && isRestrictionScheduleEnabled ? (
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<SettingsToggle
|
||||
checked={restrictScheduleForHosts}
|
||||
onCheckedChange={toggleRestrictScheduleState}
|
||||
title={t("choose_restriction_schedule")}
|
||||
description={t("choose_restriction_schedule_description")}>
|
||||
<EventTypeSchedule
|
||||
customClassNames={customClassNames?.userAvailability}
|
||||
eventType={eventType}
|
||||
fieldName="restrictionSchedule"
|
||||
{...rest}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -611,7 +880,7 @@ const UseCommonScheduleSettingsToggle = ({
|
||||
|
||||
export const EventAvailabilityTab = ({ eventType, isTeamEvent, ...rest }: EventAvailabilityTabProps) => {
|
||||
return isTeamEvent && eventType.schedulingType !== SchedulingType.MANAGED ? (
|
||||
<UseCommonScheduleSettingsToggle eventType={eventType} {...rest} />
|
||||
<UseTeamEventScheduleSettingsToggle eventType={eventType} {...rest} />
|
||||
) : (
|
||||
<EventTypeSchedule
|
||||
eventType={eventType}
|
||||
|
||||
@@ -152,10 +152,15 @@ export type FormValues = {
|
||||
secondaryEmailId?: number;
|
||||
isRRWeightsEnabled: boolean;
|
||||
maxLeadThreshold?: number;
|
||||
restrictionScheduleId: number | null;
|
||||
useBookerTimezone: boolean;
|
||||
restrictionScheduleName: string | null;
|
||||
calVideoSettings?: {
|
||||
disableRecordingForOrganizer?: boolean;
|
||||
disableRecordingForGuests?: boolean;
|
||||
enableAutomaticTranscription?: boolean;
|
||||
disableTranscriptionForGuests?: boolean;
|
||||
disableTranscriptionForOrganizer?: boolean;
|
||||
redirectUrlOnExit?: string;
|
||||
};
|
||||
maxActiveBookingPerBookerOfferReschedule: boolean;
|
||||
|
||||
@@ -21,4 +21,5 @@ export type AppFlags = {
|
||||
"workflow-smtp-emails": boolean;
|
||||
"cal-video-log-in-overlay": boolean;
|
||||
"use-api-v2-for-team-slots": boolean;
|
||||
"restriction-schedule": boolean;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const initialData: AppFlags = {
|
||||
"workflow-smtp-emails": false,
|
||||
"cal-video-log-in-overlay": false,
|
||||
"use-api-v2-for-team-slots": false,
|
||||
"restriction-schedule": false,
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
@@ -9,5 +12,16 @@ export const featureFlagRouter = router({
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
return featuresRepository.getAllFeatures();
|
||||
}),
|
||||
checkTeamFeature: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.number(),
|
||||
feature: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
return featuresRepository.checkIfTeamHasFeature(input.teamId, input.feature as keyof AppFlags);
|
||||
}),
|
||||
map,
|
||||
});
|
||||
|
||||
@@ -462,28 +462,73 @@ class EventsInsights {
|
||||
noShow: true,
|
||||
},
|
||||
},
|
||||
seatsReferences: {
|
||||
select: {
|
||||
attendee: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
noShow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bookingMap = new Map(bookings.map((booking) => [booking.uid, booking.attendees[0] || null]));
|
||||
const bookingMap = new Map(
|
||||
bookings.map((booking) => {
|
||||
const attendeeList =
|
||||
booking.seatsReferences.length > 0
|
||||
? booking.seatsReferences.map((ref) => ref.attendee)
|
||||
: booking.attendees;
|
||||
|
||||
const formattedAttendees = attendeeList
|
||||
.slice(0, 3)
|
||||
.map((attendee) => (attendee ? `${attendee.name} (${attendee.email})` : null));
|
||||
|
||||
return [
|
||||
booking.uid,
|
||||
{
|
||||
noShowGuest: attendeeList[0]?.noShow || false,
|
||||
attendee1: formattedAttendees[0] || null,
|
||||
attendee2: formattedAttendees[1] || null,
|
||||
attendee3: formattedAttendees[2] || null,
|
||||
},
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const data = csvData.map((bookingTimeStatus) => {
|
||||
if (!bookingTimeStatus.uid) {
|
||||
// should not be reached because we filtered above
|
||||
return bookingTimeStatus;
|
||||
return {
|
||||
...bookingTimeStatus,
|
||||
noShowGuest: false,
|
||||
attendee1: null,
|
||||
attendee2: null,
|
||||
attendee3: null,
|
||||
};
|
||||
}
|
||||
|
||||
const booker = bookingMap.get(bookingTimeStatus.uid);
|
||||
const attendeeData = bookingMap.get(bookingTimeStatus.uid);
|
||||
|
||||
if (!booker) {
|
||||
return bookingTimeStatus;
|
||||
if (!attendeeData) {
|
||||
return {
|
||||
...bookingTimeStatus,
|
||||
noShowGuest: false,
|
||||
attendee1: null,
|
||||
attendee2: null,
|
||||
attendee3: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...bookingTimeStatus,
|
||||
noShowGuest: booker.noShow,
|
||||
bookerEmail: booker.email,
|
||||
bookerName: booker.name,
|
||||
noShowGuest: attendeeData.noShowGuest,
|
||||
attendee1: attendeeData.attendee1,
|
||||
attendee2: attendeeData.attendee2,
|
||||
attendee3: attendeeData.attendee3,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ const DateOverrideList = ({
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
tooltip={t("edit")}
|
||||
className="text-default"
|
||||
className="text-default h-5"
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="pencil"
|
||||
@@ -116,7 +116,7 @@ const DateOverrideList = ({
|
||||
/>
|
||||
<Tooltip content="Delete">
|
||||
<Button
|
||||
className="text-default"
|
||||
className="text-default h-5"
|
||||
data-testid="delete-button"
|
||||
title={t("date_overrides_delete_on_date", {
|
||||
date: new Intl.DateTimeFormat(i18n.language, {
|
||||
|
||||
@@ -127,6 +127,7 @@ export function ScheduleListItem({
|
||||
color="destructive"
|
||||
StartIcon="trash"
|
||||
data-testid="delete-schedule"
|
||||
className="rounded-t-none"
|
||||
onClick={() => {
|
||||
if (!isDeletable) {
|
||||
showToast(t("requires_at_least_one_schedule"), "error");
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -545,13 +545,13 @@ describe("buildDateRanges", () => {
|
||||
});
|
||||
|
||||
expect(dateRanges[0]).toEqual({
|
||||
start: dayjs("2023-06-13T14:00:00Z").tz(timeZone),
|
||||
end: dayjs("2023-06-13T19:00:00Z").tz(timeZone),
|
||||
start: dayjs.utc("2023-06-13T14:00:00Z").tz(timeZone),
|
||||
end: dayjs.utc("2023-06-13T19:00:00Z").tz(timeZone),
|
||||
});
|
||||
|
||||
expect(dateRanges[1]).toEqual({
|
||||
start: dayjs("2023-06-14T12:00:00Z").tz(timeZone),
|
||||
end: dayjs("2023-06-14T21:00:00Z").tz(timeZone),
|
||||
start: dayjs.utc("2023-06-14T12:00:00Z").tz(timeZone),
|
||||
end: dayjs.utc("2023-06-14T21:00:00Z").tz(timeZone),
|
||||
});
|
||||
expect(oooExcludedDateRanges.length).toBe(1);
|
||||
expect(oooExcludedDateRanges[0]).toEqual({
|
||||
|
||||
@@ -132,12 +132,9 @@ export function processDateOverride({
|
||||
};
|
||||
}
|
||||
|
||||
// This function processes out-of-office dates and returns a date range for each OOO date.
|
||||
function processOOO(outOfOffice: Dayjs, timeZone: string) {
|
||||
const utcOffset = outOfOffice.tz(timeZone).utcOffset();
|
||||
const utcDate = outOfOffice.subtract(utcOffset, "minute");
|
||||
|
||||
const OOOdate = utcDate.tz(timeZone);
|
||||
|
||||
const OOOdate = outOfOffice.tz(timeZone, true);
|
||||
return {
|
||||
start: OOOdate,
|
||||
end: OOOdate,
|
||||
@@ -171,7 +168,7 @@ export function buildDateRanges({
|
||||
}, [])
|
||||
);
|
||||
const OOOdates = outOfOffice
|
||||
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs(outOfOffice), timeZone))
|
||||
? Object.keys(outOfOffice).map((outOfOffice) => processOOO(dayjs.utc(outOfOffice), timeZone))
|
||||
: [];
|
||||
|
||||
const groupedOOO = groupByDate(OOOdates);
|
||||
|
||||
@@ -135,6 +135,8 @@ const commons = {
|
||||
rrTimestampBasis: null,
|
||||
interfaceLanguage: null,
|
||||
customReplyToEmail: null,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
profileId: null,
|
||||
requiresConfirmationWillBlockSlot: false,
|
||||
canSendCalVideoTranscriptionEmails: false,
|
||||
|
||||
@@ -20,4 +20,6 @@ export enum ErrorCode {
|
||||
BookingConflict = "booking_conflict_error",
|
||||
BookerLimitExceeded = "booker_limit_exceeded_error",
|
||||
BookerLimitExceededReschedule = "booker_limit_exceeded_error_reschedule",
|
||||
BookingNotAllowedByRestrictionSchedule = "booking_not_allowed_by_restriction_schedule_error",
|
||||
RestrictionScheduleNotFound = "restriction_schedule_not_found_error",
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export const getEventTypeById = async ({
|
||||
rawEventType.schedule?.id ||
|
||||
(!rawEventType.team ? rawEventType.users[0]?.defaultScheduleId : null) ||
|
||||
null,
|
||||
restrictionScheduleId: rawEventType.restrictionScheduleId || null,
|
||||
restrictionScheduleName: rawEventType.restrictionSchedule?.name || null,
|
||||
useBookerTimezone: rawEventType.useBookerTimezone || false,
|
||||
instantMeetingSchedule: rawEventType.instantMeetingSchedule?.id || null,
|
||||
scheduleName: rawEventType.schedule?.name || null,
|
||||
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
|
||||
|
||||
@@ -24,8 +24,6 @@ type UserWithProfile = UserWithoutProfile & {
|
||||
profile: Profile | null;
|
||||
};
|
||||
|
||||
type OrgSlug = string | null;
|
||||
|
||||
/**
|
||||
* Determines if branding should be hidden by checking entity and organization settings
|
||||
*/
|
||||
@@ -79,12 +77,12 @@ export async function shouldHideBrandingForEvent({
|
||||
eventTypeId,
|
||||
team,
|
||||
owner,
|
||||
orgSlug,
|
||||
organizationId,
|
||||
}: {
|
||||
eventTypeId: number;
|
||||
team: Team | null;
|
||||
owner: UserWithoutProfile | null;
|
||||
orgSlug: OrgSlug;
|
||||
organizationId: number | null;
|
||||
}) {
|
||||
let ownerProfile = null;
|
||||
if (team) {
|
||||
@@ -94,10 +92,10 @@ export async function shouldHideBrandingForEvent({
|
||||
});
|
||||
} else if (owner) {
|
||||
// Needed only for User events, not for Team events
|
||||
ownerProfile = orgSlug
|
||||
ownerProfile = organizationId
|
||||
? await ProfileRepository.findByUserIdAndOrgSlug({
|
||||
userId: owner.id,
|
||||
orgSlug,
|
||||
organizationId,
|
||||
})
|
||||
: null;
|
||||
|
||||
|
||||
@@ -5,3 +5,8 @@ export const useBookerUrl = () => {
|
||||
const orgBranding = useOrgBranding();
|
||||
return orgBranding?.fullDomain ?? WEBSITE_URL ?? WEBAPP_URL;
|
||||
};
|
||||
|
||||
export const useEmbedBookerUrl = () => {
|
||||
const orgBranding = useOrgBranding();
|
||||
return orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
};
|
||||
|
||||
@@ -58,6 +58,17 @@ export default class LimitManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges busy times from another LimitManager
|
||||
*/
|
||||
mergeBusyTimes(otherManager: LimitManager) {
|
||||
otherManager.busyMap.forEach((busyTime, key) => {
|
||||
if (!this.busyMap.has(key)) {
|
||||
this.busyMap.set(key, busyTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all busy times
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
|
||||
/**
|
||||
* Checks if the restriction schedule feature is enabled for a team
|
||||
* @param teamId - The team ID to check
|
||||
* @returns Promise<boolean> - True if the feature is enabled, false otherwise
|
||||
*/
|
||||
export async function isRestrictionScheduleEnabled(teamId?: number): Promise<boolean> {
|
||||
if (!teamId) {
|
||||
return false; // Personal events don't have restriction schedules
|
||||
}
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
return await featureRepo.checkIfTeamHasFeature(teamId, "restriction-schedule");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
export const eventTypeSelect = {
|
||||
id: true,
|
||||
teamId: true,
|
||||
schedulingType: true,
|
||||
@@ -71,4 +71,29 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
maxLeadThreshold: true,
|
||||
useEventLevelSelectedCalendars: true,
|
||||
customReplyToEmail: true,
|
||||
});
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
instantMeetingSchedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.EventTypeSelect;
|
||||
|
||||
// Create a separate select for schedule-related fields
|
||||
export const eventTypeScheduleSelect = {
|
||||
...eventTypeSelect,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
restrictionSchedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.EventTypeSelect;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
|
||||
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
|
||||
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
|
||||
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
|
||||
import { getRoutedUrl } from "./getRoutedUrl";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
|
||||
vi.mock("@calcom/lib/server/repository/routingForm");
|
||||
vi.mock("@calcom/lib/server/repository/user");
|
||||
vi.mock("@calcom/features/ee/organizations/lib/orgDomains");
|
||||
vi.mock("@calcom/features/routing-forms/lib/isAuthorizedToViewForm");
|
||||
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/processRoute");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/substituteVariables");
|
||||
vi.mock("@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward");
|
||||
vi.mock("@calcom/app-store/routing-forms/getEventTypeRedirectUrl");
|
||||
vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({
|
||||
enrichFormWithMigrationData: vi.fn((form) => form),
|
||||
}));
|
||||
vi.mock("@calcom/lib/sentryWrapper", () => ({
|
||||
withReporting: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
const mockForm = {
|
||||
id: "form-id",
|
||||
user: { id: 1, name: "Test User" },
|
||||
team: null,
|
||||
name: "Test Form",
|
||||
fields: [],
|
||||
routes: [],
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
description: null,
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
fieldsOrder: [],
|
||||
position: 0,
|
||||
slug: "test-form",
|
||||
};
|
||||
|
||||
const mockSerializableForm = {
|
||||
id: "form-id",
|
||||
fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }],
|
||||
routes: [],
|
||||
user: { id: 1 },
|
||||
};
|
||||
|
||||
const mockContext = (
|
||||
query: Record<string, unknown>,
|
||||
url = "/link/form-id"
|
||||
): Pick<GetServerSidePropsContext, "query" | "req"> => ({
|
||||
query: { form: "form-id", ...query },
|
||||
req: { url },
|
||||
});
|
||||
|
||||
describe("getRoutedUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Provide default mock implementations
|
||||
vi.mocked(orgDomainConfig).mockReturnValue({ currentOrgDomain: null });
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
|
||||
vi.mocked(UserRepository.enrichUserWithItsProfile).mockImplementation(async ({ user }) => user);
|
||||
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true);
|
||||
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(null);
|
||||
vi.mocked(handleResponse).mockResolvedValue({
|
||||
teamMembersMatchingAttributeLogic: null,
|
||||
formResponse: { id: 1 },
|
||||
queuedFormResponse: null,
|
||||
attributeRoutingConfig: null,
|
||||
timeTaken: {},
|
||||
crmContactOwnerEmail: null,
|
||||
crmContactOwnerRecordType: null,
|
||||
crmAppSlug: null,
|
||||
isPreview: false,
|
||||
});
|
||||
vi.mocked(substituteVariables).mockImplementation((value) => value);
|
||||
vi.mocked(getUrlSearchParamsToForward).mockReturnValue(new URLSearchParams());
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockImplementation(
|
||||
({ eventTypeRedirectUrl }) => eventTypeRedirectUrl
|
||||
);
|
||||
vi.mocked(getResponseToStore).mockReturnValue({ email: "test@cal.com" });
|
||||
});
|
||||
|
||||
it("should return notFound if query params are invalid", async () => {
|
||||
const context = {
|
||||
query: { wrong_param: "value" }, // 'form' is missing
|
||||
req: { url: "/link/form-id" },
|
||||
};
|
||||
// @ts-expect-error we are testing invalid input
|
||||
const result = await getRoutedUrl(context);
|
||||
expect(result).toEqual({ notFound: true });
|
||||
});
|
||||
|
||||
it("should return notFound if form is not found", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
|
||||
const context = mockContext({});
|
||||
const result = await getRoutedUrl(context);
|
||||
expect(result).toEqual({ notFound: true });
|
||||
expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id");
|
||||
});
|
||||
|
||||
it("should return notFound if user is not authorized", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(false);
|
||||
|
||||
const context = mockContext({});
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({ notFound: true });
|
||||
expect(isAuthorizedToViewFormOnOrgDomain).toHaveBeenCalledWith({
|
||||
user: mockForm.user,
|
||||
team: mockForm.team,
|
||||
currentOrgDomain: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw an error if no matching route is found", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
vi.mocked(getSerializableForm).mockResolvedValue(mockSerializableForm as never);
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(null);
|
||||
|
||||
const context = mockContext({});
|
||||
await expect(getRoutedUrl(context)).rejects.toThrow("No matching route could be found");
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should return props with a message for customPageMessage action", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const message = "This is a custom message";
|
||||
const mockRoute = { id: "route1", action: { type: "customPageMessage", value: message } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
|
||||
const context = mockContext({});
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(result).toEqual({
|
||||
props: {
|
||||
isEmbed: false,
|
||||
form: mockSerializableForm,
|
||||
message: message,
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
expect(handleResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should return a redirect for eventTypeRedirectUrl action and substitute variables", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "test-user/30min/{email}";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
const substitutedUrl = "test-user/30min/test@cal.com";
|
||||
vi.mocked(substituteVariables).mockReturnValue(substitutedUrl);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${substitutedUrl}`);
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
expect(substituteVariables).toHaveBeenCalledWith(
|
||||
redirectUrl,
|
||||
{ email: "test@cal.com" },
|
||||
mockSerializableForm.fields
|
||||
);
|
||||
expect(getUrlSearchParamsToForward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchParams: expect.any(URLSearchParams),
|
||||
formResponse: { email: "test@cal.com" },
|
||||
fields: mockSerializableForm.fields,
|
||||
})
|
||||
);
|
||||
expect(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
eventTypeRedirectUrl: substitutedUrl,
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: `/${substitutedUrl}`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a redirect for externalRedirectUrl action", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "https://example.com";
|
||||
const mockRoute = { id: "route1", action: { type: "externalRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const result = await getRoutedUrl(context);
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
...(context.query as Record<string, string>),
|
||||
"cal.action": "externalRedirectUrl",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
redirect: {
|
||||
destination: `${redirectUrl}?${searchParams.toString()}`,
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
expect(findMatchingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
form: mockSerializableForm,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleResponse with correct response prop obtained from getResponseToStore", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "test-user/30min";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
|
||||
vi.mocked(getResponseToStore).mockReturnValue({ "xxx-xxx": { value: "test@cal.com" } } as never);
|
||||
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
await getRoutedUrl(context);
|
||||
|
||||
expect(getResponseToStore).toHaveBeenCalledWith({
|
||||
formFields: mockSerializableForm.fields,
|
||||
fieldsResponses: { email: "test@cal.com" },
|
||||
});
|
||||
|
||||
expect(handleResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ response: { "xxx-xxx": { value: "test@cal.com" } } })
|
||||
);
|
||||
});
|
||||
|
||||
describe("Dry Run", () => {
|
||||
it("should call handleResponse with isPreview:true", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
const redirectUrl = "test-user/30min";
|
||||
const mockRoute = { id: "route1", action: { type: "eventTypeRedirectUrl", value: redirectUrl } };
|
||||
vi.mocked(findMatchingRoute).mockReturnValue(mockRoute as never);
|
||||
vi.mocked(getAbsoluteEventTypeRedirectUrlWithEmbedSupport).mockReturnValue(`/${redirectUrl}`);
|
||||
|
||||
const context = mockContext({ "cal.isBookingDryRun": "true" });
|
||||
await getRoutedUrl(context);
|
||||
|
||||
expect(handleResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isPreview: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
// !IMPORTANT! changes to this file requires publishing new version of platform libraries in order for the changes to be applied to APIV2
|
||||
import { createHash } from "crypto";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { stringify } from "querystring";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -6,17 +7,17 @@ import z from "zod";
|
||||
|
||||
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
|
||||
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
||||
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
|
||||
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
||||
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
||||
import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader";
|
||||
import { handleResponse } from "@calcom/app-store/routing-forms/lib/handleResponse";
|
||||
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
|
||||
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
|
||||
import { getFieldResponseForJsonLogic } from "@calcom/app-store/routing-forms/lib/transformResponse";
|
||||
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
@@ -31,6 +32,18 @@ const querySchema = z
|
||||
})
|
||||
.catchall(z.string().or(z.array(z.string())));
|
||||
|
||||
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
|
||||
const sortedFields = Object.keys(fieldsResponses)
|
||||
.sort()
|
||||
.reduce((obj: Record<string, unknown>, key) => {
|
||||
obj[key] = fieldsResponses[key];
|
||||
return obj;
|
||||
}, {});
|
||||
const paramsString = JSON.stringify(sortedFields);
|
||||
const hash = createHash("sha256").update(paramsString).digest("hex");
|
||||
return hash;
|
||||
};
|
||||
|
||||
function hasEmbedPath(pathWithQuery: string) {
|
||||
const onlyPath = pathWithQuery.split("?")[0];
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
@@ -52,8 +65,21 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
|
||||
// TODO: Known params reserved by Cal.com are form, embed, layout and other cal. prefixed params. We should exclude all of them from fieldsResponses.
|
||||
// But they must be present in `paramsToBeForwardedAsIs` as they could be needed by Booking Page as well.
|
||||
const { form: formId, "cal.isBookingDryRun": isBookingDryRunParam, ...fieldsResponses } = queryParsed.data;
|
||||
const {
|
||||
form: formId,
|
||||
"cal.isBookingDryRun": isBookingDryRunParam,
|
||||
"cal.queueFormResponse": queueFormResponseParam,
|
||||
...fieldsResponses
|
||||
} = queryParsed.data;
|
||||
|
||||
const responseHash = getDeterministicHashForResponse(fieldsResponses);
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: `form:${formId}:hash:${responseHash}`,
|
||||
});
|
||||
|
||||
const isBookingDryRun = isBookingDryRunParam === "true";
|
||||
const shouldQueueFormResponse = queueFormResponseParam === "true";
|
||||
const paramsToBeForwardedAsIs = {
|
||||
...fieldsResponses,
|
||||
// Must be forwarded if present to Booking Page. Setting it explicitly here as it is critical to be present in the URL.
|
||||
@@ -95,21 +121,15 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
});
|
||||
timeTaken.getSerializableForm = performance.now() - getSerializableFormStart;
|
||||
|
||||
const response: FormResponse = {};
|
||||
if (!serializableForm.fields) {
|
||||
throw new Error("Form has no fields");
|
||||
}
|
||||
serializableForm.fields.forEach((field) => {
|
||||
const fieldResponse = fieldsResponses[getFieldIdentifier(field)] || "";
|
||||
|
||||
response[field.id] = {
|
||||
label: field.label,
|
||||
value: getFieldResponseForJsonLogic({ field, value: fieldResponse }),
|
||||
};
|
||||
const response: FormResponse = getResponseToStore({
|
||||
formFields: serializableForm.fields,
|
||||
fieldsResponses,
|
||||
});
|
||||
|
||||
const matchingRoute = findMatchingRoute({ form: serializableForm, response });
|
||||
|
||||
if (!matchingRoute) {
|
||||
throw new Error("No matching route could be found");
|
||||
}
|
||||
@@ -122,6 +142,7 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
let crmContactOwnerEmail: string | null = null;
|
||||
let crmContactOwnerRecordType: string | null = null;
|
||||
let crmAppSlug: string | null = null;
|
||||
let queuedFormResponseId;
|
||||
try {
|
||||
const result = await handleResponse({
|
||||
form: serializableForm,
|
||||
@@ -129,9 +150,11 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
response: response,
|
||||
chosenRouteId: matchingRoute.id,
|
||||
isPreview: isBookingDryRun,
|
||||
queueFormResponse: shouldQueueFormResponse,
|
||||
});
|
||||
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
|
||||
formResponseId = result.formResponse.id;
|
||||
formResponseId = result.formResponse?.id;
|
||||
queuedFormResponseId = result.queuedFormResponse?.id;
|
||||
attributeRoutingConfig = result.attributeRoutingConfig;
|
||||
timeTaken = {
|
||||
...timeTaken,
|
||||
@@ -184,8 +207,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
stringify({ ...paramsToBeForwardedAsIs, "cal.action": "eventTypeRedirectUrl" })
|
||||
),
|
||||
teamMembersMatchingAttributeLogic,
|
||||
// formResponseId is guaranteed to be set because in catch block of trpc request we return from the function and otherwise it would have been set
|
||||
formResponseId: formResponseId!,
|
||||
formResponseId: formResponseId ?? null,
|
||||
queuedFormResponseId: queuedFormResponseId ?? null,
|
||||
attributeRoutingConfig: attributeRoutingConfig ?? null,
|
||||
teamId: form?.teamId,
|
||||
orgId: form.team?.parentId,
|
||||
|
||||
@@ -394,6 +394,8 @@ export class BookingRepository {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
disableTranscriptionForGuests: true,
|
||||
disableTranscriptionForOrganizer: true,
|
||||
redirectUrlOnExit: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,6 +15,8 @@ export class CalVideoSettingsRepository {
|
||||
calVideoSettings: {
|
||||
disableRecordingForGuests?: boolean | null;
|
||||
disableRecordingForOrganizer?: boolean | null;
|
||||
disableTranscriptionForGuests?: boolean | null;
|
||||
disableTranscriptionForOrganizer?: boolean | null;
|
||||
enableAutomaticTranscription?: boolean | null;
|
||||
redirectUrlOnExit?: string | null;
|
||||
};
|
||||
@@ -25,6 +27,8 @@ export class CalVideoSettingsRepository {
|
||||
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
|
||||
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
|
||||
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
|
||||
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
|
||||
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
|
||||
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
@@ -32,6 +36,8 @@ export class CalVideoSettingsRepository {
|
||||
disableRecordingForGuests: calVideoSettings.disableRecordingForGuests ?? false,
|
||||
disableRecordingForOrganizer: calVideoSettings.disableRecordingForOrganizer ?? false,
|
||||
enableAutomaticTranscription: calVideoSettings.enableAutomaticTranscription ?? false,
|
||||
disableTranscriptionForGuests: calVideoSettings.disableTranscriptionForGuests ?? false,
|
||||
disableTranscriptionForOrganizer: calVideoSettings.disableTranscriptionForOrganizer ?? false,
|
||||
redirectUrlOnExit: calVideoSettings.redirectUrlOnExit ?? null,
|
||||
eventTypeId,
|
||||
},
|
||||
|
||||
@@ -592,6 +592,8 @@ export class EventTypeRepository {
|
||||
},
|
||||
},
|
||||
},
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
users: {
|
||||
select: userSelect,
|
||||
},
|
||||
@@ -608,6 +610,12 @@ export class EventTypeRepository {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
restrictionSchedule: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
isFixed: true,
|
||||
@@ -698,6 +706,8 @@ export class EventTypeRepository {
|
||||
disableRecordingForGuests: true,
|
||||
disableRecordingForOrganizer: true,
|
||||
enableAutomaticTranscription: true,
|
||||
disableTranscriptionForGuests: true,
|
||||
disableTranscriptionForOrganizer: true,
|
||||
redirectUrlOnExit: true,
|
||||
},
|
||||
},
|
||||
@@ -840,6 +850,8 @@ export class EventTypeRepository {
|
||||
maxLeadThreshold: true,
|
||||
includeNoShowInRRCalculation: true,
|
||||
useEventLevelSelectedCalendars: true,
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
interface RecordFormResponseInput {
|
||||
formId: string;
|
||||
response: Record<string, any> | Prisma.JsonValue;
|
||||
chosenRouteId: string | null;
|
||||
}
|
||||
|
||||
export class RoutingFormResponseRepository {
|
||||
private static generateCreateFormResponseData(
|
||||
input: RecordFormResponseInput & { queuedFormResponseId?: string | null }
|
||||
) {
|
||||
return {
|
||||
formId: input.formId,
|
||||
response: input.response as Prisma.InputJsonValue,
|
||||
chosenRouteId: input.chosenRouteId,
|
||||
...(input.queuedFormResponseId
|
||||
? {
|
||||
queuedFormResponse: {
|
||||
connect: {
|
||||
id: input.queuedFormResponseId,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
static async recordFormResponse(
|
||||
input: RecordFormResponseInput & {
|
||||
queuedFormResponseId?: string | null;
|
||||
}
|
||||
) {
|
||||
return await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: this.generateCreateFormResponseData(input),
|
||||
});
|
||||
}
|
||||
|
||||
static async recordQueuedFormResponse(input: RecordFormResponseInput) {
|
||||
return await prisma.app_RoutingForms_QueuedFormResponse.create({
|
||||
data: this.generateCreateFormResponseData(input),
|
||||
});
|
||||
}
|
||||
|
||||
static async findFormResponseIncludeForm({ routingFormResponseId }: { routingFormResponseId: number }) {
|
||||
return await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
where: {
|
||||
id: routingFormResponseId,
|
||||
},
|
||||
select: {
|
||||
response: true,
|
||||
form: {
|
||||
select: {
|
||||
routes: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
chosenRouteId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findQueuedFormResponseIncludeForm({ queuedFormResponseId }: { queuedFormResponseId: string }) {
|
||||
return await prisma.app_RoutingForms_QueuedFormResponse.findUnique({
|
||||
where: {
|
||||
id: queuedFormResponseId,
|
||||
},
|
||||
select: {
|
||||
response: true,
|
||||
form: {
|
||||
select: {
|
||||
routes: true,
|
||||
fields: true,
|
||||
},
|
||||
},
|
||||
chosenRouteId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getQueuedFormResponseFromId(id: string) {
|
||||
return await prisma.app_RoutingForms_QueuedFormResponse.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
formId: true,
|
||||
response: true,
|
||||
chosenRouteId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
actualResponseId: true,
|
||||
form: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
id: true,
|
||||
description: true,
|
||||
position: true,
|
||||
routes: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
fields: true,
|
||||
updatedById: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
disabled: true,
|
||||
settings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma";
|
||||
import { availabilityUserSelect, prisma, type PrismaTransaction, type PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
@@ -70,6 +70,37 @@ const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipW
|
||||
};
|
||||
|
||||
export class MembershipRepository {
|
||||
constructor(private readonly prismaClient: PrismaClient = prisma) {}
|
||||
|
||||
async hasMembership({ userId, teamId }: { userId: number; teamId: number }): Promise<boolean> {
|
||||
const membership = await this.prismaClient.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return !!membership;
|
||||
}
|
||||
|
||||
async listAcceptedTeamMemberIds({ teamId }: { teamId: number }): Promise<number[]> {
|
||||
const memberships =
|
||||
(await this.prismaClient.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
})) || [];
|
||||
const teamMemberIds = memberships.map((membership) => membership.userId);
|
||||
return teamMemberIds;
|
||||
}
|
||||
|
||||
static async create(data: IMembership) {
|
||||
return await prisma.membership.create({
|
||||
data: {
|
||||
|
||||
@@ -665,9 +665,20 @@ export class ProfileRepository {
|
||||
return normalizeProfile(profile);
|
||||
}
|
||||
|
||||
static async findByUserIdAndOrgSlug({ userId, orgSlug }: { userId: number; orgSlug: string }) {
|
||||
const profile = await prisma.profile.findFirst({
|
||||
where: { userId, organization: { slug: orgSlug } },
|
||||
static async findByUserIdAndOrgSlug({
|
||||
userId,
|
||||
organizationId,
|
||||
}: {
|
||||
userId: number;
|
||||
organizationId: number;
|
||||
}) {
|
||||
const profile = await prisma.profile.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
select: organizationSelect,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
|
||||
|
||||
@@ -13,6 +14,56 @@ export type FindDetailedScheduleByIdReturnType = Awaited<
|
||||
>;
|
||||
|
||||
export class ScheduleRepository {
|
||||
// when instantiating, prismaClient injection is required
|
||||
constructor(private prismaClient: PrismaClient) {}
|
||||
|
||||
async findScheduleByIdForBuildDateRanges({ scheduleId }: { scheduleId: number }) {
|
||||
const schedule = await this.prismaClient.schedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
userId: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
defaultScheduleId: true,
|
||||
travelSchedules: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async findScheduleByIdForOwnershipCheck({ scheduleId }: { scheduleId: number }) {
|
||||
const schedule = await this.prismaClient.schedule.findUnique({
|
||||
where: {
|
||||
id: scheduleId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
return schedule;
|
||||
}
|
||||
|
||||
static async findScheduleById({ id }: { id: number }) {
|
||||
const schedule = await prisma.schedule.findUnique({
|
||||
where: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user