Compare commits

...

31 Commits

Author SHA1 Message Date
Keith Williams 51fd4102ae
v3.4.6 2023-10-31 17:25:46 -03:00
Ritesh Kumar 9d1ef0a649
chore: [CAL-2654] Broken Icons in org invitation email (#12119)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-31 20:00:07 +00:00
gitstart-app[bot] f80dc0738a
test: Create E2E tests for bookings with custom/required Select + other questions (teste2e-selectQuestion) (#11564)
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2023-10-31 19:44:18 +00:00
Peer Richelsen 678ab3f453
chore: remove merge conflicts workflow (#12173) 2023-10-31 19:43:38 +00:00
Syed Ali Shahbaz 199d3e4c3f
fix: Auto-link credentials internally on destination calendar (#12055)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2023-10-31 13:54:09 -03:00
Siddharth Movaliya 79a6aef0e7
fix: text colour for no-show acknowledgment (#12132) 2023-10-31 16:41:25 +00:00
Udit Takkar 4d49fb0636
fix: cal video recording email (#12079)
* fix: cal video recording email

* fix: add check for recording

* chore: remove logs

* chore: change message

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-31 13:35:23 -03:00
Udit Takkar 0be1387d0f
fix: org settings for member improvements (#12161) 2023-10-31 16:24:51 +00:00
Morgan 58ab278813
fix(e2e): failsafe if no availabilities (#12168) 2023-10-31 16:10:08 +00:00
Alex van Andel 4de142cb7c
fix: Check for same day, not with <= (#12167)
* fix: Check for same day, not with <=

* Cover this situation with a test

* Make the test timezone-independent
2023-10-31 14:48:47 +00:00
Hariom Balhara b4d27a9326
Fix Google spam policy alert layout in embed (#12153) 2023-10-31 10:46:35 +00:00
sean-brydon 31f3d9778e
Use correct typing for totalTeamMembers (#12152) 2023-10-30 17:29:09 +02:00
sean-brydon 0a59c95b93
fix: impersonation for orgs (#12113) 2023-10-30 12:20:48 -03:00
Hariom Balhara 9e3465eeb6
fix: Support embedding org profile page (#12116)
* support embedding org profile page

* Add checkly tests

* Fix test titles

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2023-10-30 17:49:13 +05:30
Hariom Balhara 31fc4724e0
fix: request reschedule link in email for an Org event (#12125) 2023-10-30 16:25:12 +05:30
Hariom Balhara f81f0a26ec
fix: Prevent possible reason behind avatar infinite redirect (#12143) 2023-10-30 09:19:06 +00:00
Hariom Balhara 9a80bb6194
fix: Skip failing tests (#12144) 2023-10-30 09:05:05 +00:00
Udit Takkar 901fc36c97
fix: padding in footer in profile (#12101) 2023-10-28 18:17:41 +01:00
Joe Au-Yeung 2831fb2b57
refactor: Falling Back to `FirstCalendarCredential` (#11986) 2023-10-27 11:52:56 -04:00
Hariom Balhara 426d31712e
test: E2E for Orgs - The beginning (#12095) 2023-10-27 18:14:16 +05:30
Carina Wollendorfer 09ecd445bb
fix adding managed event type to workflow (#12111)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-27 17:30:34 +05:30
Carina Wollendorfer 08d65c85de
fix: cron scheduleEmailRemider time out (#12108)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-27 08:53:53 -03:00
Peer Richelsen b9cef10ef2
chore: new cal.ai tip (#12096) 2023-10-27 11:25:28 +00:00
Manish Singh Bisht 0dc41592f2
fix: use app.slug not hard coded zoom (#11963)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2023-10-27 12:05:30 +01:00
Leo Giovanetti aabf3c54ea
Update Avatar.tsx (#12110) 2023-10-26 21:28:30 +00:00
Carina Wollendorfer c2a57fd72b
split date ranges for calling /freebusy endpoint (#11962)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-26 10:29:08 -04:00
Carina Wollendorfer 52386e08f2
fix: pull managed event type bookings in zapier (#12106)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2023-10-26 10:18:37 -04:00
Keith Williams b724d367fc
v3.4.5 2023-10-26 10:38:57 -03:00
gitstart-app[bot] 07924751ad
test: Create E2E tests for bookings with custom/required Address + other questions (teste2e-addresQuestion) (#11563)
* Add E2E tests for address action in a regular booking

* Remove unnecessary changes

* change all tests

* Fix type check failing

* Update addressQuestion.e2e.ts

* Update addressQuestion.e2e.ts

* Requested changes

---------

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
2023-10-26 15:22:09 +03:00
Siddharth Movaliya defa8df7ca
fix:fixes the padding and position of tabs on the Team Availability page (#12078)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2023-10-26 08:29:57 +00:00
Hariom Balhara bf8580fa88
fix: Embed Plus Org - Adds missing embed route, also fixes infinite redirection for migrated user in embed (#12071) 2023-10-26 13:42:05 +05:30
88 changed files with 4045 additions and 579 deletions

View File

@ -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

4
__checks__/README.md Normal file
View File

@ -0,0 +1,4 @@
# Checkly Tests
Run as `yarn checkly test`
Deploy the tests as `yarn checkly deploy`

View File

@ -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);
}

View File

@ -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,
});

View File

@ -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);

View File

@ -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),

View File

@ -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 />

View File

@ -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",
},
});
});
});

View File

@ -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;
}

View File

@ -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 });
});
});
});

View File

@ -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: {

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.4.4",
"version": "3.4.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",

View File

@ -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("/");

View File

@ -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) {

View File

@ -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) {

View File

@ -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");

View File

@ -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" });
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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

View File

@ -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"
)}

View File

@ -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");

View File

@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "../[user]";
export { default } from "../[user]";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);

View File

@ -0,0 +1,7 @@
import withEmbedSsr from "@lib/withEmbedSsr";
import { getServerSideProps as _getServerSideProps } from "./index";
export { default } from "./index";
export const getServerSideProps = withEmbedSsr(_getServerSideProps);

View File

@ -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}>

View File

@ -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} />

View File

@ -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) {

View File

@ -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) {

View File

@ -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: {

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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),
},
},
});
}

View File

@ -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";

View File

@ -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) {

View File

@ -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);

View File

@ -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 });
}

View File

@ -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}`);

View File

@ -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",

View File

@ -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 };

View File

@ -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(

44
checkly.config.ts Normal file
View File

@ -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;

View File

@ -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",

View File

@ -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;

View File

@ -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: {

View File

@ -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";

View File

@ -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
);

View File

@ -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

View File

@ -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) {

View File

@ -9,6 +9,7 @@ import type {
} from "@calcom/types/Calendar";
class CalendarEventClass implements CalendarEvent {
bookerUrl?: string | undefined;
type!: string;
title!: string;
startTime!: string;

View File

@ -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,

View File

@ -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
/>

View File

@ -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",

View File

@ -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(

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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>

View File

@ -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();
});
});
});

View File

@ -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

View File

@ -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) {

View File

@ -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;
}

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>
)}

View File

@ -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);

View File

@ -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}`}>

View File

@ -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>

View File

@ -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" });
}

View File

@ -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,
});

View File

@ -193,6 +193,7 @@ export function AvailabilitySliderTable() {
<>
<div className="relative">
<DataTable
searchKey="member"
tableContainerRef={tableContainerRef}
columns={memorisedColumns}
onRowMouseclick={(row) => {

View File

@ -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();

View File

@ -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({

View File

@ -188,7 +188,7 @@ export async function listBookings(
};
} else {
where.eventType = {
teamId: account.id,
OR: [{ teamId: account.id }, { parent: { teamId: account.id } }],
};
}
}

View File

@ -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

View File

@ -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>) {

View File

@ -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())

View File

@ -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);
}

View File

@ -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,
};
}

View File

@ -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(),

View File

@ -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;
}

View File

@ -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: {

View File

@ -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]

View File

@ -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"

View File

@ -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(() => {

View File

@ -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";
}

943
yarn.lock

File diff suppressed because it is too large Load Diff