Compare commits
31 Commits
fix/organi
...
main
Author | SHA1 | Date |
---|---|---|
Keith Williams | 51fd4102ae | |
Ritesh Kumar | 9d1ef0a649 | |
gitstart-app[bot] | f80dc0738a | |
Peer Richelsen | 678ab3f453 | |
Syed Ali Shahbaz | 199d3e4c3f | |
Siddharth Movaliya | 79a6aef0e7 | |
Udit Takkar | 4d49fb0636 | |
Udit Takkar | 0be1387d0f | |
Morgan | 58ab278813 | |
Alex van Andel | 4de142cb7c | |
Hariom Balhara | b4d27a9326 | |
sean-brydon | 31f3d9778e | |
sean-brydon | 0a59c95b93 | |
Hariom Balhara | 9e3465eeb6 | |
Hariom Balhara | 31fc4724e0 | |
Hariom Balhara | f81f0a26ec | |
Hariom Balhara | 9a80bb6194 | |
Udit Takkar | 901fc36c97 | |
Joe Au-Yeung | 2831fb2b57 | |
Hariom Balhara | 426d31712e | |
Carina Wollendorfer | 09ecd445bb | |
Carina Wollendorfer | 08d65c85de | |
Peer Richelsen | b9cef10ef2 | |
Manish Singh Bisht | 0dc41592f2 | |
Leo Giovanetti | aabf3c54ea | |
Carina Wollendorfer | c2a57fd72b | |
Carina Wollendorfer | 52386e08f2 | |
Keith Williams | b724d367fc | |
gitstart-app[bot] | 07924751ad | |
Siddharth Movaliya | defa8df7ca | |
Hariom Balhara | bf8580fa88 |
|
@ -1,18 +0,0 @@
|
|||
name: Auto Comment Merge Conflicts
|
||||
on: push
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-comment-merge-conflicts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: codytseng/auto-comment-merge-conflicts@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-body: "Hey there, there is a merge conflict, can you take a look?"
|
||||
wait-ms: 3000
|
||||
max-retries: 5
|
||||
label-name: "🚨 merge conflict"
|
||||
ignore-authors: dependabot,otherAuthor
|
|
@ -0,0 +1,4 @@
|
|||
# Checkly Tests
|
||||
|
||||
Run as `yarn checkly test`
|
||||
Deploy the tests as `yarn checkly deploy`
|
|
@ -0,0 +1,53 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Org", () => {
|
||||
// Because these pages involve next.config.js rewrites, it's better to test them on production
|
||||
test.describe("Embeds - i.cal.com", () => {
|
||||
test("Org Profile Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.screenshot({ path: "screenshot.jpg" });
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/peer/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/peer/meet/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/sales/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/sales/hipaa/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
|
||||
async function expectPageToBeServerSideRendered(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
return window.__NEXT_DATA__.props.pageProps.isEmbed;
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
|
@ -3,7 +3,6 @@ import { z } from "zod";
|
|||
import { _DestinationCalendarModel as DestinationCalendar } from "@calcom/prisma/zod";
|
||||
|
||||
export const schemaDestinationCalendarBaseBodyParams = DestinationCalendar.pick({
|
||||
credentialId: true,
|
||||
integration: true,
|
||||
externalId: true,
|
||||
eventTypeId: true,
|
||||
|
@ -15,7 +14,6 @@ const schemaDestinationCalendarCreateParams = z
|
|||
.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
credentialId: z.number(),
|
||||
eventTypeId: z.number().optional(),
|
||||
bookingId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
|
@ -47,5 +45,4 @@ export const schemaDestinationCalendarReadPublic = DestinationCalendar.pick({
|
|||
eventTypeId: true,
|
||||
bookingId: true,
|
||||
userId: true,
|
||||
credentialId: true,
|
||||
});
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
import {
|
||||
schemaDestinationCalendarEditBodyParams,
|
||||
|
@ -56,16 +62,251 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
|||
* 404:
|
||||
* description: Destination calendar not found
|
||||
*/
|
||||
type DestinationCalendarType = {
|
||||
userId?: number | null;
|
||||
eventTypeId?: number | null;
|
||||
credentialId: number | null;
|
||||
};
|
||||
|
||||
type UserCredentialType = {
|
||||
id: number;
|
||||
appId: string | null;
|
||||
type: string;
|
||||
userId: number | null;
|
||||
user: {
|
||||
email: string;
|
||||
} | null;
|
||||
teamId: number | null;
|
||||
key: Prisma.JsonValue;
|
||||
invalid: boolean | null;
|
||||
};
|
||||
|
||||
export async function patchHandler(req: NextApiRequest) {
|
||||
const { prisma, query, body } = req;
|
||||
const { userId, isAdmin, prisma, query, body } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
||||
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
||||
|
||||
validateIntegrationInput(parsedBody);
|
||||
const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma);
|
||||
await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma });
|
||||
|
||||
const userCredentials = await getUserCredentials({
|
||||
credentialId: destinationCalendarObject.credentialId,
|
||||
userId: assignedUserId,
|
||||
prisma,
|
||||
});
|
||||
const credentialId = await verifyCredentialsAndGetId({
|
||||
parsedBody,
|
||||
userCredentials,
|
||||
currentCredentialId: destinationCalendarObject.credentialId,
|
||||
});
|
||||
// If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well
|
||||
if (parsedBody.eventTypeId) parsedBody.userId = undefined;
|
||||
const destinationCalendar = await prisma.destinationCalendar.update({
|
||||
where: { id },
|
||||
data: parsedBody,
|
||||
data: { ...parsedBody, credentialId },
|
||||
});
|
||||
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user
|
||||
*
|
||||
* @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown.
|
||||
* @param userId - The user ID against which the credentials need to be verified.
|
||||
* @param prisma - An instance of PrismaClient for database operations.
|
||||
*
|
||||
* @returns - An array containing the matching user credentials.
|
||||
*
|
||||
* @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database.
|
||||
*/
|
||||
async function getUserCredentials({
|
||||
credentialId,
|
||||
userId,
|
||||
prisma,
|
||||
}: {
|
||||
credentialId: number | null;
|
||||
userId: number;
|
||||
prisma: PrismaClient;
|
||||
}) {
|
||||
if (!credentialId) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `Destination calendar missing credential id`,
|
||||
});
|
||||
}
|
||||
const userCredentials = await prisma.credential.findMany({
|
||||
where: { id: credentialId, userId },
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (!userCredentials || userCredentials.length === 0) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Bad request, no associated credentials found`,
|
||||
});
|
||||
}
|
||||
return userCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the provided credentials and retrieves the associated credential ID.
|
||||
*
|
||||
* This function checks if the `integration` and `externalId` properties from the parsed body are present.
|
||||
* If both properties exist, it fetches the connected calendar credentials using the provided user credentials
|
||||
* and checks for a matching external ID and integration from the list of connected calendars.
|
||||
*
|
||||
* If a match is found, it updates the `credentialId` with the one from the connected calendar.
|
||||
* Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID.
|
||||
*
|
||||
* If the parsed body does not contain the necessary properties, the function
|
||||
* returns the `credentialId` from the destination calendar object.
|
||||
*
|
||||
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
||||
* Checked if it contain properties like `integration` and `externalId`.
|
||||
* @param userCredentials - An array of user credentials used to fetch the connected calendar credentials.
|
||||
* @param destinationCalendarObject - An object representing the destination calendar. Primarily used
|
||||
* to fetch the default `credentialId`.
|
||||
*
|
||||
* @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar,
|
||||
* or the provided destination calendar object in other cases.
|
||||
*
|
||||
* @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`.
|
||||
*/
|
||||
async function verifyCredentialsAndGetId({
|
||||
parsedBody,
|
||||
userCredentials,
|
||||
currentCredentialId,
|
||||
}: {
|
||||
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
||||
userCredentials: UserCredentialType[];
|
||||
currentCredentialId: number | null;
|
||||
}) {
|
||||
if (parsedBody.integration && parsedBody.externalId) {
|
||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(
|
||||
calendarCredentials,
|
||||
[],
|
||||
parsedBody.externalId
|
||||
);
|
||||
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
||||
const calendar = eligibleCalendars?.find(
|
||||
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
||||
);
|
||||
|
||||
if (!calendar?.credentialId)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, credential id invalid",
|
||||
});
|
||||
return calendar?.credentialId;
|
||||
}
|
||||
return currentCredentialId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the request for updating a destination calendar.
|
||||
*
|
||||
* This function checks the validity of the provided eventTypeId against the existing destination calendar object
|
||||
* in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided.
|
||||
*
|
||||
* It also ensures that the eventTypeId, if provided, belongs to the assigned user.
|
||||
*
|
||||
* @param destinationCalendarObject - An object representing the destination calendar.
|
||||
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
||||
* @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user.
|
||||
* @param prisma - An instance of PrismaClient for database operations.
|
||||
*
|
||||
* @throws HttpError - If the validation fails or inconsistencies are detected in the request data.
|
||||
*/
|
||||
async function validateRequestAndOwnership({
|
||||
destinationCalendarObject,
|
||||
parsedBody,
|
||||
assignedUserId,
|
||||
prisma,
|
||||
}: {
|
||||
destinationCalendarObject: DestinationCalendarType;
|
||||
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
||||
assignedUserId: number;
|
||||
prisma: PrismaClient;
|
||||
}) {
|
||||
if (parsedBody.eventTypeId) {
|
||||
if (!destinationCalendarObject.eventTypeId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `The provided destination calendar can not be linked to an event type`,
|
||||
});
|
||||
}
|
||||
|
||||
const userEventType = await prisma.eventType.findFirst({
|
||||
where: { id: parsedBody.eventTypeId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!userEventType || userEventType.userId !== assignedUserId) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `Event type with ID ${parsedBody.eventTypeId} not found`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedBody.eventTypeId) {
|
||||
if (destinationCalendarObject.eventTypeId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `The provided destination calendar can only be linked to an event type`,
|
||||
});
|
||||
}
|
||||
if (destinationCalendarObject.userId !== assignedUserId) {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: `Forbidden`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`.
|
||||
*
|
||||
* If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status
|
||||
* indicating that the desired destination calendar was not found is thrown.
|
||||
*
|
||||
* @param id - The ID of the destination calendar to be retrieved.
|
||||
* @param prisma - An instance of PrismaClient for database operations.
|
||||
*
|
||||
* @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`.
|
||||
*
|
||||
* @throws HttpError - If no destination calendar matches the provided ID.
|
||||
*/
|
||||
async function getDestinationCalendar(id: number, prisma: PrismaClient) {
|
||||
const destinationCalendarObject = await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: { userId: true, eventTypeId: true, credentialId: true },
|
||||
});
|
||||
|
||||
if (!destinationCalendarObject) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `Destination calendar with ID ${id} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return destinationCalendarObject;
|
||||
}
|
||||
|
||||
function validateIntegrationInput(parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>) {
|
||||
if (parsedBody.integration && !parsedBody.externalId) {
|
||||
throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" });
|
||||
}
|
||||
if (!parsedBody.integration && parsedBody.externalId) {
|
||||
throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" });
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultResponder(patchHandler);
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
import {
|
||||
schemaDestinationCalendarReadPublic,
|
||||
|
@ -38,9 +40,6 @@ import {
|
|||
* externalId:
|
||||
* type: string
|
||||
* description: 'The external ID of the integration'
|
||||
* credentialId:
|
||||
* type: integer
|
||||
* description: 'The credential ID it is associated with'
|
||||
* eventTypeId:
|
||||
* type: integer
|
||||
* description: 'The ID of the eventType it is associated with'
|
||||
|
@ -65,20 +64,38 @@ async function postHandler(req: NextApiRequest) {
|
|||
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
||||
await checkPermissions(req, userId);
|
||||
|
||||
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
||||
const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId;
|
||||
|
||||
/* Check if credentialId data matches the ownership and integration passed in */
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: { type: parsedBody.integration, userId: assignedUserId },
|
||||
select: { id: true, type: true, userId: true },
|
||||
const userCredentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
type: parsedBody.integration,
|
||||
userId: assignedUserId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (!credential)
|
||||
if (userCredentials.length === 0)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, credential id invalid",
|
||||
});
|
||||
|
||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId);
|
||||
|
||||
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
||||
const calendar = eligibleCalendars?.find(
|
||||
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
||||
);
|
||||
if (!calendar?.credentialId)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, credential id invalid",
|
||||
});
|
||||
const credentialId = calendar.credentialId;
|
||||
|
||||
if (parsedBody.eventTypeId) {
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
||||
|
@ -91,7 +108,9 @@ async function postHandler(req: NextApiRequest) {
|
|||
parsedBody.userId = undefined;
|
||||
}
|
||||
|
||||
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
|
||||
const destination_calendar = await prisma.destinationCalendar.create({
|
||||
data: { ...parsedBody, credentialId },
|
||||
});
|
||||
|
||||
return {
|
||||
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
||||
|
|
|
@ -141,17 +141,6 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
: []),
|
||||
];
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
|
@ -270,11 +259,21 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
const bookingLink = buildBookingLink();
|
||||
|
||||
const title = booking.title;
|
||||
// To be used after we run query on legacy bookings
|
||||
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
||||
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const checkForRecordingsButton =
|
||||
!showRecordingsButtons && (booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: checkForRecordingsButton ? "check_for_recordings" : "view_recordings",
|
||||
label: checkForRecordingsButton ? t("check_for_recordings") : t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -299,7 +298,7 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{showRecordingsButtons && (
|
||||
{(showRecordingsButtons || checkForRecordingsButton) && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
|
@ -469,7 +468,9 @@ function BookingListItem(booking: BookingItemProps) {
|
|||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||
{(showRecordingsButtons || checkForRecordingsButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
|
||||
|
||||
function mockARedirectInDB({
|
||||
toUrl,
|
||||
slug,
|
||||
redirectType,
|
||||
}: {
|
||||
toUrl: string;
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
prismaMock.tempOrgRedirect.findUnique.mockImplementation(({ where }) => {
|
||||
return new Promise((resolve) => {
|
||||
if (
|
||||
where.from_type_fromOrgId.type === redirectType &&
|
||||
where.from_type_fromOrgId.from === slug &&
|
||||
where.from_type_fromOrgId.fromOrgId === 0
|
||||
) {
|
||||
resolve({ toUrl });
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("getTemporaryOrgRedirect", () => {
|
||||
it("should generate event-type URL without existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate event-type URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate User URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Profile URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL without query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,3 +1,6 @@
|
|||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
@ -7,10 +10,12 @@ export const getTemporaryOrgRedirect = async ({
|
|||
slug,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
}: {
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
log.debug(
|
||||
|
@ -33,10 +38,12 @@ export const getTemporaryOrgRedirect = async ({
|
|||
|
||||
if (redirect) {
|
||||
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
|
||||
const newDestinationWithoutQuery = eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl;
|
||||
const currentQueryString = stringify(currentQuery);
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl,
|
||||
destination: `${newDestinationWithoutQuery}${currentQueryString ? `?${currentQueryString}` : ""}`,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
import type { Request, Response } from "express";
|
||||
import type { Redirect } from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import withEmbedSsr from "./withEmbedSsr";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
|
||||
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
|
||||
}
|
||||
|
||||
function getServerSidePropsFnGenerator(
|
||||
config:
|
||||
| { redirectUrl: string }
|
||||
| { props: Record<string, unknown> }
|
||||
| {
|
||||
notFound: true;
|
||||
}
|
||||
) {
|
||||
if ("redirectUrl" in config)
|
||||
return async () => {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: config.redirectUrl,
|
||||
} satisfies Redirect,
|
||||
};
|
||||
};
|
||||
|
||||
if ("props" in config)
|
||||
return async () => {
|
||||
return {
|
||||
props: config.props,
|
||||
};
|
||||
};
|
||||
|
||||
if ("notFound" in config)
|
||||
return async () => {
|
||||
return {
|
||||
notFound: true as const,
|
||||
};
|
||||
};
|
||||
|
||||
throw new Error("Invalid config");
|
||||
}
|
||||
|
||||
function getServerSidePropsContextArg({
|
||||
embedRelatedParams,
|
||||
}: {
|
||||
embedRelatedParams?: Record<string, string>;
|
||||
}) {
|
||||
return {
|
||||
...createMockNextJsRequest(),
|
||||
query: {
|
||||
...embedRelatedParams,
|
||||
},
|
||||
resolvedUrl: "/MOCKED_RESOLVED_URL",
|
||||
};
|
||||
}
|
||||
|
||||
describe("withEmbedSsr", () => {
|
||||
describe("when gSSP returns redirect", () => {
|
||||
describe("when redirect destination is relative, should add /embed to end of the path", () => {
|
||||
it("should add layout and embed params from the current query", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add layout and embed params without losing query params that were in redirect", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule?redirectParam=1",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add embed param even when it was empty(i.e. default namespace of embed)", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule?redirectParam=1",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
|
||||
it("should add layout and embed params from the current query when destination URL is HTTPS", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "https://calcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should add layout and embed params from the current query when destination URL is HTTP", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "http://calcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should correctly identify a URL as non absolute URL if protocol is missing", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "httpcalcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
// FIXME: Note that it is adding a / in the beginning of the path, which might be fine for now, but could be an issue
|
||||
destination: "/httpcalcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when gSSP returns props", () => {
|
||||
it("should add isEmbed=true prop", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
props: {
|
||||
prop1: "value1",
|
||||
},
|
||||
})
|
||||
);
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
props: {
|
||||
prop1: "value1",
|
||||
isEmbed: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when gSSP doesn't have props or redirect ", () => {
|
||||
it("should return the result from gSSP as is", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
notFound: true,
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({ notFound: true });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,7 @@
|
|||
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export type EmbedProps = {
|
||||
isEmbed?: boolean;
|
||||
};
|
||||
|
@ -11,14 +13,25 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
|||
const layout = context.query.layout;
|
||||
|
||||
if ("redirect" in ssrResponse) {
|
||||
// Use a dummy URL https://base as the fallback base URL so that URL parsing works for relative URLs as well.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
|
||||
const destinationUrl = ssrResponse.redirect.destination;
|
||||
let urlPrefix = "";
|
||||
|
||||
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, WEBAPP_URL);
|
||||
|
||||
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
|
||||
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
|
||||
urlPrefix = destinationUrlObj.origin;
|
||||
} else {
|
||||
// Don't use any prefix for relative URLs to ensure we stay on the same domain
|
||||
urlPrefix = "";
|
||||
}
|
||||
|
||||
const destinationQueryStr = destinationUrlObj.searchParams.toString();
|
||||
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
||||
const newDestinationUrl = `${
|
||||
destinationUrlObj.pathname
|
||||
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
|
||||
|
||||
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
|
||||
destinationQueryStr ? `${destinationQueryStr}&` : ""
|
||||
}layout=${layout}&embed=${embed}`;
|
||||
return {
|
||||
...ssrResponse,
|
||||
redirect: {
|
||||
|
|
|
@ -102,6 +102,16 @@ const matcherConfigRootPath = {
|
|||
source: "/",
|
||||
};
|
||||
|
||||
const matcherConfigRootPathEmbed = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: "/embed",
|
||||
};
|
||||
|
||||
const matcherConfigUserRoute = {
|
||||
has: [
|
||||
{
|
||||
|
@ -245,6 +255,10 @@ const nextConfig = {
|
|||
...matcherConfigRootPath,
|
||||
destination: "/team/:orgSlug?isOrgProfile=1",
|
||||
},
|
||||
{
|
||||
...matcherConfigRootPathEmbed,
|
||||
destination: "/team/:orgSlug/embed?isOrgProfile=1",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
destination: "/org/:orgSlug/:user",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.4.4",
|
||||
"version": "3.4.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
|
|
@ -3,7 +3,10 @@ import Link from "next/link";
|
|||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
getOrgDomainConfigFromHostname,
|
||||
subdomainSuffix,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
|
@ -50,7 +53,10 @@ export default function Custom404() {
|
|||
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||
useEffect(() => {
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||
const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({
|
||||
hostname: window.location.host,
|
||||
});
|
||||
|
||||
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
|
||||
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
|
||||
const splitPath = routerUsername.split("/");
|
||||
|
|
|
@ -275,10 +275,7 @@ export type UserPageProps = {
|
|||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
const dataFetchStart = Date.now();
|
||||
|
@ -343,6 +340,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
|||
slug: usernameList[0],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -72,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
|||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
|
@ -148,10 +145,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
|
@ -160,6 +154,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
slug: usernames[0],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
|
|||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { query } = req;
|
||||
const parsedQuery = logoApiSchema.parse(query);
|
||||
const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const hostname = req?.headers["host"];
|
||||
if (!hostname) throw new Error("No hostname");
|
||||
|
|
|
@ -62,6 +62,46 @@ const triggerWebhook = async ({
|
|||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
const checkIfUserIsPartOfTheSameTeam = async (
|
||||
teamId: number | undefined | null,
|
||||
userId: number,
|
||||
userEmail: string | undefined | null
|
||||
) => {
|
||||
if (!teamId) return false;
|
||||
|
||||
const getUserQuery = () => {
|
||||
if (!!userEmail) {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
id: userId,
|
||||
},
|
||||
{
|
||||
email: userEmail,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: userId,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
user: getUserQuery(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!team;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
||||
return res.status(405).json({ message: "No SendGrid API key or email" });
|
||||
|
@ -137,12 +177,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
const isUserAttendeeOrOrganiser =
|
||||
booking?.user?.id === session.user.id ||
|
||||
attendeesList.find((attendee) => attendee.id === session.user.id);
|
||||
attendeesList.find(
|
||||
(attendee) => attendee.id === session.user.id || attendee.email === session.user.email
|
||||
);
|
||||
|
||||
if (!isUserAttendeeOrOrganiser) {
|
||||
return res.status(403).send({
|
||||
message: "Unauthorised",
|
||||
});
|
||||
const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam(
|
||||
booking?.eventType?.teamId,
|
||||
session.user.id,
|
||||
session.user.email
|
||||
);
|
||||
|
||||
if (!isUserMemberOfTheTeam) {
|
||||
return res.status(403).send({
|
||||
message: "Unauthorised",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
|
@ -202,7 +252,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
return res.status(403).json({ message: "User does not have team plan to send out emails" });
|
||||
} catch (err) {
|
||||
console.warn("something_went_wrong", err);
|
||||
console.warn("Error in /recorded-daily-video", err);
|
||||
return res.status(500).json({ message: "something went wrong" });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ const querySchema = z
|
|||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ type Response = {
|
|||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(req);
|
||||
const result = await checkUsername(req.body.username, currentOrgDomain);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
if (session) {
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
|
|
|
@ -342,14 +342,17 @@ export default function Success(props: SuccessProps) {
|
|||
<div
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "text-center" : "",
|
||||
"flex items-end justify-center px-4 pb-20 pt-4 sm:block sm:p-0"
|
||||
"flex items-end justify-center px-4 pb-20 pt-4 sm:flex sm:p-0"
|
||||
)}>
|
||||
<div
|
||||
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : " inset-0")}
|
||||
className={classNames(
|
||||
"main my-4 flex flex-col transition-opacity sm:my-0 ",
|
||||
isEmbed ? "" : " inset-0"
|
||||
)}
|
||||
aria-hidden="true">
|
||||
<div
|
||||
className={classNames(
|
||||
"main inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
!isBackgroundTransparent && " bg-default dark:bg-muted border-booker border-booker-width",
|
||||
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
||||
)}
|
||||
|
|
|
@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
|||
const session = await getServerSession(context);
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[user]";
|
||||
|
||||
export { default } from "../[user]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
|
@ -0,0 +1,7 @@
|
|||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "./index";
|
||||
|
||||
export { default } from "./index";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
|
@ -283,8 +283,8 @@ const ProfileView = () => {
|
|||
/>
|
||||
|
||||
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
|
||||
</div>
|
||||
{/* Delete account Dialog */}
|
||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
|
|
|
@ -164,14 +164,13 @@ const PasswordView = ({ user }: PasswordViewProps) => {
|
|||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<div>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||
{t("account_managed_by_identity_provider", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-xl border border-t-0 px-4 py-6 sm:px-6">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||
{t("account_managed_by_identity_provider", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<p className="text-subtle mt-1 text-sm">
|
||||
{t("account_managed_by_identity_provider_description", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
|
@ -180,7 +179,7 @@ const PasswordView = ({ user }: PasswordViewProps) => {
|
|||
</div>
|
||||
) : (
|
||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||
<div className="border-x px-4 py-6 sm:px-6">
|
||||
<div className="border-subtle border-x px-4 py-6 sm:px-6">
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
|
|
|
@ -269,10 +269,7 @@ function TeamPage({
|
|||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
|
@ -299,6 +296,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
slug: slug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -74,10 +74,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
|
@ -85,6 +82,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
slug: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
@ -435,6 +435,8 @@ test.describe("Reschedule for booking with seats", () => {
|
|||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
select: {
|
||||
|
|
|
@ -0,0 +1,483 @@
|
|||
import { loginUser } from "../../fixtures/regularBookings";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Address Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Checkbox Group Question", () => {
|
||||
test("Address required and checkbox group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox Group question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and checkbox group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox Group question (only address required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Checkbox Question", () => {
|
||||
test("Address required and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox question (both required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Addres and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox question (only address required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Long text Question", () => {
|
||||
test("Addres required and Long Text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Long Text question (both required)",
|
||||
secondQuestion: "textarea",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Long Text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Long Text question (only address required)",
|
||||
secondQuestion: "textarea",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Multi email Question", () => {
|
||||
test("Address required and Multi email required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multiemail question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Multi email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multiemail question (only address required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and multiselect Question", () => {
|
||||
test("Address required and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isMultiSelect: true },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isMultiSelect: true, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Number Question", () => {
|
||||
test("Address required and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Number question (both required)",
|
||||
secondQuestion: "number",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Number question (only address required)",
|
||||
secondQuestion: "number",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Phone Question", () => {
|
||||
test("Address required and Phone required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone-test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "phone",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Phone not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone-test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "phone",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Radio group Question", () => {
|
||||
test("Address required and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Radio question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Radio question (only address required)",
|
||||
secondQuestion: "radio",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and select Question", () => {
|
||||
test("Address required and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and select not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Short text question", () => {
|
||||
test("Address required and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "text",
|
||||
options: { ...bookingOptions, isRequired: true },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,441 @@
|
|||
import { loginUser } from "../fixtures/regularBookings";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Phone Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users, bookingPage }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Address Question", () => {
|
||||
test("Select required and Address required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Address question (both required)",
|
||||
secondQuestion: "address",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Address not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Address question (only select required)",
|
||||
secondQuestion: "address",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and checkbox group Question", () => {
|
||||
test("Select required and checkbox group required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Checkbox question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and checkbox group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Checkbox question (only Select required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and checkbox Question", () => {
|
||||
test("Select required and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and boolean question (both required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and boolean question (only select required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Long text Question", () => {
|
||||
test("Select required and Long text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and textarea question (both required)",
|
||||
secondQuestion: "textarea",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Long text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and textarea question (only select required)",
|
||||
secondQuestion: "textarea",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Multi email Question", () => {
|
||||
test("Select required and Multi email required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiemail question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Multi email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiemail question (only select required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and multiselect Question", () => {
|
||||
test("Select required and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiselect question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiselect question (only select required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Number Question", () => {
|
||||
test("Select required and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and number question (both required)",
|
||||
secondQuestion: "number",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and number question (only select required)",
|
||||
secondQuestion: "number",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Phone Question", () => {
|
||||
test("Select required and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and phone question (both required)",
|
||||
secondQuestion: "phone",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Phone not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and phone question (only select required)",
|
||||
secondQuestion: "phone",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Radio group Question", () => {
|
||||
test("Select required and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and radio question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and radio question (only select required)",
|
||||
secondQuestion: "radio",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Short text question", () => {
|
||||
test("Select required and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and text question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and text question (only select required)",
|
||||
secondQuestion: "text",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,56 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import type { Team } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createOrgsFixture = (page: Page) => {
|
||||
const store = { orgs: [], page } as { orgs: Team[]; page: typeof page };
|
||||
return {
|
||||
create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => {
|
||||
const org = await createOrgInDb({
|
||||
name: opts.name,
|
||||
slug: opts.slug || getRandomSlug(),
|
||||
requestedSlug: opts.requestedSlug,
|
||||
});
|
||||
store.orgs.push(org);
|
||||
return org;
|
||||
},
|
||||
get: () => store.orgs,
|
||||
deleteAll: async () => {
|
||||
await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } });
|
||||
store.orgs = [];
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
await prisma.team.delete({ where: { id } });
|
||||
store.orgs = store.orgs.filter((b) => b.id !== id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function createOrgInDb({
|
||||
name,
|
||||
slug,
|
||||
requestedSlug,
|
||||
}: {
|
||||
name: string;
|
||||
slug: string | null;
|
||||
requestedSlug?: string;
|
||||
}) {
|
||||
return await prisma.team.create({
|
||||
data: {
|
||||
name: name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
...(requestedSlug
|
||||
? {
|
||||
requestedSlug,
|
||||
}
|
||||
: null),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import type { createUsersFixture } from "./users";
|
||||
|
||||
const reschedulePlaceholderText = "Let others know why you need to reschedule";
|
||||
|
@ -13,6 +15,7 @@ type BookingOptions = {
|
|||
hasPlaceholder?: boolean;
|
||||
isReschedule?: boolean;
|
||||
isRequired?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
};
|
||||
|
||||
interface QuestionActions {
|
||||
|
@ -37,6 +40,12 @@ type fillAndConfirmBookingParams = {
|
|||
|
||||
type UserFixture = ReturnType<typeof createUsersFixture>;
|
||||
|
||||
function isLastDayOfMonth(): boolean {
|
||||
const today = dayjs();
|
||||
const endOfMonth = today.endOf("month");
|
||||
return today.isSame(endOfMonth, "day");
|
||||
}
|
||||
|
||||
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
|
||||
const questionActions: QuestionActions = {
|
||||
phone: async () => {
|
||||
|
@ -102,7 +111,6 @@ const fillQuestion = async (eventTypePage: Page, questionType: string, customLoc
|
|||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
|
||||
},
|
||||
};
|
||||
|
||||
if (questionActions[questionType]) {
|
||||
await questionActions[questionType]();
|
||||
}
|
||||
|
@ -113,6 +121,17 @@ export async function loginUser(users: UserFixture) {
|
|||
await pro.apiLogin();
|
||||
}
|
||||
|
||||
const goToNextMonthIfNoAvailabilities = async (eventTypePage: Page) => {
|
||||
try {
|
||||
if (isLastDayOfMonth()) {
|
||||
await eventTypePage.getByTestId("view_next_month").waitFor({ timeout: 6000 });
|
||||
await eventTypePage.getByTestId("view_next_month").click();
|
||||
}
|
||||
} catch (err) {
|
||||
console.info("No need to click on view next month button");
|
||||
}
|
||||
};
|
||||
|
||||
export function createBookingPageFixture(page: Page) {
|
||||
return {
|
||||
goToEventType: async (eventType: string) => {
|
||||
|
@ -153,19 +172,13 @@ export function createBookingPageFixture(page: Page) {
|
|||
return eventtypePromise;
|
||||
},
|
||||
selectTimeSlot: async (eventTypePage: Page) => {
|
||||
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||
}
|
||||
await goToNextMonthIfNoAvailabilities(eventTypePage);
|
||||
await eventTypePage.getByTestId("time").first().click();
|
||||
},
|
||||
clickReschedule: async () => {
|
||||
await page.getByText("Reschedule").click();
|
||||
},
|
||||
navigateToAvailableTimeSlot: async () => {
|
||||
while (await page.getByRole("button", { name: "View next" }).isVisible()) {
|
||||
await page.getByRole("button", { name: "View next" }).click();
|
||||
}
|
||||
},
|
||||
|
||||
selectFirstAvailableTime: async () => {
|
||||
await page.getByTestId("time").first().click();
|
||||
},
|
||||
|
@ -185,6 +198,7 @@ export function createBookingPageFixture(page: Page) {
|
|||
},
|
||||
|
||||
rescheduleBooking: async (eventTypePage: Page) => {
|
||||
await goToNextMonthIfNoAvailabilities(eventTypePage);
|
||||
await eventTypePage.getByText("Reschedule").click();
|
||||
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||
|
@ -221,7 +235,7 @@ export function createBookingPageFixture(page: Page) {
|
|||
|
||||
// Change the selector for specifics cases related to select question
|
||||
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
|
||||
question === "select" && ["multiemail", "multiselect"].includes(secondQuestion);
|
||||
question === "select" && ["multiemail", "multiselect", "address"].includes(secondQuestion);
|
||||
|
||||
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
||||
question === "radio" && secondQuestion === "checkbox";
|
||||
|
|
|
@ -9,6 +9,7 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
|
|||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||
import { TimeZoneEnum } from "./types";
|
||||
|
@ -78,11 +79,13 @@ const createTeamAndAddUser = async (
|
|||
isUnpublished,
|
||||
isOrg,
|
||||
hasSubteam,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: number; username: string | null; role?: MembershipRole };
|
||||
isUnpublished?: boolean;
|
||||
isOrg?: boolean;
|
||||
hasSubteam?: true;
|
||||
organizationId?: number | null;
|
||||
},
|
||||
workerInfo: WorkerInfo
|
||||
) => {
|
||||
|
@ -101,6 +104,7 @@ const createTeamAndAddUser = async (
|
|||
data.children = { connect: [{ id: team.id }] };
|
||||
}
|
||||
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
|
||||
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
|
||||
const team = await prisma.team.create({
|
||||
data,
|
||||
});
|
||||
|
@ -114,6 +118,7 @@ const createTeamAndAddUser = async (
|
|||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
};
|
||||
|
||||
|
@ -282,6 +287,7 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
|||
isUnpublished: scenario.isUnpublished,
|
||||
isOrg: scenario.isOrg,
|
||||
hasSubteam: scenario.hasSubteam,
|
||||
organizationId: opts?.organizationId,
|
||||
},
|
||||
workerInfo
|
||||
);
|
||||
|
@ -399,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
getTeam: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
getFirstTeam: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { team: true },
|
||||
});
|
||||
|
||||
const membership = memberships
|
||||
.map((membership) => {
|
||||
return {
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
};
|
||||
})
|
||||
.find((membership) => !membership.team?.metadata?.isOrganization);
|
||||
if (!membership) {
|
||||
throw new Error("No team found for user");
|
||||
}
|
||||
return membership;
|
||||
},
|
||||
getOrg: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
|
@ -453,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
|||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
|
||||
type CustomUserOptsKeys =
|
||||
| "username"
|
||||
| "password"
|
||||
| "completedOnboarding"
|
||||
| "locale"
|
||||
| "name"
|
||||
| "email"
|
||||
| "organizationId";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
// ignores adding the worker-index after username
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
const createUser = (
|
||||
workerInfo: WorkerInfo,
|
||||
opts?: CustomUserOpts | null
|
||||
): PrismaType.UserUncheckedCreateInput => {
|
||||
// build a unique name for our user
|
||||
const uname =
|
||||
opts?.useExactUsername && opts?.username
|
||||
|
@ -478,6 +511,7 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
locale: opts?.locale ?? "en",
|
||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
? {
|
||||
|
@ -493,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
|||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
function getOrganizationRelatedProps({
|
||||
organizationId,
|
||||
role,
|
||||
}: {
|
||||
organizationId: number | null | undefined;
|
||||
role: MembershipRole | undefined;
|
||||
}) {
|
||||
if (!organizationId) {
|
||||
return null;
|
||||
}
|
||||
if (!role) {
|
||||
throw new Error("Missing role for user in organization");
|
||||
}
|
||||
return {
|
||||
organizationId: organizationId || null,
|
||||
...(organizationId
|
||||
? {
|
||||
teams: {
|
||||
// Create membership
|
||||
create: [
|
||||
{
|
||||
team: {
|
||||
connect: {
|
||||
id: organizationId,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: null),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function confirmPendingPayment(page: Page) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import prisma from "@calcom/prisma";
|
|||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||
import { createOrgsFixture } from "../fixtures/orgs";
|
||||
import { createPaymentsFixture } from "../fixtures/payments";
|
||||
import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
|
@ -17,6 +18,7 @@ import { createUsersFixture } from "../fixtures/users";
|
|||
|
||||
export interface Fixtures {
|
||||
page: Page;
|
||||
orgs: ReturnType<typeof createOrgsFixture>;
|
||||
users: ReturnType<typeof createUsersFixture>;
|
||||
bookings: ReturnType<typeof createBookingsFixture>;
|
||||
payments: ReturnType<typeof createPaymentsFixture>;
|
||||
|
@ -48,6 +50,10 @@ declare global {
|
|||
* @see https://playwright.dev/docs/test-fixtures
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
orgs: async ({ page }, use) => {
|
||||
const orgsFixture = createOrgsFixture(page);
|
||||
await use(orgsFixture);
|
||||
},
|
||||
users: async ({ page, context, emails }, use, workerInfo) => {
|
||||
const usersFixture = createUsersFixture(page, emails, workerInfo);
|
||||
await use(usersFixture);
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Teams", () => {
|
||||
test.describe("Teams - NonOrg", () => {
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test("Can create teams via Wizard", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
|
@ -64,6 +64,7 @@ test.describe("Teams", () => {
|
|||
// await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -78,7 +79,7 @@ test.describe("Teams", () => {
|
|||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -99,6 +100,7 @@ test.describe("Teams", () => {
|
|||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -113,7 +115,7 @@ test.describe("Teams", () => {
|
|||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
@ -135,6 +137,7 @@ test.describe("Teams", () => {
|
|||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Non admin team members cannot create team in org", async ({ page, users }) => {
|
||||
const teamMateName = "teammate-1";
|
||||
|
||||
|
@ -169,6 +172,7 @@ test.describe("Teams", () => {
|
|||
await prisma.team.delete({ where: { id: org.teamId } });
|
||||
}
|
||||
});
|
||||
|
||||
test("Can create team with same name as user", async ({ page, users }) => {
|
||||
// Name to be used for both user and team
|
||||
const uniqueName = "test-unique-name";
|
||||
|
@ -210,6 +214,7 @@ test.describe("Teams", () => {
|
|||
await prisma.team.delete({ where: { id: team?.id } });
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a private team", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
|
@ -226,7 +231,7 @@ test.describe("Teams", () => {
|
|||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
|
||||
// Mark team as private
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
@ -247,3 +252,180 @@ test.describe("Teams", () => {
|
|||
todo("Reschedule a Collective EventType booking");
|
||||
todo("Reschedule a Round Robin EventType booking");
|
||||
});
|
||||
|
||||
test.describe("Teams - Org", () => {
|
||||
test.afterEach(({ orgs, users }) => {
|
||||
orgs.deleteAll();
|
||||
users.deleteAll();
|
||||
});
|
||||
|
||||
test("Can create teams via Wizard", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.ADMIN,
|
||||
});
|
||||
const inviteeEmail = `${user.username}+invitee@example.com`;
|
||||
await user.apiLogin();
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Can create team", async () => {
|
||||
// Click text=Create Team
|
||||
await page.locator("text=Create a new Team").click();
|
||||
await page.waitForURL((url) => url.pathname === "/settings/teams/new");
|
||||
// Fill input[name="name"]
|
||||
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
|
||||
// Click text=Continue
|
||||
await page.locator("text=Continue").click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("Can add members", async () => {
|
||||
// Click [data-testid="new-member-button"]
|
||||
await page.locator('[data-testid="new-member-button"]').click();
|
||||
// Fill [placeholder="email\@example\.com"]
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
|
||||
// Click [data-testid="invite-new-member-button"]
|
||||
await page.locator('[data-testid="invite-new-member-button"]').click();
|
||||
await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible();
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
});
|
||||
|
||||
await test.step("Can remove members", async () => {
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2);
|
||||
|
||||
const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1);
|
||||
|
||||
// Cleanup here since this user is created without our fixtures.
|
||||
await prisma.user.delete({ where: { email: inviteeEmail } });
|
||||
});
|
||||
|
||||
await test.step("Can finish team creation", async () => {
|
||||
await page.locator("text=Finish").click();
|
||||
await page.waitForURL("/settings/teams");
|
||||
});
|
||||
|
||||
await test.step("Can disband team", async () => {
|
||||
await page.locator('[data-testid="team-list-item-link"]').click();
|
||||
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
|
||||
await page.locator("text=Disband Team").click();
|
||||
await page.locator("text=Yes, disband team").click();
|
||||
await page.waitForURL("/teams");
|
||||
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("Can create a booking for Collective EventType", async ({ page, users, orgs }) => {
|
||||
const org = await orgs.create({
|
||||
name: "TestOrg",
|
||||
});
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
|
||||
const owner = await users.create(
|
||||
{
|
||||
username: "pro-user",
|
||||
name: "pro-user",
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
},
|
||||
{
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
}
|
||||
);
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
|
||||
await expect(page.locator('[data-testid="404-page"]')).toBeVisible();
|
||||
await doOnOrgDomain(
|
||||
{
|
||||
orgSlug: org.slug,
|
||||
page,
|
||||
},
|
||||
async () => {
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
// The booker should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
||||
// All the teammates should be in the booking
|
||||
for (const teammate of teamMatesObj) {
|
||||
await expect(page.getByText(teammate.name, { exact: true })).toBeVisible();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
|
||||
test("Can create a booking for Round Robin EventType", async ({ page, users }) => {
|
||||
const ownerObj = { username: "pro-user", name: "pro-user" };
|
||||
const teamMatesObj = [
|
||||
{ name: "teammate-1" },
|
||||
{ name: "teammate-2" },
|
||||
{ name: "teammate-3" },
|
||||
{ name: "teammate-4" },
|
||||
];
|
||||
const owner = await users.create(ownerObj, {
|
||||
hasTeam: true,
|
||||
teammates: teamMatesObj,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// The person who booked the meeting should be in the attendee list
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
||||
// The title of the booking
|
||||
const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`;
|
||||
await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle);
|
||||
|
||||
// Since all the users have the same leastRecentlyBooked value
|
||||
// Anyone of the teammates could be the Host of the booking.
|
||||
const chosenUser = await page.getByTestId("booking-host-name").textContent();
|
||||
expect(chosenUser).not.toBeNull();
|
||||
expect(teamMatesObj.some(({ name }) => name === chosenUser)).toBe(true);
|
||||
// TODO: Assert whether the user received an email
|
||||
});
|
||||
});
|
||||
|
||||
async function doOnOrgDomain(
|
||||
{ orgSlug, page }: { orgSlug: string | null; page: Page },
|
||||
callback: ({ page }: { page: Page }) => Promise<void>
|
||||
) {
|
||||
if (!orgSlug) {
|
||||
throw new Error("orgSlug is not available");
|
||||
}
|
||||
page.setExtraHTTPHeaders({
|
||||
"x-cal-force-slug": orgSlug,
|
||||
});
|
||||
await callback({ page });
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ test.afterAll(async ({ users }) => {
|
|||
test.describe("Unpublished", () => {
|
||||
test("Regular team profile", async ({ page, users }) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true, isUnpublished: true });
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
await page.goto(`/team/${requestedSlug}`);
|
||||
expect(await page.locator('[data-testid="empty-screen"]').count()).toBe(1);
|
||||
|
@ -33,7 +33,7 @@ test.describe("Unpublished", () => {
|
|||
isUnpublished: true,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
});
|
||||
const { team } = await owner.getTeam();
|
||||
const { team } = await owner.getFirstTeam();
|
||||
const { requestedSlug } = team.metadata as { requestedSlug: string };
|
||||
const { slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id);
|
||||
await page.goto(`/team/${requestedSlug}/${teamEventSlug}`);
|
||||
|
|
|
@ -605,7 +605,7 @@
|
|||
"hide_book_a_team_member": "Hide Book a Team Member Button",
|
||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Careful. Account deletion cannot be undone.",
|
||||
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
|
@ -1296,6 +1296,7 @@
|
|||
"select_calendars": "Select which calendars you want to check for conflicts to prevent double bookings.",
|
||||
"check_for_conflicts": "Check for conflicts",
|
||||
"view_recordings": "View recordings",
|
||||
"check_for_recordings":"Check for recordings",
|
||||
"adding_events_to": "Adding events to",
|
||||
"follow_system_preferences": "Follow system preferences",
|
||||
"custom_brand_colors": "Custom brand colors",
|
||||
|
|
|
@ -66,6 +66,7 @@ type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
|
|||
id: number;
|
||||
defaultScheduleId?: number | null;
|
||||
credentials?: InputCredential[];
|
||||
organizationId?: number | null;
|
||||
selectedCalendars?: InputSelectedCalendar[];
|
||||
schedules: {
|
||||
// Allows giving id in the input directly so that it can be referenced somewhere else as well
|
||||
|
@ -264,8 +265,21 @@ async function addBookingsToDb(
|
|||
})[]
|
||||
) {
|
||||
log.silly("TestData: Creating Bookings", JSON.stringify(bookings));
|
||||
|
||||
function getDateObj(time: string | Date) {
|
||||
return time instanceof Date ? time : new Date(time);
|
||||
}
|
||||
|
||||
// Make sure that we store the date in Date object always. This is to ensure consistency which Prisma does but not prismock
|
||||
log.silly("Handling Prismock bug-3");
|
||||
const fixedBookings = bookings.map((booking) => {
|
||||
const startTime = getDateObj(booking.startTime);
|
||||
const endTime = getDateObj(booking.endTime);
|
||||
return { ...booking, startTime, endTime };
|
||||
});
|
||||
|
||||
await prismock.booking.createMany({
|
||||
data: bookings,
|
||||
data: fixedBookings,
|
||||
});
|
||||
log.silly(
|
||||
"TestData: Bookings as in DB",
|
||||
|
@ -406,6 +420,7 @@ async function addUsers(users: InputUser[]) {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
return newUser;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
@ -446,6 +461,16 @@ export async function createBookingScenario(data: ScenarioData) {
|
|||
};
|
||||
}
|
||||
|
||||
export async function createOrganization(orgData: { name: string; slug: string }) {
|
||||
const org = await prismock.team.create({
|
||||
data: {
|
||||
name: orgData.name,
|
||||
slug: orgData.slug,
|
||||
},
|
||||
});
|
||||
return org;
|
||||
}
|
||||
|
||||
// async function addPaymentsToDb(payments: Prisma.PaymentCreateInput[]) {
|
||||
// await prismaMock.payment.createMany({
|
||||
// data: payments,
|
||||
|
@ -722,6 +747,7 @@ export function getOrganizer({
|
|||
}) {
|
||||
return {
|
||||
...TestData.users.example,
|
||||
organizationId: null as null | number,
|
||||
name,
|
||||
email,
|
||||
id,
|
||||
|
@ -733,24 +759,33 @@ export function getOrganizer({
|
|||
};
|
||||
}
|
||||
|
||||
export function getScenarioData({
|
||||
organizer,
|
||||
eventTypes,
|
||||
usersApartFromOrganizer = [],
|
||||
apps = [],
|
||||
webhooks,
|
||||
bookings,
|
||||
}: // hosts = [],
|
||||
{
|
||||
organizer: ReturnType<typeof getOrganizer>;
|
||||
eventTypes: ScenarioData["eventTypes"];
|
||||
apps?: ScenarioData["apps"];
|
||||
usersApartFromOrganizer?: ScenarioData["users"];
|
||||
webhooks?: ScenarioData["webhooks"];
|
||||
bookings?: ScenarioData["bookings"];
|
||||
// hosts?: ScenarioData["hosts"];
|
||||
}) {
|
||||
export function getScenarioData(
|
||||
{
|
||||
organizer,
|
||||
eventTypes,
|
||||
usersApartFromOrganizer = [],
|
||||
apps = [],
|
||||
webhooks,
|
||||
bookings,
|
||||
}: // hosts = [],
|
||||
{
|
||||
organizer: ReturnType<typeof getOrganizer>;
|
||||
eventTypes: ScenarioData["eventTypes"];
|
||||
apps?: ScenarioData["apps"];
|
||||
usersApartFromOrganizer?: ScenarioData["users"];
|
||||
webhooks?: ScenarioData["webhooks"];
|
||||
bookings?: ScenarioData["bookings"];
|
||||
// hosts?: ScenarioData["hosts"];
|
||||
},
|
||||
org?: { id: number | null } | undefined | null
|
||||
) {
|
||||
const users = [organizer, ...usersApartFromOrganizer];
|
||||
if (org) {
|
||||
users.forEach((user) => {
|
||||
user.organizationId = org.id;
|
||||
});
|
||||
}
|
||||
|
||||
eventTypes.forEach((eventType) => {
|
||||
if (
|
||||
eventType.users?.filter((eventTypeUser) => {
|
||||
|
@ -897,6 +932,7 @@ export function mockCalendar(
|
|||
url: "https://UNUSED_URL",
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deleteEvent: async (...rest: any[]) => {
|
||||
log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest }));
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
|
@ -1021,6 +1057,7 @@ export function mockVideoApp({
|
|||
...videoMeetingData,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deleteMeeting: async (...rest: any[]) => {
|
||||
log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest));
|
||||
deleteMeetingCalls.push({
|
||||
|
@ -1153,7 +1190,6 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte
|
|||
await handleStripePaymentSuccess(getMockedStripePaymentEvent({ paymentIntentId: externalId }));
|
||||
} catch (e) {
|
||||
log.silly("mockPaymentSuccessWebhookFromStripe:catch", JSON.stringify(e));
|
||||
|
||||
webhookResponse = e as HttpError;
|
||||
}
|
||||
return { webhookResponse };
|
||||
|
|
|
@ -2,10 +2,14 @@ import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
|
|||
|
||||
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
|
||||
import { parse } from "node-html-parser";
|
||||
import type { VEvent } from "node-ical";
|
||||
import ical from "node-ical";
|
||||
import { expect } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DEFAULT_TIMEZONE_BOOKER } from "@calcom/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
@ -15,42 +19,73 @@ import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
|||
|
||||
import type { InputEventType } from "./bookingScenario";
|
||||
|
||||
// This is too complex at the moment, I really need to simplify this.
|
||||
// Maybe we can replace the exact match with a partial match approach that would be easier to maintain but we would still need Dayjs to do the timezone conversion
|
||||
// Alternative could be that we use some other library to do the timezone conversion?
|
||||
function formatDateToWhenFormat({ start, end }: { start: Date; end: Date }, timeZone: string) {
|
||||
const startTime = dayjs(start).tz(timeZone);
|
||||
return `${startTime.format(`dddd, LL`)} | ${startTime.format("h:mma")} - ${dayjs(end)
|
||||
.tz(timeZone)
|
||||
.format("h:mma")} (${timeZone})`;
|
||||
}
|
||||
|
||||
type Recurrence = {
|
||||
freq: number;
|
||||
interval: number;
|
||||
count: number;
|
||||
};
|
||||
type ExpectedEmail = {
|
||||
/**
|
||||
* Checks the main heading of the email - Also referred to as title in code at some places
|
||||
*/
|
||||
heading?: string;
|
||||
links?: { text: string; href: string }[];
|
||||
/**
|
||||
* Checks the sub heading of the email - Also referred to as subTitle in code
|
||||
*/
|
||||
subHeading?: string;
|
||||
/**
|
||||
* Checks the <title> tag - Not sure what's the use of it, as it is not shown in UI it seems.
|
||||
*/
|
||||
titleTag?: string;
|
||||
to: string;
|
||||
bookingTimeRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
timeZone: string;
|
||||
};
|
||||
// TODO: Implement these and more
|
||||
// what?: string;
|
||||
// when?: string;
|
||||
// who?: string;
|
||||
// where?: string;
|
||||
// additionalNotes?: string;
|
||||
// footer?: {
|
||||
// rescheduleLink?: string;
|
||||
// cancelLink?: string;
|
||||
// };
|
||||
ics?: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
recurrence?: Recurrence;
|
||||
};
|
||||
/**
|
||||
* Checks that there is no
|
||||
*/
|
||||
noIcs?: true;
|
||||
appsStatus?: AppsStatus[];
|
||||
};
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace jest {
|
||||
interface Matchers<R> {
|
||||
toHaveEmail(
|
||||
expectedEmail: {
|
||||
title?: string;
|
||||
to: string;
|
||||
noIcs?: true;
|
||||
ics?: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
};
|
||||
appsStatus?: AppsStatus[];
|
||||
},
|
||||
to: string
|
||||
): R;
|
||||
toHaveEmail(expectedEmail: ExpectedEmail, to: string): R;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect.extend({
|
||||
toHaveEmail(
|
||||
emails: Fixtures["emails"],
|
||||
expectedEmail: {
|
||||
title?: string;
|
||||
to: string;
|
||||
ics: {
|
||||
filename: string;
|
||||
iCalUID: string;
|
||||
};
|
||||
noIcs: true;
|
||||
appsStatus: AppsStatus[];
|
||||
},
|
||||
to: string
|
||||
) {
|
||||
toHaveEmail(emails: Fixtures["emails"], expectedEmail: ExpectedEmail, to: string) {
|
||||
const { isNot } = this;
|
||||
const testEmail = emails.get().find((email) => email.to.includes(to));
|
||||
const emailsToLog = emails
|
||||
|
@ -66,6 +101,7 @@ expect.extend({
|
|||
}
|
||||
const ics = testEmail.icalEvent;
|
||||
const icsObject = ics?.content ? ical.sync.parseICS(ics?.content) : null;
|
||||
const iCalUidData = icsObject ? icsObject[expectedEmail.ics?.iCalUID || ""] : null;
|
||||
|
||||
let isToAddressExpected = true;
|
||||
const isIcsFilenameExpected = expectedEmail.ics ? ics?.filename === expectedEmail.ics.filename : true;
|
||||
|
@ -75,13 +111,18 @@ expect.extend({
|
|||
const emailDom = parse(testEmail.html);
|
||||
|
||||
const actualEmailContent = {
|
||||
title: emailDom.querySelector("title")?.innerText,
|
||||
subject: emailDom.querySelector("subject")?.innerText,
|
||||
titleTag: emailDom.querySelector("title")?.innerText,
|
||||
heading: emailDom.querySelector('[data-testid="heading"]')?.innerText,
|
||||
subHeading: emailDom.querySelector('[data-testid="subHeading"]')?.innerText,
|
||||
when: emailDom.querySelector('[data-testid="when"]')?.innerText,
|
||||
links: emailDom.querySelectorAll("a[href]").map((link) => ({
|
||||
text: link.innerText,
|
||||
href: link.getAttribute("href"),
|
||||
})),
|
||||
};
|
||||
|
||||
const expectedEmailContent = {
|
||||
title: expectedEmail.title,
|
||||
};
|
||||
const expectedEmailContent = getExpectedEmailContent(expectedEmail);
|
||||
assertHasRecurrence(expectedEmail.ics?.recurrence, (iCalUidData as VEvent)?.rrule?.toString() || "");
|
||||
|
||||
const isEmailContentMatched = this.equals(
|
||||
actualEmailContent,
|
||||
|
@ -114,7 +155,7 @@ expect.extend({
|
|||
return {
|
||||
pass: false,
|
||||
actual: ics?.filename,
|
||||
expected: expectedEmail.ics.filename,
|
||||
expected: expectedEmail.ics?.filename,
|
||||
message: () => `ICS Filename ${isNot ? "is" : "is not"} matching`,
|
||||
};
|
||||
}
|
||||
|
@ -123,11 +164,18 @@ expect.extend({
|
|||
return {
|
||||
pass: false,
|
||||
actual: JSON.stringify(icsObject),
|
||||
expected: expectedEmail.ics.iCalUID,
|
||||
expected: expectedEmail.ics?.iCalUID,
|
||||
message: () => `Expected ICS UID ${isNot ? "is" : "isn't"} present in actual`,
|
||||
};
|
||||
}
|
||||
|
||||
if (expectedEmail.noIcs && ics) {
|
||||
return {
|
||||
pass: false,
|
||||
message: () => `${isNot ? "" : "Not"} expected ics file`,
|
||||
};
|
||||
}
|
||||
|
||||
if (expectedEmail.appsStatus) {
|
||||
const actualAppsStatus = emailDom.querySelectorAll('[data-testid="appsStatus"] li').map((li) => {
|
||||
return li.innerText.trim();
|
||||
|
@ -155,6 +203,50 @@ expect.extend({
|
|||
pass: true,
|
||||
message: () => `Email ${isNot ? "is" : "isn't"} correct`,
|
||||
};
|
||||
|
||||
function getExpectedEmailContent(expectedEmail: ExpectedEmail) {
|
||||
const bookingTimeRange = expectedEmail.bookingTimeRange;
|
||||
const when = bookingTimeRange
|
||||
? formatDateToWhenFormat(
|
||||
{
|
||||
start: bookingTimeRange.start,
|
||||
end: bookingTimeRange.end,
|
||||
},
|
||||
bookingTimeRange.timeZone
|
||||
)
|
||||
: null;
|
||||
|
||||
const expectedEmailContent = {
|
||||
titleTag: expectedEmail.titleTag,
|
||||
heading: expectedEmail.heading,
|
||||
subHeading: expectedEmail.subHeading,
|
||||
when: when ? (expectedEmail.ics?.recurrence ? `starting ${when}` : `${when}`) : undefined,
|
||||
links: expect.arrayContaining(expectedEmail.links || []),
|
||||
};
|
||||
// Remove undefined props so that they aren't matched, they are intentionally left undefined because we don't want to match them
|
||||
Object.keys(expectedEmailContent).filter((key) => {
|
||||
if (expectedEmailContent[key as keyof typeof expectedEmailContent] === undefined) {
|
||||
delete expectedEmailContent[key as keyof typeof expectedEmailContent];
|
||||
}
|
||||
});
|
||||
return expectedEmailContent;
|
||||
}
|
||||
|
||||
function assertHasRecurrence(expectedRecurrence: Recurrence | null | undefined, rrule: string) {
|
||||
if (!expectedRecurrence) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedRrule = `FREQ=${
|
||||
expectedRecurrence.freq === 0 ? "YEARLY" : expectedRecurrence.freq === 1 ? "MONTHLY" : "WEEKLY"
|
||||
};COUNT=${expectedRecurrence.count};INTERVAL=${expectedRecurrence.interval}`;
|
||||
|
||||
logger.silly({
|
||||
expectedRrule,
|
||||
rrule,
|
||||
});
|
||||
expect(rrule).toContain(expectedRrule);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -235,21 +327,50 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
guests,
|
||||
otherTeamMembers,
|
||||
iCalUID,
|
||||
recurrence,
|
||||
bookingTimeRange,
|
||||
booking,
|
||||
}: {
|
||||
emails: Fixtures["emails"];
|
||||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
guests?: { email: string; name: string }[];
|
||||
otherTeamMembers?: { email: string; name: string }[];
|
||||
organizer: { email: string; name: string; timeZone: string };
|
||||
booker: { email: string; name: string; timeZone?: string };
|
||||
guests?: { email: string; name: string; timeZone?: string }[];
|
||||
otherTeamMembers?: { email: string; name: string; timeZone?: string }[];
|
||||
iCalUID: string;
|
||||
recurrence?: Recurrence;
|
||||
eventDomain?: string;
|
||||
bookingTimeRange?: { start: Date; end: Date };
|
||||
booking: { uid: string; urlOrigin?: string };
|
||||
}) {
|
||||
const bookingUrlOrigin = booking.urlOrigin || WEBAPP_URL;
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "new_event_scheduled_recurring" : "new_event_scheduled",
|
||||
subHeading: "",
|
||||
links: [
|
||||
{
|
||||
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
||||
text: "reschedule",
|
||||
},
|
||||
{
|
||||
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
||||
text: "cancel",
|
||||
},
|
||||
],
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
timeZone: organizer.timeZone,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
iCalUID: `${iCalUID}`,
|
||||
recurrence,
|
||||
},
|
||||
},
|
||||
`${organizer.email}`
|
||||
|
@ -257,12 +378,34 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled",
|
||||
subHeading: "emailed_you_and_any_other_attendees",
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
// Using the default timezone
|
||||
timeZone: booker.timeZone || DEFAULT_TIMEZONE_BOOKER,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
recurrence,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
||||
text: "reschedule",
|
||||
},
|
||||
{
|
||||
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
||||
text: "cancel",
|
||||
},
|
||||
],
|
||||
},
|
||||
`${booker.name} <${booker.email}>`
|
||||
);
|
||||
|
@ -271,13 +414,33 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
otherTeamMembers.forEach((otherTeamMember) => {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "new_event_scheduled_recurring" : "new_event_scheduled",
|
||||
subHeading: "",
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
timeZone: otherTeamMember.timeZone || DEFAULT_TIMEZONE_BOOKER,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
|
||||
to: `${otherTeamMember.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
iCalUID: iCalUID,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
href: `${bookingUrlOrigin}/reschedule/${booking.uid}`,
|
||||
text: "reschedule",
|
||||
},
|
||||
{
|
||||
href: `${bookingUrlOrigin}/booking/${booking.uid}?cancel=true&allRemainingBookings=false`,
|
||||
text: "cancel",
|
||||
},
|
||||
],
|
||||
},
|
||||
`${otherTeamMember.email}`
|
||||
);
|
||||
|
@ -288,7 +451,17 @@ export function expectSuccessfulBookingCreationEmails({
|
|||
guests.forEach((guest) => {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "confirmed_event_type_subject",
|
||||
titleTag: "confirmed_event_type_subject",
|
||||
heading: recurrence ? "your_event_has_been_scheduled_recurring" : "your_event_has_been_scheduled",
|
||||
subHeading: "emailed_you_and_any_other_attendees",
|
||||
...(bookingTimeRange
|
||||
? {
|
||||
bookingTimeRange: {
|
||||
...bookingTimeRange,
|
||||
timeZone: guest.timeZone || DEFAULT_TIMEZONE_BOOKER,
|
||||
},
|
||||
}
|
||||
: null),
|
||||
to: `${guest.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
|
@ -311,7 +484,7 @@ export function expectBrokenIntegrationEmails({
|
|||
// Broken Integration email is only sent to the Organizer
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "broken_integration",
|
||||
titleTag: "broken_integration",
|
||||
to: `${organizer.email}`,
|
||||
// No ics goes in case of broken integration email it seems
|
||||
// ics: {
|
||||
|
@ -344,7 +517,7 @@ export function expectCalendarEventCreationFailureEmails({
|
|||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "broken_integration",
|
||||
titleTag: "broken_integration",
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
|
@ -356,7 +529,7 @@ export function expectCalendarEventCreationFailureEmails({
|
|||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "calendar_event_creation_failure_subject",
|
||||
titleTag: "calendar_event_creation_failure_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
|
@ -378,11 +551,11 @@ export function expectSuccessfulBookingRescheduledEmails({
|
|||
organizer: { email: string; name: string };
|
||||
booker: { email: string; name: string };
|
||||
iCalUID: string;
|
||||
appsStatus: AppsStatus[];
|
||||
appsStatus?: AppsStatus[];
|
||||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "event_type_has_been_rescheduled_on_time_date",
|
||||
titleTag: "event_type_has_been_rescheduled_on_time_date",
|
||||
to: `${organizer.email}`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
|
@ -395,7 +568,7 @@ export function expectSuccessfulBookingRescheduledEmails({
|
|||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "event_type_has_been_rescheduled_on_time_date",
|
||||
titleTag: "event_type_has_been_rescheduled_on_time_date",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
ics: {
|
||||
filename: "event.ics",
|
||||
|
@ -415,7 +588,7 @@ export function expectAwaitingPaymentEmails({
|
|||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "awaiting_payment_subject",
|
||||
titleTag: "awaiting_payment_subject",
|
||||
to: `${booker.name} <${booker.email}>`,
|
||||
noIcs: true,
|
||||
},
|
||||
|
@ -434,7 +607,7 @@ export function expectBookingRequestedEmails({
|
|||
}) {
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "event_awaiting_approval_subject",
|
||||
titleTag: "event_awaiting_approval_subject",
|
||||
to: `${organizer.email}`,
|
||||
noIcs: true,
|
||||
},
|
||||
|
@ -443,7 +616,7 @@ export function expectBookingRequestedEmails({
|
|||
|
||||
expect(emails).toHaveEmail(
|
||||
{
|
||||
title: "booking_submitted_subject",
|
||||
titleTag: "booking_submitted_subject",
|
||||
to: `${booker.email}`,
|
||||
noIcs: true,
|
||||
},
|
||||
|
@ -629,32 +802,42 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
updateEventCalls: any[];
|
||||
},
|
||||
expected: {
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars: Partial<DestinationCalendar>[];
|
||||
}
|
||||
expected:
|
||||
| {
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars?: Partial<DestinationCalendar>[];
|
||||
}
|
||||
| {
|
||||
calendarId?: string | null;
|
||||
videoCallUrl: string;
|
||||
destinationCalendars?: Partial<DestinationCalendar>[];
|
||||
}[]
|
||||
) {
|
||||
expect(calendarMock.createEventCalls.length).toBe(1);
|
||||
const call = calendarMock.createEventCalls[0];
|
||||
const calEvent = call[0];
|
||||
const expecteds = expected instanceof Array ? expected : [expected];
|
||||
expect(calendarMock.createEventCalls.length).toBe(expecteds.length);
|
||||
for (let i = 0; i < calendarMock.createEventCalls.length; i++) {
|
||||
const expected = expecteds[i];
|
||||
|
||||
expect(calEvent).toEqual(
|
||||
expect.objectContaining({
|
||||
destinationCalendar: expected.calendarId
|
||||
? [
|
||||
expect.objectContaining({
|
||||
externalId: expected.calendarId,
|
||||
}),
|
||||
]
|
||||
: expected.destinationCalendars
|
||||
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
|
||||
: null,
|
||||
videoCallData: expect.objectContaining({
|
||||
url: expected.videoCallUrl,
|
||||
}),
|
||||
})
|
||||
);
|
||||
const calEvent = calendarMock.createEventCalls[i][0];
|
||||
|
||||
expect(calEvent).toEqual(
|
||||
expect.objectContaining({
|
||||
destinationCalendar: expected.calendarId
|
||||
? [
|
||||
expect.objectContaining({
|
||||
externalId: expected.calendarId,
|
||||
}),
|
||||
]
|
||||
: expected.destinationCalendars
|
||||
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
|
||||
: null,
|
||||
videoCallData: expect.objectContaining({
|
||||
url: expected.videoCallUrl,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function expectSuccessfulCalendarEventUpdationInCalendar(
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { defineConfig } from "checkly";
|
||||
|
||||
/**
|
||||
* See https://www.checklyhq.com/docs/cli/project-structure/
|
||||
*/
|
||||
const config = defineConfig({
|
||||
/* A human friendly name for your project */
|
||||
projectName: "calcom-monorepo",
|
||||
/** A logical ID that needs to be unique across your Checkly account,
|
||||
* See https://www.checklyhq.com/docs/cli/constructs/ to learn more about logical IDs.
|
||||
*/
|
||||
logicalId: "calcom-monorepo",
|
||||
/* An optional URL to your Git repo */
|
||||
repoUrl: "https://github.com/checkly/checkly-cli",
|
||||
/* Sets default values for Checks */
|
||||
checks: {
|
||||
/* A default for how often your Check should run in minutes */
|
||||
frequency: 10,
|
||||
/* Checkly data centers to run your Checks as monitors */
|
||||
locations: ["us-east-1", "eu-west-1"],
|
||||
/* An optional array of tags to organize your Checks */
|
||||
tags: ["Web"],
|
||||
/** The Checkly Runtime identifier, determining npm packages and the Node.js version available at runtime.
|
||||
* See https://www.checklyhq.com/docs/cli/npm-packages/
|
||||
*/
|
||||
runtimeId: "2023.02",
|
||||
/* A glob pattern that matches the Checks inside your repo, see https://www.checklyhq.com/docs/cli/using-check-test-match/ */
|
||||
checkMatch: "**/__checks__/**/*.check.ts",
|
||||
browserChecks: {
|
||||
/* A glob pattern matches any Playwright .spec.ts files and automagically creates a Browser Check. This way, you
|
||||
* can just write native Playwright code. See https://www.checklyhq.com/docs/cli/using-check-test-match/
|
||||
* */
|
||||
testMatch: "**/__checks__/**/*.spec.ts",
|
||||
},
|
||||
},
|
||||
cli: {
|
||||
/* The default datacenter location to use when running npx checkly test */
|
||||
runLocation: "eu-west-1",
|
||||
/* An array of default reporters to use when a reporter is not specified with the "--reporter" flag */
|
||||
reporters: ["list"],
|
||||
},
|
||||
});
|
||||
|
||||
export default config;
|
|
@ -81,6 +81,7 @@
|
|||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@types/jsonwebtoken": "^9.0.3",
|
||||
"c8": "^7.13.0",
|
||||
"checkly": "latest",
|
||||
"dotenv-checker": "^1.1.5",
|
||||
"husky": "^8.0.0",
|
||||
"i18n-unused": "^0.13.0",
|
||||
|
|
|
@ -4,6 +4,7 @@ import type { calendar_v3 } from "googleapis";
|
|||
import { google } from "googleapis";
|
||||
|
||||
import { MeetLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import type CalendarService from "@calcom/lib/CalendarService";
|
||||
|
@ -369,57 +370,75 @@ export default class GoogleCalendarService implements Calendar {
|
|||
timeMin: string;
|
||||
timeMax: string;
|
||||
items: { id: string }[];
|
||||
}): Promise<calendar_v3.Schema$FreeBusyResponse> {
|
||||
}): Promise<EventBusyDate[] | null> {
|
||||
const calendar = await this.authedCalendar();
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
|
||||
let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {};
|
||||
if (!flags["calendar-cache"]) {
|
||||
this.log.warn("Calendar Cache is disabled - Skipping");
|
||||
const { timeMin, timeMax, items } = args;
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
return apires.data;
|
||||
|
||||
freeBusyResult = apires.data;
|
||||
} else {
|
||||
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
|
||||
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
|
||||
const key = JSON.stringify({ timeMin, timeMax, items });
|
||||
const cached = await prisma.calendarCache.findUnique({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
expiresAt: { gte: new Date(Date.now()) },
|
||||
},
|
||||
});
|
||||
|
||||
if (cached) {
|
||||
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
|
||||
} else {
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
|
||||
// Skipping await to respond faster
|
||||
await prisma.calendarCache.upsert({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
create: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
});
|
||||
|
||||
freeBusyResult = apires.data;
|
||||
}
|
||||
}
|
||||
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
|
||||
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
|
||||
const key = JSON.stringify({ timeMin, timeMax, items });
|
||||
const cached = await prisma.calendarCache.findUnique({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
expiresAt: { gte: new Date(Date.now()) },
|
||||
},
|
||||
});
|
||||
if (!freeBusyResult.calendars) return null;
|
||||
|
||||
if (cached) return cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
|
||||
|
||||
const apires = await calendar.freebusy.query({
|
||||
requestBody: { timeMin, timeMax, items },
|
||||
});
|
||||
|
||||
// Skipping await to respond faster
|
||||
await prisma.calendarCache.upsert({
|
||||
where: {
|
||||
credentialId_key: {
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
create: {
|
||||
value: JSON.parse(JSON.stringify(apires.data)),
|
||||
credentialId: this.credential.id,
|
||||
key,
|
||||
expiresAt: new Date(Date.now() + CACHING_TIME),
|
||||
},
|
||||
});
|
||||
|
||||
return apires.data;
|
||||
const result = Object.values(freeBusyResult.calendars).reduce((c, i) => {
|
||||
i.busy?.forEach((busyTime) => {
|
||||
c.push({
|
||||
start: busyTime.start || "",
|
||||
end: busyTime.end || "",
|
||||
});
|
||||
});
|
||||
return c;
|
||||
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
|
||||
return result;
|
||||
}
|
||||
|
||||
async getAvailability(
|
||||
|
@ -444,22 +463,44 @@ export default class GoogleCalendarService implements Calendar {
|
|||
|
||||
try {
|
||||
const calsIds = await getCalIds();
|
||||
const freeBusyData = await this.getCacheOrFetchAvailability({
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calsIds.map((id) => ({ id })),
|
||||
});
|
||||
if (!freeBusyData?.calendars) throw new Error("No response from google calendar");
|
||||
const result = Object.values(freeBusyData.calendars).reduce((c, i) => {
|
||||
i.busy?.forEach((busyTime) => {
|
||||
c.push({
|
||||
start: busyTime.start || "",
|
||||
end: busyTime.end || "",
|
||||
});
|
||||
const originalStartDate = dayjs(dateFrom);
|
||||
const originalEndDate = dayjs(dateTo);
|
||||
const diff = originalEndDate.diff(originalStartDate, "days");
|
||||
|
||||
// /freebusy from google api only allows a date range of 90 days
|
||||
if (diff <= 90) {
|
||||
const freeBusyData = await this.getCacheOrFetchAvailability({
|
||||
timeMin: dateFrom,
|
||||
timeMax: dateTo,
|
||||
items: calsIds.map((id) => ({ id })),
|
||||
});
|
||||
return c;
|
||||
}, [] as Prisma.PromiseReturnType<CalendarService["getAvailability"]>);
|
||||
return result;
|
||||
if (!freeBusyData) throw new Error("No response from google calendar");
|
||||
|
||||
return freeBusyData;
|
||||
} else {
|
||||
const busyData = [];
|
||||
|
||||
const loopsNumber = Math.ceil(diff / 90);
|
||||
|
||||
let startDate = originalStartDate;
|
||||
let endDate = originalStartDate.add(90, "days");
|
||||
|
||||
for (let i = 0; i < loopsNumber; i++) {
|
||||
if (endDate.isAfter(originalEndDate)) endDate = originalEndDate;
|
||||
|
||||
busyData.push(
|
||||
...((await this.getCacheOrFetchAvailability({
|
||||
timeMin: startDate.format(),
|
||||
timeMax: endDate.format(),
|
||||
items: calsIds.map((id) => ({ id })),
|
||||
})) || [])
|
||||
);
|
||||
|
||||
startDate = endDate.add(1, "minutes");
|
||||
endDate = startDate.add(90, "days");
|
||||
}
|
||||
return busyData;
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error("There was an error contacting google calendar service: ", error);
|
||||
throw error;
|
||||
|
|
|
@ -54,7 +54,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { form: formId, slug: _slug, pages: _pages, ...fieldsResponses } = queryParsed.data;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
|
|
|
@ -248,7 +248,7 @@ export const getServerSideProps = async function getServerSideProps(
|
|||
notFound: true,
|
||||
};
|
||||
}
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
const isEmbed = params.appPages[1] === "embed";
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapt
|
|||
import type { ParseRefreshTokenResponse } from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import parseRefreshTokenResponse from "../../_utils/oauth/parseRefreshTokenResponse";
|
||||
import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens";
|
||||
import metadata from "../_metadata";
|
||||
import { getZoomAppKeys } from "./getZoomAppKeys";
|
||||
|
||||
/** @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate */
|
||||
|
@ -91,7 +92,7 @@ const zoomAuth = (credential: CredentialPayload) => {
|
|||
grant_type: "refresh_token",
|
||||
}),
|
||||
}),
|
||||
"zoom",
|
||||
metadata.slug,
|
||||
credential.userId
|
||||
);
|
||||
|
||||
|
|
|
@ -489,6 +489,22 @@ export default class EventManager {
|
|||
*/
|
||||
private async createAllCalendarEvents(event: CalendarEvent) {
|
||||
let createdEvents: EventResult<NewCalendarEventType>[] = [];
|
||||
|
||||
const fallbackToFirstConnectedCalendar = async () => {
|
||||
/**
|
||||
* Not ideal but, if we don't find a destination calendar,
|
||||
* fallback to the first connected calendar - Shouldn't be a CRM calendar
|
||||
*/
|
||||
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
|
||||
if (credential) {
|
||||
const createdEvent = await createEvent(credential, event);
|
||||
log.silly("Created Calendar event", safeStringify({ createdEvent }));
|
||||
if (createdEvent) {
|
||||
createdEvents.push(createdEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (event.destinationCalendar && event.destinationCalendar.length > 0) {
|
||||
// Since GCal pushes events to multiple calendars we only want to create one event per booking
|
||||
let gCalAdded = false;
|
||||
|
@ -545,6 +561,14 @@ export default class EventManager {
|
|||
);
|
||||
// It might not be the first connected calendar as it seems that the order is not guaranteed to be ascending of credentialId.
|
||||
const firstCalendarCredential = destinationCalendarCredentials[0];
|
||||
|
||||
if (!firstCalendarCredential) {
|
||||
log.warn(
|
||||
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
|
||||
);
|
||||
await fallbackToFirstConnectedCalendar();
|
||||
}
|
||||
|
||||
log.warn(
|
||||
"No credentialId found for destination calendar, falling back to first found calendar",
|
||||
safeStringify({
|
||||
|
@ -563,19 +587,7 @@ export default class EventManager {
|
|||
calendarCredentials: this.calendarCredentials,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Not ideal but, if we don't find a destination calendar,
|
||||
* fallback to the first connected calendar - Shouldn't be a CRM calendar
|
||||
*/
|
||||
const [credential] = this.calendarCredentials.filter((cred) => !cred.type.endsWith("other_calendar"));
|
||||
if (credential) {
|
||||
const createdEvent = await createEvent(credential, event);
|
||||
log.silly("Created Calendar event", safeStringify({ createdEvent }));
|
||||
if (createdEvent) {
|
||||
createdEvents.push(createdEvent);
|
||||
}
|
||||
}
|
||||
await fallbackToFirstConnectedCalendar();
|
||||
}
|
||||
|
||||
// Taking care of non-traditional calendar integrations
|
||||
|
|
|
@ -255,7 +255,10 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
|||
const queryParams = new URLSearchParams();
|
||||
queryParams.set("rescheduleUid", `${booking.uid}`);
|
||||
slug = `${slug}`;
|
||||
const rescheduleLink = `${WEBAPP_URL}/${slug}?${queryParams.toString()}`;
|
||||
|
||||
const rescheduleLink = `${
|
||||
this.calendarEvent.bookerUrl ?? WEBAPP_URL
|
||||
}/${slug}?${queryParams.toString()}`;
|
||||
this.rescheduleLink = rescheduleLink;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from "@calcom/types/Calendar";
|
||||
|
||||
class CalendarEventClass implements CalendarEvent {
|
||||
bookerUrl?: string | undefined;
|
||||
type!: string;
|
||||
title!: string;
|
||||
startTime!: string;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CSSProperties } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import EmailCommonDivider from "./EmailCommonDivider";
|
||||
|
||||
|
@ -19,6 +19,7 @@ const EmailScheduledBodyHeaderContent = (props: {
|
|||
wordBreak: "break-word",
|
||||
}}>
|
||||
<div
|
||||
data-testid="heading"
|
||||
style={{
|
||||
fontFamily: "Roboto, Helvetica, sans-serif",
|
||||
fontSize: 24,
|
||||
|
@ -35,6 +36,7 @@ const EmailScheduledBodyHeaderContent = (props: {
|
|||
<tr>
|
||||
<td align="center" style={{ fontSize: 0, padding: "10px 25px", wordBreak: "break-word" }}>
|
||||
<div
|
||||
data-testid="subHeading"
|
||||
style={{
|
||||
fontFamily: "Roboto, Helvetica, sans-serif",
|
||||
fontSize: 16,
|
||||
|
|
|
@ -61,11 +61,11 @@ export function WhenInfo(props: {
|
|||
!!props.calEvent.cancellationReason && !props.calEvent.cancellationReason.includes("$RCH$")
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<span data-testid="when">
|
||||
{recurringEvent?.count ? `${t("starting")} ` : ""}
|
||||
{getRecipientStart(`dddd, LL | ${timeFormat}`)} - {getRecipientEnd(timeFormat)}{" "}
|
||||
<span style={{ color: "#4B5563" }}>({timeZone})</span>
|
||||
</>
|
||||
</span>
|
||||
}
|
||||
withSpacer
|
||||
/>
|
||||
|
|
|
@ -161,8 +161,8 @@ const EmailStep = (props: { translationString: string; iconsrc: string }) => {
|
|||
style={{
|
||||
backgroundColor: "#E5E7EB",
|
||||
borderRadius: "48px",
|
||||
height: "48px",
|
||||
width: "48px",
|
||||
minHeight: "48px",
|
||||
minWidth: "48px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
|
|
@ -50,8 +50,13 @@ export default class OrganizerDailyVideoDownloadRecordingEmail extends BaseEmail
|
|||
return this.getFormattedRecipientTime({ time: this.calEvent.endTime, format });
|
||||
}
|
||||
|
||||
protected getLocale(): string {
|
||||
return this.calEvent.organizer.language.locale;
|
||||
}
|
||||
|
||||
protected getFormattedDate() {
|
||||
const organizerTimeFormat = this.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR;
|
||||
|
||||
return `${this.getOrganizerStart(organizerTimeFormat)} - ${this.getOrganizerEnd(
|
||||
organizerTimeFormat
|
||||
)}, ${this.t(this.getOrganizerStart("dddd").toLowerCase())}, ${this.t(
|
||||
|
|
|
@ -53,24 +53,26 @@ import {
|
|||
import { createMockNextJsRequest } from "./lib/createMockNextJsRequest";
|
||||
import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "./lib/setupAndTeardown";
|
||||
import { testWithAndWithoutOrg } from "./lib/test";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
// Local test runs sometime gets too slow
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
describe("handleNewBooking", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
describe("Fresh/New Booking:", () => {
|
||||
test(
|
||||
testWithAndWithoutOrg(
|
||||
`should create a successful booking with Cal Video(Daily Video) if no explicit location is provided
|
||||
1. Should create a booking in the database
|
||||
2. Should send emails to the booker as well as organizer
|
||||
3. Should create a booking in the event's destination calendar
|
||||
3. Should trigger BOOKING_CREATED webhook
|
||||
`,
|
||||
async ({ emails }) => {
|
||||
async ({ emails, org }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
|
@ -89,37 +91,41 @@ describe("handleNewBooking", () => {
|
|||
externalId: "organizer@google-calendar.com",
|
||||
},
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
getScenarioData(
|
||||
{
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
},
|
||||
org?.organization
|
||||
)
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
|
@ -195,6 +201,10 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
urlOrigin: org ? org.urlOrigin : WEBAPP_URL,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
@ -343,6 +353,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
@ -488,6 +501,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
@ -749,6 +765,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
@ -834,11 +853,14 @@ describe("handleNewBooking", () => {
|
|||
const createdBooking = await handleNewBooking(req);
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
// Because no calendar was involved, we don't have an ics UID
|
||||
iCalUID: createdBooking.uid,
|
||||
iCalUID: createdBooking.uid!,
|
||||
});
|
||||
|
||||
expectBookingCreatedWebhookToHaveBeenFired({
|
||||
|
@ -1436,6 +1458,9 @@ describe("handleNewBooking", () => {
|
|||
expectWorkflowToBeTriggered();
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
@ -1730,6 +1755,9 @@ describe("handleNewBooking", () => {
|
|||
expectWorkflowToBeTriggered();
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
|
||||
export const DEFAULT_TIMEZONE_BOOKER = "Asia/Kolkata";
|
||||
export function getBasicMockRequestDataForBooking() {
|
||||
return {
|
||||
start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`,
|
||||
end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`,
|
||||
eventTypeSlug: "no-confirmation",
|
||||
timeZone: "Asia/Calcutta",
|
||||
timeZone: DEFAULT_TIMEZONE_BOOKER,
|
||||
language: "en",
|
||||
user: "teampro",
|
||||
metadata: {},
|
||||
|
@ -20,6 +21,8 @@ export function getMockRequestDataForBooking({
|
|||
eventTypeId: number;
|
||||
rescheduleUid?: string;
|
||||
bookingUid?: string;
|
||||
recurringEventId?: string;
|
||||
recurringCount?: number;
|
||||
responses: {
|
||||
email: string;
|
||||
name: string;
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
import type { TestFunction } from "vitest";
|
||||
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
import type { Fixtures } from "@calcom/web/test/fixtures/fixtures";
|
||||
import { createOrganization } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
|
||||
const _testWithAndWithoutOrg = (
|
||||
description: Parameters<typeof testWithAndWithoutOrg>[0],
|
||||
fn: Parameters<typeof testWithAndWithoutOrg>[1],
|
||||
timeout: Parameters<typeof testWithAndWithoutOrg>[2],
|
||||
mode: "only" | "skip" | "run" = "run"
|
||||
) => {
|
||||
const t = mode === "only" ? test.only : mode === "skip" ? test.skip : test;
|
||||
t(
|
||||
`${description} - With org`,
|
||||
async ({ emails, meta, task, onTestFailed, expect, skip }) => {
|
||||
const org = await createOrganization({
|
||||
name: "Test Org",
|
||||
slug: "testorg",
|
||||
});
|
||||
|
||||
await fn({
|
||||
meta,
|
||||
task,
|
||||
onTestFailed,
|
||||
expect,
|
||||
emails,
|
||||
skip,
|
||||
org: {
|
||||
organization: org,
|
||||
urlOrigin: `http://${org.slug}.cal.local:3000`,
|
||||
},
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
t(
|
||||
`${description}`,
|
||||
async ({ emails, meta, task, onTestFailed, expect, skip }) => {
|
||||
await fn({
|
||||
emails,
|
||||
meta,
|
||||
task,
|
||||
onTestFailed,
|
||||
expect,
|
||||
skip,
|
||||
org: null,
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
};
|
||||
|
||||
export const testWithAndWithoutOrg = (
|
||||
description: string,
|
||||
fn: TestFunction<
|
||||
Fixtures & {
|
||||
org: {
|
||||
organization: { id: number | null };
|
||||
urlOrigin?: string;
|
||||
} | null;
|
||||
}
|
||||
>,
|
||||
timeout?: number
|
||||
) => {
|
||||
_testWithAndWithoutOrg(description, fn, timeout, "run");
|
||||
};
|
||||
|
||||
testWithAndWithoutOrg.only = ((description, fn) => {
|
||||
_testWithAndWithoutOrg(description, fn, "only");
|
||||
}) as typeof _testWithAndWithoutOrg;
|
||||
|
||||
testWithAndWithoutOrg.skip = ((description, fn) => {
|
||||
_testWithAndWithoutOrg(description, fn, "skip");
|
||||
}) as typeof _testWithAndWithoutOrg;
|
|
@ -213,6 +213,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
|
@ -525,6 +528,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
|
@ -842,6 +848,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
|
@ -1056,6 +1065,9 @@ describe("handleNewBooking", () => {
|
|||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
|
|
|
@ -95,7 +95,7 @@ const NoAvailabilityOverlay = ({
|
|||
return (
|
||||
<div className="bg-muted border-subtle absolute left-1/2 top-40 -mt-10 w-max -translate-x-1/2 -translate-y-1/2 transform rounded-md border p-8 shadow-sm">
|
||||
<h4 className="text-emphasis mb-4 font-medium">{t("no_availability_in_month", { month: month })}</h4>
|
||||
<Button onClick={nextMonthButton} color="primary" EndIcon={ArrowRight}>
|
||||
<Button onClick={nextMonthButton} color="primary" EndIcon={ArrowRight} data-testid="view_next_month">
|
||||
{t("view_next_month")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getAvailableDatesInMonth } from "@calcom/features/calendars/lib/getAvailableDatesInMonth";
|
||||
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
||||
|
||||
|
@ -63,5 +64,22 @@ describe("Test Suite: Date Picker", () => {
|
|||
vi.setSystemTime(vi.getRealSystemTime());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("it returns the correct responses end of month", () => {
|
||||
// test a date at one minute past midnight, end of month.
|
||||
// we use dayjs() as the system timezone can still modify the Date.
|
||||
vi.useFakeTimers().setSystemTime(dayjs().endOf("month").startOf("day").add(1, "second").toDate());
|
||||
|
||||
const currentDate = new Date();
|
||||
const result = getAvailableDatesInMonth({
|
||||
browsingDate: currentDate,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
// Undo the forced time we applied earlier, reset to system default.
|
||||
vi.setSystemTime(vi.getRealSystemTime());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import dayjs from "@calcom/dayjs";
|
||||
import { daysInMonth, yyyymmdd } from "@calcom/lib/date-fns";
|
||||
|
||||
// calculate the available dates in the month:
|
||||
|
@ -21,7 +22,9 @@ export function getAvailableDatesInMonth({
|
|||
);
|
||||
for (
|
||||
let date = browsingDate > minDate ? browsingDate : minDate;
|
||||
date <= lastDateOfMonth;
|
||||
// Check if date is before the last date of the month
|
||||
// or is the same day, in the same month, in the same year.
|
||||
date < lastDateOfMonth || dayjs(date).isSame(lastDateOfMonth, "day");
|
||||
date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1)
|
||||
) {
|
||||
// intersect included dates
|
||||
|
|
|
@ -5,6 +5,7 @@ import { z } from "zod";
|
|||
|
||||
import { getSession } from "@calcom/features/auth/lib/getSession";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
const teamIdschema = z.object({
|
||||
teamId: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number().positive()),
|
||||
|
@ -63,11 +64,75 @@ export function checkUserIdentifier(creds: Partial<Credentials>) {
|
|||
}
|
||||
|
||||
export function checkPermission(session: Session | null) {
|
||||
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
|
||||
if (
|
||||
(session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") ||
|
||||
!session?.user
|
||||
) {
|
||||
throw new Error("You do not have permission to do this.");
|
||||
}
|
||||
}
|
||||
|
||||
async function getImpersonatedUser({
|
||||
session,
|
||||
teamId,
|
||||
creds,
|
||||
}: {
|
||||
session: Session | null;
|
||||
teamId: number | undefined;
|
||||
creds: Credentials | null;
|
||||
}) {
|
||||
let TeamWhereClause: Prisma.MembershipWhereInput = {
|
||||
disableImpersonation: false, // Ensure they have impersonation enabled
|
||||
accepted: true, // Ensure they are apart of the team and not just invited.
|
||||
team: {
|
||||
id: teamId, // Bring back only the right team
|
||||
},
|
||||
};
|
||||
|
||||
// If you are an admin we dont need to follow this flow -> We can just follow the usual flow
|
||||
// If orgId and teamId are the same we can follow the same flow
|
||||
if (session?.user.org?.id && session.user.org.id !== teamId && session?.user.role !== "ADMIN") {
|
||||
TeamWhereClause = {
|
||||
disableImpersonation: false,
|
||||
accepted: true,
|
||||
team: {
|
||||
id: session.user.org.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Get user who is being impersonated
|
||||
const impersonatedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: creds?.username }, { email: creds?.username }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
organizationId: true,
|
||||
disableImpersonation: true,
|
||||
locale: true,
|
||||
teams: {
|
||||
where: TeamWhereClause,
|
||||
select: {
|
||||
teamId: true,
|
||||
disableImpersonation: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!impersonatedUser) {
|
||||
throw new Error("This user does not exist");
|
||||
}
|
||||
|
||||
return impersonatedUser;
|
||||
}
|
||||
|
||||
const ImpersonationProvider = CredentialsProvider({
|
||||
id: "impersonation-auth",
|
||||
name: "Impersonation",
|
||||
|
@ -85,41 +150,7 @@ const ImpersonationProvider = CredentialsProvider({
|
|||
checkUserIdentifier(creds);
|
||||
checkPermission(session);
|
||||
|
||||
// Get user who is being impersonated
|
||||
const impersonatedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ username: creds?.username }, { email: creds?.username }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
organizationId: true,
|
||||
disableImpersonation: true,
|
||||
locale: true,
|
||||
teams: {
|
||||
where: {
|
||||
disableImpersonation: false, // Ensure they have impersonation enabled
|
||||
accepted: true, // Ensure they are apart of the team and not just invited.
|
||||
team: {
|
||||
id: teamId, // Bring back only the right team
|
||||
},
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
disableImpersonation: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if impersonating is allowed for this user
|
||||
if (!impersonatedUser) {
|
||||
throw new Error("This user does not exist");
|
||||
}
|
||||
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });
|
||||
|
||||
if (session?.user.role === "ADMIN") {
|
||||
if (impersonatedUser.disableImpersonation) {
|
||||
|
|
|
@ -1,16 +1,33 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
|
||||
const log = logger.getSubLogger({
|
||||
prefix: ["orgDomains.ts"],
|
||||
});
|
||||
/**
|
||||
* return the org slug
|
||||
* @param hostname
|
||||
*/
|
||||
export function getOrgSlug(hostname: string) {
|
||||
export function getOrgSlug(hostname: string, forcedSlug?: string) {
|
||||
if (forcedSlug) {
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
log.debug("Using provided forcedSlug in E2E", {
|
||||
forcedSlug,
|
||||
});
|
||||
return forcedSlug;
|
||||
}
|
||||
log.debug("Ignoring forcedSlug in non-test mode", {
|
||||
forcedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
if (!hostname.includes(".")) {
|
||||
// A no-dot domain can never be org domain. It automatically handles localhost
|
||||
log.warn('Org support not enabled for hostname without "."', { hostname });
|
||||
// A no-dot domain can never be org domain. It automatically considers localhost to be non-org domain
|
||||
return null;
|
||||
}
|
||||
// Find which hostname is being currently used
|
||||
|
@ -19,24 +36,45 @@ export function getOrgSlug(hostname: string) {
|
|||
const testHostname = `${url.hostname}${url.port ? `:${url.port}` : ""}`;
|
||||
return testHostname.endsWith(`.${ahn}`);
|
||||
});
|
||||
logger.debug(`getOrgSlug: ${hostname} ${currentHostname}`, {
|
||||
ALLOWED_HOSTNAMES,
|
||||
WEBAPP_URL,
|
||||
currentHostname,
|
||||
hostname,
|
||||
});
|
||||
if (currentHostname) {
|
||||
// Define which is the current domain/subdomain
|
||||
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
|
||||
return slug.indexOf(".") === -1 ? slug : null;
|
||||
|
||||
if (!currentHostname) {
|
||||
log.warn("Match of WEBAPP_URL with ALLOWED_HOSTNAME failed", { WEBAPP_URL, ALLOWED_HOSTNAMES });
|
||||
return null;
|
||||
}
|
||||
// Define which is the current domain/subdomain
|
||||
const slug = hostname.replace(`.${currentHostname}` ?? "", "");
|
||||
const hasNoDotInSlug = slug.indexOf(".") === -1;
|
||||
if (hasNoDotInSlug) {
|
||||
return slug;
|
||||
}
|
||||
log.warn("Derived slug ended up having dots, so not considering it an org domain", { slug });
|
||||
return null;
|
||||
}
|
||||
|
||||
export function orgDomainConfig(hostname: string, fallback?: string | string[]) {
|
||||
const currentOrgDomain = getOrgSlug(hostname);
|
||||
export function orgDomainConfig(req: IncomingMessage | undefined, fallback?: string | string[]) {
|
||||
const forcedSlugHeader = req?.headers?.["x-cal-force-slug"];
|
||||
|
||||
const forcedSlug = forcedSlugHeader instanceof Array ? forcedSlugHeader[0] : forcedSlugHeader;
|
||||
|
||||
const hostname = req?.headers?.host || "";
|
||||
return getOrgDomainConfigFromHostname({
|
||||
hostname,
|
||||
fallback,
|
||||
forcedSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOrgDomainConfigFromHostname({
|
||||
hostname,
|
||||
fallback,
|
||||
forcedSlug,
|
||||
}: {
|
||||
hostname: string;
|
||||
fallback?: string | string[];
|
||||
forcedSlug?: string;
|
||||
}) {
|
||||
const currentOrgDomain = getOrgSlug(hostname, forcedSlug);
|
||||
const isValidOrgDomain = currentOrgDomain !== null && !RESERVED_SUBDOMAINS.includes(currentOrgDomain);
|
||||
logger.debug(`orgDomainConfig: ${hostname} ${currentOrgDomain} ${isValidOrgDomain}`);
|
||||
if (isValidOrgDomain || !fallback) {
|
||||
return {
|
||||
currentOrgDomain: isValidOrgDomain ? currentOrgDomain : null,
|
||||
|
@ -58,7 +96,10 @@ export function subdomainSuffix() {
|
|||
|
||||
export function getOrgFullOrigin(slug: string, options: { protocol: boolean } = { protocol: true }) {
|
||||
if (!slug) return WEBAPP_URL;
|
||||
return `${options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""}${slug}.${subdomainSuffix()}`;
|
||||
const orgFullOrigin = `${
|
||||
options.protocol ? `${new URL(WEBAPP_URL).protocol}//` : ""
|
||||
}${slug}.${subdomainSuffix()}`;
|
||||
return orgFullOrigin;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -100,6 +141,6 @@ export function whereClauseForOrgWithSlugOrRequestedSlug(slug: string) {
|
|||
}
|
||||
|
||||
export function userOrgQuery(hostname: string, fallback?: string | string[]) {
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(hostname, fallback);
|
||||
const { currentOrgDomain, isValidOrgDomain } = getOrgDomainConfigFromHostname({ hostname, fallback });
|
||||
return isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null;
|
||||
}
|
||||
|
|
|
@ -194,7 +194,7 @@ const OrgAppearanceView = ({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-subtle rounded-md border p-5">
|
||||
<div className="py-5">
|
||||
<span className="text-default text-sm">{t("only_owner_change")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -113,7 +113,7 @@ const OrgProfileView = () => {
|
|||
{isOrgAdminOrOwner ? (
|
||||
<OrgProfileForm defaultValues={defaultValues} />
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="border-subtle flex rounded-b-md border border-t-0 px-4 py-8 sm:px-6">
|
||||
<div className="flex-grow">
|
||||
<div>
|
||||
<Label className="text-emphasis">{t("org_name")}</Label>
|
||||
|
|
|
@ -147,7 +147,7 @@ const PaymentForm = (props: Props) => {
|
|||
formatParams: { amount: { currency: props.payment.currency } },
|
||||
})}
|
||||
onChange={(e) => setHoldAcknowledged(e.target.checked)}
|
||||
descriptionClassName="text-blue-900 font-semibold"
|
||||
descriptionClassName="text-info font-semibold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -321,7 +321,7 @@ export default function MemberListItem(props: Props) {
|
|||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", {
|
||||
username: props.member.username || props.member.email,
|
||||
username: props.member.email,
|
||||
teamId: props.team.id,
|
||||
});
|
||||
setShowImpersonateModal(false);
|
||||
|
|
|
@ -183,6 +183,7 @@ export default function TeamListItem(props: Props) {
|
|||
<div className={classNames("flex items-center justify-between", !isInvitee && "hover:bg-muted group")}>
|
||||
{!isInvitee ? (
|
||||
<Link
|
||||
data-testid="team-list-item-link"
|
||||
href={`/settings/teams/${team.id}/profile`}
|
||||
className="flex-grow cursor-pointer truncate text-sm"
|
||||
title={`${team.name}`}>
|
||||
|
|
|
@ -174,7 +174,7 @@ export const ViewRecordingsDialog = (props: IViewRecordingsDialog) => {
|
|||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent>
|
||||
<DialogContent enableOverflow>
|
||||
<DialogHeader title={t("recordings_title")} subtitle={subtitle} />
|
||||
{roomName ? (
|
||||
<LicenseRequired>
|
||||
|
|
|
@ -26,6 +26,7 @@ const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
|
|||
const senderEmail = process.env.SENDGRID_EMAIL as string;
|
||||
|
||||
sgMail.setApiKey(sendgridAPIKey);
|
||||
client.setApiKey(sendgridAPIKey);
|
||||
|
||||
type Booking = Prisma.BookingGetPayload<{
|
||||
include: {
|
||||
|
@ -106,6 +107,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
|
||||
const pageSize = 90;
|
||||
let pageNumber = 0;
|
||||
const deletePromises = [];
|
||||
|
||||
//delete batch_ids with already past scheduled date from scheduled_sends
|
||||
while (true) {
|
||||
|
@ -128,19 +130,25 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
break;
|
||||
}
|
||||
|
||||
for (const reminder of remindersToDelete) {
|
||||
try {
|
||||
await client.request({
|
||||
deletePromises.push(
|
||||
remindersToDelete.map((reminder) =>
|
||||
client.request({
|
||||
url: `/v3/user/scheduled_sends/${reminder.referenceId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error deleting batch id from scheduled_sends: ${error}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
Promise.allSettled(deletePromises).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.log(`Error deleting batch id from scheduled_sends: ${result.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await prisma.workflowReminder.deleteMany({
|
||||
where: {
|
||||
method: WorkflowMethods.EMAIL,
|
||||
|
@ -153,6 +161,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
//cancel reminders for cancelled/rescheduled bookings that are scheduled within the next hour
|
||||
|
||||
pageNumber = 0;
|
||||
|
||||
const allPromisesCancelReminders = [];
|
||||
|
||||
while (true) {
|
||||
const remindersToCancel = await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
|
@ -175,32 +186,39 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
}
|
||||
|
||||
for (const reminder of remindersToCancel) {
|
||||
try {
|
||||
await client.request({
|
||||
url: "/v3/user/scheduled_sends",
|
||||
method: "POST",
|
||||
body: {
|
||||
batch_id: reminder.referenceId,
|
||||
status: "cancel",
|
||||
},
|
||||
});
|
||||
const cancelPromise = client.request({
|
||||
url: "/v3/user/scheduled_sends",
|
||||
method: "POST",
|
||||
body: {
|
||||
batch_id: reminder.referenceId,
|
||||
status: "cancel",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
},
|
||||
data: {
|
||||
scheduled: false, // to know which reminder already got cancelled (to avoid error from cancelling the same reminders again)
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`Error cancelling scheduled Emails: ${error}`);
|
||||
}
|
||||
const updatePromise = prisma.workflowReminder.update({
|
||||
where: {
|
||||
id: reminder.id,
|
||||
},
|
||||
data: {
|
||||
scheduled: false, // to know which reminder already got cancelled (to avoid error from cancelling the same reminders again)
|
||||
},
|
||||
});
|
||||
|
||||
allPromisesCancelReminders.push(cancelPromise, updatePromise);
|
||||
}
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
Promise.allSettled(allPromisesCancelReminders).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.log(`Error cancelling scheduled_sends: ${result.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
pageNumber = 0;
|
||||
const sendEmailPromises = [];
|
||||
|
||||
while (true) {
|
||||
//find all unscheduled Email reminders
|
||||
|
@ -390,34 +408,36 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
const batchId = batchIdResponse[1].batch_id;
|
||||
|
||||
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
|
||||
await sgMail.send({
|
||||
to: sendTo,
|
||||
from: {
|
||||
email: senderEmail,
|
||||
name: reminder.workflowStep.sender || "Cal.com",
|
||||
},
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email || senderEmail,
|
||||
mailSettings: {
|
||||
sandboxMode: {
|
||||
enable: sandboxMode,
|
||||
sendEmailPromises.push(
|
||||
sgMail.send({
|
||||
to: sendTo,
|
||||
from: {
|
||||
email: senderEmail,
|
||||
name: reminder.workflowStep.sender || "Cal.com",
|
||||
},
|
||||
},
|
||||
attachments: reminder.workflowStep.includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
subject: emailContent.emailSubject,
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email || senderEmail,
|
||||
mailSettings: {
|
||||
sandboxMode: {
|
||||
enable: sandboxMode,
|
||||
},
|
||||
},
|
||||
attachments: reminder.workflowStep.includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
content: Buffer.from(getiCalEventAsString(reminder.booking) || "").toString("base64"),
|
||||
filename: "event.ics",
|
||||
type: "text/calendar; method=REQUEST",
|
||||
disposition: "attachment",
|
||||
contentId: uuidv4(),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.workflowReminder.update({
|
||||
|
@ -436,6 +456,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
}
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
Promise.allSettled(sendEmailPromises).then((results) => {
|
||||
results.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.log("Email sending failed", result.reason);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.status(200).json({ message: "Emails scheduled" });
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orgDomainConfig, getOrgSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrgSlug, getOrgDomainConfigFromHostname } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
|
||||
function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
|
||||
|
@ -35,10 +35,10 @@ function setupEnvs({ WEBAPP_URL = "https://app.cal.com" } = {}) {
|
|||
}
|
||||
|
||||
describe("Org Domains Utils", () => {
|
||||
describe("orgDomainConfig", () => {
|
||||
describe("getOrgDomainConfigFromHostname", () => {
|
||||
it("should return a valid org domain", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("acme.cal.com")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "acme.cal.com" })).toEqual({
|
||||
currentOrgDomain: "acme",
|
||||
isValidOrgDomain: true,
|
||||
});
|
||||
|
@ -46,7 +46,7 @@ describe("Org Domains Utils", () => {
|
|||
|
||||
it("should return a non valid org domain", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("app.cal.com")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "app.cal.com" })).toEqual({
|
||||
currentOrgDomain: null,
|
||||
isValidOrgDomain: false,
|
||||
});
|
||||
|
@ -54,7 +54,7 @@ describe("Org Domains Utils", () => {
|
|||
|
||||
it("should return a non valid org domain for localhost", () => {
|
||||
setupEnvs();
|
||||
expect(orgDomainConfig("localhost:3000")).toEqual({
|
||||
expect(getOrgDomainConfigFromHostname({ hostname: "localhost:3000" })).toEqual({
|
||||
currentOrgDomain: null,
|
||||
isValidOrgDomain: false,
|
||||
});
|
||||
|
|
|
@ -193,6 +193,7 @@ export function AvailabilitySliderTable() {
|
|||
<>
|
||||
<div className="relative">
|
||||
<DataTable
|
||||
searchKey="member"
|
||||
tableContainerRef={tableContainerRef}
|
||||
columns={memorisedColumns}
|
||||
onRowMouseclick={(row) => {
|
||||
|
|
|
@ -93,6 +93,14 @@ export const tips = [
|
|||
description: "Get a better understanding of your business",
|
||||
href: "https://go.cal.com/insights",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
thumbnailUrl: "https://ph-files.imgix.net/46d376e1-f897-40fc-9921-c64de971ee13.jpeg?auto=compress&codec=mozjpeg&cs=strip&auto=format&w=390&h=220&fit=max&dpr=2",
|
||||
mediaLink: "https://go.cal.com/cal-ai",
|
||||
title: "Cal.ai",
|
||||
description: "Your personal AI scheduling assistant",
|
||||
href: "https://go.cal.com/cal-ai",
|
||||
}
|
||||
];
|
||||
|
||||
const reversedTips = tips.slice(0).reverse();
|
||||
|
|
|
@ -27,7 +27,7 @@ export function ImpersonationMemberModal(props: { state: State; dispatch: Dispat
|
|||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await signIn("impersonation-auth", {
|
||||
username: user.username || user.email,
|
||||
username: user.email,
|
||||
teamId: teamId,
|
||||
});
|
||||
props.dispatch({
|
||||
|
|
|
@ -188,7 +188,7 @@ export async function listBookings(
|
|||
};
|
||||
} else {
|
||||
where.eventType = {
|
||||
teamId: account.id,
|
||||
OR: [{ teamId: account.id }, { parent: { teamId: account.id } }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,8 @@ import dayjs from "@calcom/dayjs";
|
|||
import { buildDateRanges, processDateOverride, processWorkingHours, subtract } from "./date-ranges";
|
||||
|
||||
describe("processWorkingHours", () => {
|
||||
it("should return the correct working hours given a specific availability, timezone, and date range", () => {
|
||||
// TEMPORAIRLY SKIPPING THIS TEST - Started failing after 29th Oct
|
||||
it.skip("should return the correct working hours given a specific availability, timezone, and date range", () => {
|
||||
const item = {
|
||||
days: [1, 2, 3, 4, 5], // Monday to Friday
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||
|
@ -47,8 +48,8 @@ describe("processWorkingHours", () => {
|
|||
|
||||
expect(lastAvailableSlot.start.date()).toBe(31);
|
||||
});
|
||||
|
||||
it("should return the correct working hours in the month were DST ends", () => {
|
||||
// TEMPORAIRLY SKIPPING THIS TEST - Started failing after 29th Oct
|
||||
it.skip("should return the correct working hours in the month were DST ends", () => {
|
||||
const item = {
|
||||
days: [0, 1, 2, 3, 4, 5, 6], // Monday to Sunday
|
||||
startTime: new Date(Date.UTC(2023, 5, 12, 8, 0)), // 8 AM
|
||||
|
|
|
@ -59,18 +59,12 @@ export function getPiiFreeBooking(booking: {
|
|||
}
|
||||
|
||||
export function getPiiFreeCredential(credential: Partial<Credential>) {
|
||||
return {
|
||||
id: credential.id,
|
||||
invalid: credential.invalid,
|
||||
appId: credential.appId,
|
||||
userId: credential.userId,
|
||||
type: credential.type,
|
||||
teamId: credential.teamId,
|
||||
/**
|
||||
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
|
||||
*/
|
||||
key: getBooleanStatus(credential.key),
|
||||
};
|
||||
/**
|
||||
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
|
||||
*/
|
||||
const booleanKeyStatus = getBooleanStatus(credential?.key);
|
||||
|
||||
return { ...credential, key: booleanKeyStatus };
|
||||
}
|
||||
|
||||
export function getPiiFreeSelectedCalendar(selectedCalendar: Partial<SelectedCalendar>) {
|
||||
|
|
|
@ -991,6 +991,7 @@ model TempOrgRedirect {
|
|||
// 0 would mean it is non org
|
||||
fromOrgId Int
|
||||
type RedirectType
|
||||
// It doesn't have any query params
|
||||
toUrl String
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
|
|
@ -22,6 +22,7 @@ import { TRPCError } from "@trpc/server";
|
|||
import { getDefaultScheduleId } from "../viewer/availability/util";
|
||||
import { updateUserMetadataAllowedKeys, type TUpdateProfileInputSchema } from "./updateProfile.schema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["updateProfile"] });
|
||||
type UpdateProfileOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
|
@ -35,6 +36,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
|||
const userMetadata = handleUserMetadata({ ctx, input });
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
...input,
|
||||
avatar: await getAvatarToSet(input.avatar),
|
||||
metadata: userMetadata,
|
||||
};
|
||||
|
||||
|
@ -61,12 +63,6 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
|||
}
|
||||
}
|
||||
}
|
||||
if (input.avatar) {
|
||||
data.avatar = await resizeBase64Image(input.avatar);
|
||||
}
|
||||
if (input.avatar === null) {
|
||||
data.avatar = null;
|
||||
}
|
||||
|
||||
if (isPremiumUsername) {
|
||||
const stripeCustomerId = userMetadata?.stripeCustomerId;
|
||||
|
@ -234,3 +230,17 @@ const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
|
|||
// Required so we don't override and delete saved values
|
||||
return { ...userMetadata, ...cleanMetadata };
|
||||
};
|
||||
|
||||
async function getAvatarToSet(avatar: string | null | undefined) {
|
||||
if (avatar === null || avatar === undefined) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
if (!avatar.startsWith("data:image")) {
|
||||
// Non Base64 avatar currently could only be the dynamic avatar URL(i.e. /{USER}/avatar.png). If we allow setting that URL, we would get infinite redirects on /user/avatar.ts endpoint
|
||||
log.warn("Non Base64 avatar, ignored it", { avatar });
|
||||
// `undefined` would not ignore the avatar, but `null` would remove it. So, we return `undefined` here.
|
||||
return undefined;
|
||||
}
|
||||
return await resizeBase64Image(avatar);
|
||||
}
|
||||
|
|
|
@ -130,14 +130,15 @@ async function getInfoForAllTeams({ ctx, input }: GetOptions) {
|
|||
|
||||
// Get total team count across all teams the user is in (for pagination)
|
||||
|
||||
const totalTeamMembers =
|
||||
await prisma.$queryRaw<number>`SELECT COUNT(DISTINCT "userId")::integer from "Membership" WHERE "teamId" IN (${Prisma.join(
|
||||
teamIds
|
||||
)})`;
|
||||
const totalTeamMembers = await prisma.$queryRaw<
|
||||
{
|
||||
count: number;
|
||||
}[]
|
||||
>`SELECT COUNT(DISTINCT "userId")::integer from "Membership" WHERE "teamId" IN (${Prisma.join(teamIds)})`;
|
||||
|
||||
return {
|
||||
teamMembers,
|
||||
totalTeamMembers,
|
||||
totalTeamMembers: totalTeamMembers[0].count,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
|
|||
import { isPrismaObjOrUndefined } from "@calcom/lib";
|
||||
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { getBookerUrl } from "@calcom/lib/server/getBookerUrl";
|
||||
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
@ -167,6 +168,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
|
|||
const builder = new CalendarEventBuilder();
|
||||
builder.init({
|
||||
title: bookingToReschedule.title,
|
||||
bookerUrl: await getBookerUrl(user),
|
||||
type: event && event.title ? event.title : bookingToReschedule.title,
|
||||
startTime: bookingToReschedule.startTime.toISOString(),
|
||||
endTime: bookingToReschedule.endTime.toISOString(),
|
||||
|
|
|
@ -266,7 +266,7 @@ export function getRegularOrDynamicEventType(
|
|||
}
|
||||
|
||||
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? "");
|
||||
const orgDetails = orgDomainConfig(ctx?.req);
|
||||
if (process.env.INTEGRATION_TEST_MODE === "true") {
|
||||
logger.settings.minLevel = 2;
|
||||
}
|
||||
|
|
|
@ -86,13 +86,16 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
|||
|
||||
const hasOrgsPlan = IS_SELF_HOSTED || ctx.user.organizationId;
|
||||
|
||||
const where: Prisma.EventTypeWhereInput = {};
|
||||
where.id = {
|
||||
in: activeOn,
|
||||
};
|
||||
if (userWorkflow.teamId) {
|
||||
//all children managed event types are added after
|
||||
where.parentId = null;
|
||||
}
|
||||
const activeOnEventTypes = await ctx.prisma.eventType.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: activeOn,
|
||||
},
|
||||
parentId: null,
|
||||
},
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
children: {
|
||||
|
|
|
@ -41,7 +41,7 @@ export function Avatar(props: AvatarProps) {
|
|||
<AvatarPrimitive.Root
|
||||
data-testid={props?.["data-testid"]}
|
||||
className={classNames(
|
||||
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full",
|
||||
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full align-top",
|
||||
indicator ? "overflow-visible" : "overflow-hidden",
|
||||
props.className,
|
||||
sizesPropsBySize[size]
|
||||
|
|
|
@ -36,7 +36,7 @@ export function DataTableToolbar<TData>({
|
|||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-default sticky top-[3rem] z-10 flex items-center justify-end space-x-2 py-[2.15rem] md:top-0">
|
||||
<div className="bg-default sticky top-[3rem] z-10 flex items-center justify-end space-x-2 py-4 md:top-0">
|
||||
{searchKey && (
|
||||
<Input
|
||||
className="max-w-64 mb-0 mr-auto rounded-md"
|
||||
|
|
|
@ -13,7 +13,6 @@ vi.mock("@calcom/prisma", () => ({
|
|||
const handlePrismockBugs = () => {
|
||||
const __updateBooking = prismock.booking.update;
|
||||
const __findManyWebhook = prismock.webhook.findMany;
|
||||
const __findManyBooking = prismock.booking.findMany;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prismock.booking.update = (...rest: any[]) => {
|
||||
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
|
||||
|
@ -46,35 +45,6 @@ const handlePrismockBugs = () => {
|
|||
// @ts-ignore
|
||||
return __findManyWebhook(...rest);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prismock.booking.findMany = (...rest: any[]) => {
|
||||
// There is a bug in prismock where it considers `createMany` and `create` itself to have the data directly
|
||||
// In booking flows, we encounter such scenario, so let's fix that here directly till it's fixed in prismock
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const where = rest[0]?.where;
|
||||
if (where?.OR) {
|
||||
logger.silly("Fixed Prismock bug-3");
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where.OR.forEach((or: any) => {
|
||||
if (or.startTime?.gte) {
|
||||
or.startTime.gte = or.startTime.gte.toISOString ? or.startTime.gte.toISOString() : or.startTime.gte;
|
||||
}
|
||||
if (or.startTime?.lte) {
|
||||
or.startTime.lte = or.startTime.lte.toISOString ? or.startTime.lte.toISOString() : or.startTime.lte;
|
||||
}
|
||||
if (or.endTime?.gte) {
|
||||
or.endTime.lte = or.endTime.gte.toISOString ? or.endTime.gte.toISOString() : or.endTime.gte;
|
||||
}
|
||||
if (or.endTime?.lte) {
|
||||
or.endTime.lte = or.endTime.lte.toISOString ? or.endTime.lte.toISOString() : or.endTime.lte;
|
||||
}
|
||||
});
|
||||
}
|
||||
return __findManyBooking(...rest);
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -2,9 +2,6 @@ import { defineConfig } from "vitest/config";
|
|||
|
||||
process.env.INTEGRATION_TEST_MODE = "true";
|
||||
|
||||
// We can't set it during tests because it is used as soon as _metadata.ts is imported which happens before tests start running
|
||||
process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
|
@ -13,3 +10,12 @@ export default defineConfig({
|
|||
testTimeout: 500000,
|
||||
},
|
||||
});
|
||||
|
||||
setEnvVariablesThatAreUsedBeforeSetup();
|
||||
|
||||
function setEnvVariablesThatAreUsedBeforeSetup() {
|
||||
// We can't set it during tests because it is used as soon as _metadata.ts is imported which happens before tests start running
|
||||
process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
|
||||
// With same env variable, we can test both non org and org booking scenarios
|
||||
process.env.NEXT_PUBLIC_WEBAPP_URL = "http://app.cal.local:3000";
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue