Merge branch 'main' into testE2E-timezone

This commit is contained in:
GitStart-Cal.com 2023-10-30 20:51:56 +05:45 committed by GitHub
commit 73728d2fe7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 2165 additions and 443 deletions

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

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

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

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;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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