Compare commits

...
21 Commits
Author SHA1 Message Date
Keith Williams 18f637d94c v3.7.0
release.yml / release (push) Canceled after 0s
2024-01-15 14:23:30 -03:00
e5eb121ab7 fix: Reschedule a cancelled booking (#13170)
* fix: Reschedule a cancelled booking

* canceled instead of cancelled

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-01-15 16:52:04 +00:00
6612926aa5 test: Check availability tab and all your functions (teste2e-availability) (#12434)
* add changes

* add changes

* add changes

* add changes

* Requested changes

* Change way to grab element

---------

Co-authored-by: gitstart-calcom <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>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-01-15 11:45:51 -05:00
Hariom Balhara 93dc1247f1 fix: Dynamic group booking org-migration redirect (#13223)
* fix: Dynamic group booking redirect

* Fix flaky private team test
2024-01-15 21:38:43 +05:30
24607873c0 refactor: Abstract handleSeats from handleNewBooking (#12062)
* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Erik <erik@erosemberg.com>
2024-01-15 15:28:11 +00:00
Peer Richelsen 8b37d91108 chore: added community support to help (#13213) 2024-01-15 15:16:35 +00:00
22b0644700 fix: event reschedule workflow trigger (#13122)
* fix event reschedule workflow trigger

* uncomment previously failed test

* review fix

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-01-15 10:15:49 -05:00
Crowdin Bot 404a7c1550 New Crowdin translations by Github Action 2024-01-15 13:50:07 +00:00
Peer Richelsen 19a2cd5ae4 chore: small improvements to signup (#13227)
* removed features on mobile, better background color

* nit

* dark mode nits
2024-01-15 13:46:03 +00:00
sean-brydonandPeer Richelsen 0798e2940a fix: check for isEmbed (#12907)
* fix: check for isEmbed

* chore: useIsEmbed hook

* Update packages/features/bookings/Booker/components/Header.tsx

Co-authored-by: Peer Richelsen <peeroke@gmail.com>

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-01-15 10:39:38 +00:00
Udit TakkarandPeer Richelsen b1bf94aa58 chore: added a fallback for country code (#13171)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-01-15 09:51:16 +00:00
Peer RichelsenandUdit Takkar 0a921c2929 chore: added mini game to waiting screen of instant meetings (#13177)
* nit

* Update packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-01-15 03:29:01 +00:00
Carina Wollendorfer 67c318e527 fix: use DateRangePicker component in my-account/out-of-office (#13207) 2024-01-14 13:58:10 +00:00
Crowdin Bot 41b8d2ab1a New Crowdin translations by Github Action 2024-01-14 13:11:02 +00:00
57124b48fe fix: don't allow uid update in bookings update api (#13071)
* don't allow uid update in bookings update api

* omit uid from schemaBookingEditBodyParams instead of updating schemaBookingBaseBodyParams

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-01-14 13:08:02 +00:00
Benny Joo b76a2a4019 chore: [app-router-migration 21] Migrate "/d/*" pages (#13047)
* Migrate d/* page group

* Fix metadata

* manual: fix type of arg of getData

* fix

* manual: fix type errors

* Fix type errors

* fix type errors

* fix error

* fix

* fix build error
2024-01-12 18:37:25 -03:00
DmytroHryshynandBenny Joo a28b9cacd2 chore: [app-router-migration 17]: migrate payments page (#13039)
* chore: migrate payments page

* migrate page to WithLayout HOC, replace new ssrInit

* fix type error

* fix

* revert version changes

* fix

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-01-12 18:36:48 -03:00
DmytroHryshyn abd90f6af8 chore: [app-router-migration 14] migrate not-found page (#13032)
* chore: migrate not-found page

* migrate page, replace ssgInit

* fix flaky test

* fix

* fix

* fix
2024-01-12 18:21:35 -03:00
Benny JooandGreg Pabian 0bdc45a1a5 chore: [app-router-migration 18] Migrate "/settings/organizations/*" pages (#13042)
* manual: app-directory-boilerplate-calcom

* manual: import components directly

* manual: move files to correct route groups and add metadata

* manual: Change structure & Refactor to make code up to date

* manual: refactors

* Fix

* manual: fix type of arg of getData

* manual: fix type error

* fix type bugs

* fix

* fixing the build

* wip

---------

Co-authored-by: Greg Pabian <35925521+grzpab@users.noreply.github.com>
2024-01-12 15:32:39 -03:00
Riddhesh MahajanandKeith Williams 65d9704f2b fix: add check for already used slug (#13076)
* add check for already used slug

* Update _patch.ts

Removed comment that added no value based on the code. Renamed const

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-12 11:07:39 -03:00
sean-brydon d8ba783369 fix: use brand accent instead of invert on current day selector (#13194)
* fix: use brand accent instead of invert

* fix: use brand accent instead of invert

* chore: remove constants from commit
2024-01-12 13:44:26 +00:00
124 changed files with 2938 additions and 631 deletions
+3 -1
View File
@@ -28,7 +28,9 @@ const schemaBookingEditParams = z
})
.strict();
export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams.merge(schemaBookingEditParams);
export const schemaBookingEditBodyParams = schemaBookingBaseBodyParams
.merge(schemaBookingEditParams)
.omit({ uid: true });
export const schemaBookingReadPublic = Booking.extend({
attendees: z
@@ -58,6 +58,7 @@ export async function patchHandler(req: NextApiRequest) {
const { prisma, body, userId } = req;
const data = schemaTeamUpdateBodyParams.parse(body);
const { teamId } = schemaQueryTeamId.parse(req.query);
/** Only OWNERS and ADMINS can edit teams */
const _team = await prisma.team.findFirst({
include: { members: true },
@@ -65,6 +66,18 @@ export async function patchHandler(req: NextApiRequest) {
});
if (!_team) throw new HttpError({ statusCode: 401, message: "Unauthorized: OWNER or ADMIN required" });
const slugAlreadyExists = await prisma.team.findFirst({
where: {
slug: {
mode: "insensitive",
equals: data.slug,
},
},
});
if (slugAlreadyExists && data.slug !== _team.slug)
throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
// Check if parentId is related to this user
if (data.parentId && data.parentId === teamId) {
throw new HttpError({
+7 -3
View File
@@ -2,16 +2,20 @@ import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import { notFound, redirect } from "next/navigation";
export const withAppDir =
(getServerSideProps: GetServerSideProps) => async (context: GetServerSidePropsContext) => {
<T extends Record<string, any>>(getServerSideProps: GetServerSideProps<T>) =>
async (context: GetServerSidePropsContext): Promise<T> => {
const ssrResponse = await getServerSideProps(context);
if ("redirect" in ssrResponse) {
redirect(ssrResponse.redirect.destination);
}
if ("notFound" in ssrResponse) {
notFound();
}
return ssrResponse.props;
return {
...ssrResponse.props,
// includes dehydratedState required for future page trpcPropvider
...("trpcState" in ssrResponse.props && { dehydratedState: ssrResponse.props.trpcState }),
};
};
+2 -5
View File
@@ -1,13 +1,12 @@
import LegacyPage from "@pages/apps/categories/index";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async () => {
@@ -17,11 +16,9 @@ export const generateMetadata = async () => {
);
};
const getData = async (ctx: ReturnType<typeof buildLegacyCtx>) => {
// @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'.
const getData = async (ctx: GetServerSidePropsContext) => {
const ssr = await ssrInit(ctx);
// @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest | IncomingMessage
const session = await getServerSession({ req: ctx.req });
let appStore;
+2 -5
View File
@@ -1,6 +1,7 @@
import AppsPage from "@pages/apps";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
@@ -10,8 +11,6 @@ import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import { APP_NAME } from "@calcom/lib/constants";
import type { AppCategories } from "@calcom/prisma/enums";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async () => {
@@ -21,11 +20,9 @@ export const generateMetadata = async () => {
);
};
const getData = async (ctx: ReturnType<typeof buildLegacyCtx>) => {
// @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'.
const getData = async (ctx: GetServerSidePropsContext) => {
const ssr = await ssrInit(ctx);
// @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest
const session = await getServerSession({ req: ctx.req });
let appStore, userAdminTeams: UserAdminTeams;
@@ -1,13 +1,12 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { APP_NAME } from "@calcom/lib/constants";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssgInit } from "@server/lib/ssg";
const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
@@ -26,7 +25,7 @@ export const generateStaticParams = async () => {
return validStatuses.map((status) => ({ status }));
};
const getData = async (ctx: ReturnType<typeof buildLegacyCtx>) => {
const getData = async (ctx: GetServerSidePropsContext) => {
const parsedParams = querySchema.safeParse(ctx.params);
if (!parsedParams.success) {
@@ -0,0 +1,132 @@
import LegacyPage, { type PageProps } from "@pages/d/[link]/[slug]";
import { withAppDir } from "app/AppDirSSRHOC";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async ({ params }: { params: Record<string, string | string[]> }) => {
const pageProps = await getPageProps(
buildLegacyCtx(headers(), cookies(), params) as unknown as GetServerSidePropsContext
);
const { entity, booking, user, slug, isTeamEvent } = pageProps;
const rescheduleUid = booking?.uid;
const { trpc } = await import("@calcom/trpc");
const { data: event } = trpc.viewer.public.event.useQuery(
{ username: user ?? "", eventSlug: slug ?? "", isTeamEvent, org: entity.orgSlug ?? null },
{ refetchOnWindowFocus: false }
);
const profileName = event?.profile?.name ?? "";
const title = event?.title ?? "";
return await _generateMetadata(
(t) => `${rescheduleUid && !!booking ? t("reschedule") : ""} ${title} | ${profileName}`,
(t) => `${rescheduleUid ? t("reschedule") : ""} ${title}`
);
};
async function getPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession({ req: context.req });
const { link, slug } = paramsSchema.parse(context.params);
const { rescheduleUid, duration: queryDuration } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
const org = isValidOrgDomain ? currentOrgDomain : null;
const hashedLink = await prisma.hashedLink.findUnique({
where: {
link,
},
select: {
eventTypeId: true,
eventType: {
select: {
users: {
select: {
username: true,
},
},
team: {
select: {
id: true,
},
},
},
},
},
});
const username = hashedLink?.eventType.users[0]?.username;
if (!hashedLink || !username) {
return notFound();
}
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain
? {
slug: currentOrgDomain,
}
: null,
},
select: {
away: true,
hideBranding: true,
},
});
if (!user) {
return notFound();
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
}
const isTeamEvent = !!hashedLink.eventType?.team?.id;
const ssr = await ssrInit(context);
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({ username, eventSlug: slug, isTeamEvent, org });
if (!eventData) {
return notFound();
}
return {
entity: eventData.entity,
duration: getMultipleDurationValue(eventData.metadata?.multipleDuration, queryDuration, eventData.length),
booking,
away: user?.away,
user: username,
slug,
dehydratedState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
// Sending the team event from the server, because this template file
// is reused for both team and user events.
isTeamEvent,
hashedLink: link,
};
}
const paramsSchema = z.object({ link: z.string(), slug: z.string().transform((s) => slugify(s)) });
// @ts-expect-error arg
const getData = withAppDir<PageProps>(getPageProps);
export default WithLayout({ getLayout: null, Page: LegacyPage, getData })<"P">;
@@ -1,25 +1,19 @@
import LegacyPage from "@pages/getting-started/[[...step]]";
import { WithLayout } from "app/layoutHOC";
import { cookies, headers } from "next/headers";
import { type GetServerSidePropsContext } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssrInit } from "@server/lib/ssr";
const getData = async (ctx: ReturnType<typeof buildLegacyCtx>) => {
const req = { headers: headers(), cookies: cookies() };
//@ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest
const session = await getServerSession({ req });
const getData = async (ctx: GetServerSidePropsContext) => {
const session = await getServerSession({ req: ctx.req });
if (!session?.user?.id) {
return redirect("/auth/login");
}
// @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'.
const ssr = await ssrInit(ctx);
await ssr.viewer.me.prefetch();
@@ -54,7 +48,7 @@ const getData = async (ctx: ReturnType<typeof buildLegacyCtx>) => {
return {
dehydratedState: ssr.dehydrate(),
hasPendingInvites: user.teams.find((team: any) => team.accepted === false) ?? false,
hasPendingInvites: user.teams.find((team) => team.accepted === false) ?? false,
requiresLicense: false,
themeBasis: null,
};
+167
View File
@@ -0,0 +1,167 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { redirect, notFound } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PaymentPage from "@calcom/features/ee/payments/components/PaymentPage";
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
import { APP_NAME } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async () =>
await _generateMetadata(
// the title does not contain the eventName as in the legacy page
(t) => `${t("payment")} | ${APP_NAME}`,
() => ""
);
const querySchema = z.object({
uid: z.string(),
});
async function getData(context: GetServerSidePropsContext) {
const session = await getServerSession({ req: context.req });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
const { uid } = querySchema.parse(context.params);
const rawPayment = await prisma.payment.findFirst({
where: {
uid,
},
select: {
data: true,
success: true,
uid: true,
refunded: true,
bookingId: true,
appId: true,
amount: true,
currency: true,
paymentOption: true,
booking: {
select: {
id: true,
uid: true,
description: true,
title: true,
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
name: true,
},
},
eventTypeId: true,
location: true,
status: true,
rejectionReason: true,
cancellationReason: true,
eventType: {
select: {
id: true,
title: true,
description: true,
length: true,
eventName: true,
requiresConfirmation: true,
userId: true,
metadata: true,
users: {
select: {
name: true,
username: true,
hideBranding: true,
theme: true,
},
},
team: {
select: {
name: true,
hideBranding: true,
},
},
price: true,
currency: true,
successRedirectUrl: true,
},
},
},
},
},
});
if (!rawPayment) {
return notFound();
}
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) {
return notFound();
}
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) {
return notFound();
}
if (eventType.users.length === 0 && !!!eventType.team) {
return notFound();
}
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
if (
([BookingStatus.CANCELLED, BookingStatus.REJECTED] as BookingStatus[]).includes(
booking.status as BookingStatus
)
) {
return redirect(`/booking/${booking.uid}`);
}
return {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
dehydratedState: ssr.dehydrate(),
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
};
}
export default WithLayout({ getLayout: null, getData, Page: PaymentPage });
@@ -0,0 +1,11 @@
import LegacyPage, { WrappedAboutOrganizationPage } from "@pages/settings/organizations/[id]/about";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("about_your_organization"),
(t) => t("about_your_organization_description")
);
export default WithLayout({ Page: LegacyPage, getLayout: WrappedAboutOrganizationPage });
@@ -0,0 +1,11 @@
import LegacyPage, { WrapperAddNewTeamsPage } from "@pages/settings/organizations/[id]/add-teams";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("create_your_teams"),
(t) => t("create_your_teams_description")
);
export default WithLayout({ Page: LegacyPage, getLayout: WrapperAddNewTeamsPage });
@@ -0,0 +1,35 @@
import LegacyPage, {
buildWrappedOnboardTeamMembersPage,
} from "@pages/settings/organizations/[id]/onboard-admins";
import { type Params } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { headers } from "next/headers";
import PageWrapper from "@components/PageWrapperAppDir";
type PageProps = Readonly<{
params: Params;
}>;
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("invite_organization_admins"),
(t) => t("invite_organization_admins_description")
);
const Page = ({ params }: PageProps) => {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
return (
<PageWrapper
getLayout={(page: React.ReactElement) => buildWrappedOnboardTeamMembersPage(params.id, page)}
requiresLicense={false}
nonce={nonce}
themeBasis={null}>
<LegacyPage />
</PageWrapper>
);
};
export default Page;
@@ -0,0 +1,11 @@
import LegacyPage, { WrappedSetPasswordPage } from "@pages/settings/organizations/[id]/set-password";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_a_password"),
(t) => t("set_a_password_description")
);
export default WithLayout({ Page: LegacyPage, getLayout: WrappedSetPasswordPage });
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/appearance";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("appearance"),
(t) => t("appearance_org_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,10 @@
import Page from "@pages/settings/billing/index";
import { _generateMetadata } from "app/_utils";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("billing"),
(t) => t("manage_billing_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/general";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("general"),
(t) => t("general_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/members";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("organization_members"),
(t) => t("organization_description")
);
export default Page;
@@ -0,0 +1,34 @@
import LegacyPage, { WrappedCreateNewOrganizationPage } from "@pages/settings/organizations/new/index";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { type GetServerSidePropsContext } from "next";
import { notFound } from "next/navigation";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_up_your_organization"),
(t) => t("organizations_description")
);
const getPageProps = async (context: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
// Check if organizations are enabled
if (flags["organizations"] !== true) {
return notFound();
}
const querySlug = context.query.slug as string;
return {
querySlug: querySlug ?? null,
};
};
export default WithLayout({
getLayout: WrappedCreateNewOrganizationPage,
Page: LegacyPage,
getData: getPageProps,
});
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/profile";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("profile"),
(t) => t("profile_org_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/teams/pages/team-appearance-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("booking_appearance"),
(t) => t("appearance_team_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/other-team-members-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("team_members"),
(t) => t("members_team_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/other-team-profile-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("profile"),
(t) => t("profile_team_description")
);
export default Page;
@@ -0,0 +1,5 @@
import { WithLayout } from "app/layoutHOC";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir";
export default WithLayout({ getLayout });
@@ -0,0 +1,11 @@
import { _generateMetadata } from "app/_utils";
import Page from "@calcom/features/ee/organizations/pages/settings/other-team-listing-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("org_admin_other_teams"),
(t) => t("org_admin_other_teams_description")
);
export default Page;
+2 -5
View File
@@ -1,13 +1,12 @@
import OldPage from "@pages/teams/index";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { GetServerSidePropsContext } from "next";
import { redirect } from "next/navigation";
import { getLayout } from "@calcom/features/MainLayoutAppDir";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async () =>
@@ -16,14 +15,12 @@ export const generateMetadata = async () =>
(t) => t("create_manage_teams_collaborative")
);
async function getData(context: ReturnType<typeof buildLegacyCtx>) {
// @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'.
async function getData(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
await ssr.viewer.me.prefetch();
const session = await getServerSession({
// @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest | (IncomingMessage & { cookies: Partial<{ [key: string]: string; }>; })'.
req: context.req,
});
+3 -6
View File
@@ -2,14 +2,13 @@ import OldPage from "@pages/video/[uid]";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { ssrInit } from "@server/lib/ssr";
export const generateMetadata = async () =>
@@ -20,8 +19,7 @@ export const generateMetadata = async () =>
const md = new MarkdownIt("default", { html: true, breaks: true, linkify: true });
async function getData(context: ReturnType<typeof buildLegacyCtx>) {
// @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'.
async function getData(context: GetServerSidePropsContext) {
const ssr = await ssrInit(context);
const booking = await prisma.booking.findUnique({
@@ -79,12 +77,11 @@ async function getData(context: ReturnType<typeof buildLegacyCtx>) {
endTime: booking.endTime.toString(),
});
// @ts-expect-error Type '{ headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }' is not assignable to type 'NextApiRequest | (IncomingMessage & { cookies: Partial<{ [key: string]: string; }>; })'.
const session = await getServerSession({ req: context.req });
// set meetingPassword to null for guests
if (session?.user.id !== bookingObj.user?.id) {
bookingObj.references.forEach((bookRef: any) => {
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = null;
});
}
@@ -49,5 +49,4 @@ async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolve
};
}
// @ts-expect-error getData arg
export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">;
@@ -47,5 +47,4 @@ async function getData(context: Omit<GetServerSidePropsContext, "res" | "resolve
};
}
// @ts-expect-error getData arg
export default WithLayout({ getData, Page: OldPage, getLayout: null })<"P">;
@@ -1,8 +1,7 @@
import LegacyPage from "@pages/video/no-meeting-found";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import type { buildLegacyCtx } from "@lib/buildLegacyCtx";
import { type GetServerSidePropsContext } from "next";
import { ssrInit } from "@server/lib/ssr";
@@ -12,8 +11,7 @@ export const generateMetadata = async () =>
(t) => t("no_meeting_found")
);
const getData = async (context: ReturnType<typeof buildLegacyCtx>) => {
// @ts-expect-error Argument of type '{ query: Params; params: Params; req: { headers: ReadonlyHeaders; cookies: ReadonlyRequestCookies; }; }' is not assignable to parameter of type 'GetServerSidePropsContext'.
const getData = async (context: GetServerSidePropsContext) => {
const ssr = await ssrInit(context);
return {
@@ -35,7 +35,6 @@ async function getProps(context: GetServerSidePropsContext) {
export const generateStaticParams = () => [];
// @ts-expect-error getData arg
export default WithLayout({ getLayout: null, getData: getProps, Page: LegacyPage })<"P">;
export const dynamic = "force-static";
// generate segments on demand
+5 -2
View File
@@ -1,4 +1,5 @@
import type { LayoutProps, PageProps } from "app/_types";
import { type GetServerSidePropsContext } from "next";
import { cookies, headers } from "next/headers";
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
@@ -8,14 +9,16 @@ import PageWrapper from "@components/PageWrapperAppDir";
type WithLayoutParams<T extends Record<string, any>> = {
getLayout: ((page: React.ReactElement) => React.ReactNode) | null;
Page?: (props: T) => React.ReactElement | null;
getData?: (arg: ReturnType<typeof buildLegacyCtx>) => Promise<T>;
getData?: (arg: GetServerSidePropsContext) => Promise<T>;
};
export function WithLayout<T extends Record<string, any>>({ getLayout, getData, Page }: WithLayoutParams<T>) {
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
const props = getData ? await getData(buildLegacyCtx(h, cookies(), p.params)) : ({} as T);
const props = getData
? await getData(buildLegacyCtx(h, cookies(), p.params) as unknown as GetServerSidePropsContext)
: ({} as T);
const children = "children" in p ? p.children : null;
+17
View File
@@ -0,0 +1,17 @@
import NotFoundPage from "@pages/404";
import { WithLayout } from "app/layoutHOC";
import type { GetStaticPropsContext } from "next";
import { ssgInit } from "@server/lib/ssg";
const getData = async (context: GetStaticPropsContext) => {
const ssg = await ssgInit(context);
return {
dehydratedState: ssg.dehydrate(),
};
};
export const dynamic = "force-static";
export default WithLayout({ getLayout: null, getData, Page: NotFoundPage });
@@ -1,110 +0,0 @@
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
box-shadow: none;
width: 100%;
background-color: transparent;
}
/* Media query for screens larger than 768px */
@media (max-width: 639) {
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
max-width: 400px;
}
}
.recharts-cartesian-grid-horizontal line{
@apply stroke-emphasis
}
.tremor-DateRangePicker-button button{
@apply !h-9 !max-h-9 border-default hover:border-emphasis
}
.tremor-DateRangePicker-calendarButton,
.tremor-DateRangePicker-dropdownButton {
@apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
}
.tremor-DateRangePicker-dropdownModal{
@apply divide-none
}
.tremor-DropdownItem-root{
@apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis
}
.tremor-DateRangePicker-calendarButtonText,
.tremor-DateRangePicker-dropdownButtonText {
@apply text-default;
}
.tremor-DateRangePicker-calendarHeaderText{
@apply !text-default
}
.tremor-DateRangePicker-calendarHeader svg{
@apply text-default
}
.tremor-DateRangePicker-calendarHeader button{
@apply hover:bg-emphasis shadow-none focus:ring-0
}
.tremor-DateRangePicker-calendarHeader button:hover svg{
@apply text-emphasis
}
.tremor-DateRangePicker-calendarButtonIcon{
@apply text-default
}
.tremor-DateRangePicker-calendarModal,
.tremor-DateRangePicker-dropdownModal {
@apply bg-default border-subtle shadow-dropdown
}
.tremor-DateRangePicker-calendarBodyDate button{
@apply text-default hover:bg-emphasis
}
.tremor-DateRangePicker-calendarBodyDate button:disabled,
.tremor-DateRangePicker-calendarBodyDate button[disabled]{
@apply opacity-25
}
.tremor-DateRangePicker-calendarHeader button{
@apply border-default text-default
}
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100{
@apply bg-subtle
}
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500{
@apply !bg-brand-default text-inverted
}
.tremor-Card-root {
@apply p-5 bg-default;
}
.tremor-TableCell-root {
@apply pl-0;
}
.recharts-responsive-container {
@apply -mx-4;
}
.tremor-Card-root > p {
@apply mb-2 text-base font-semibold;
}
.tremor-Legend-legendItem {
@apply ml-2;
}
.tremor-TableBody-root {
@apply divide-subtle;
}
@@ -1,47 +0,0 @@
import type { BookingRedirectForm } from "@pages/settings/my-account/out-of-office";
import { DateRangePicker } from "@tremor/react";
import type { UseFormSetValue } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import "./DateSelect.css";
interface IOutOfOfficeDateRangeSelectProps {
dateRange: [Date | null, Date | null, null];
setDateRange: React.Dispatch<React.SetStateAction<[Date | null, Date | null, null]>>;
setValue: UseFormSetValue<BookingRedirectForm>;
}
const OutOfOfficeDateRangePicker = (props: IOutOfOfficeDateRangeSelectProps) => {
const { t } = useLocale();
const { dateRange, setDateRange, setValue } = props;
return (
<div className="custom-date">
<DateRangePicker
value={dateRange}
defaultValue={dateRange}
onValueChange={(datesArray) => {
const [start, end] = datesArray;
if (start) {
setDateRange([start, end as Date | null, null]);
}
if (start && end) {
setValue("startDate", start.toISOString());
setValue("endDate", end.toISOString());
}
}}
color="gray"
options={undefined}
enableDropdown={false}
placeholder={t("select_date_range")}
enableYearPagination={true}
minDate={dayjs().startOf("d").toDate()}
maxDate={dayjs().add(2, "y").endOf("d").toDate()}
/>
</div>
);
};
export { OutOfOfficeDateRangePicker };
+5 -1
View File
@@ -62,8 +62,12 @@ const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSR
// @TODO
const session = useSession();
// window.document.documentElement.lang can be empty in some cases, for instance when we rendering GlobalError (not-found) page.
const locale =
session?.data?.user.locale ?? typeof window !== "undefined" ? window.document.documentElement.lang : "en";
session?.data?.user.locale ?? typeof window !== "undefined"
? window.document.documentElement.lang || "en"
: "en";
useEffect(() => {
try {
+188 -17
View File
@@ -1,11 +1,19 @@
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
const mockData = {
redirects: [] as {
toUrl: string;
from: string;
redirectType: RedirectType;
}[],
};
function mockARedirectInDB({
toUrl,
slug,
@@ -15,28 +23,38 @@ function mockARedirectInDB({
slug: string;
redirectType: RedirectType;
}) {
mockData.redirects.push({
toUrl,
from: slug,
redirectType,
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
prismaMock.tempOrgRedirect.findUnique.mockImplementation(({ where }) => {
prismaMock.tempOrgRedirect.findMany.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);
}
const tempOrgRedirects: typeof mockData.redirects = [];
where.from.in.forEach((whereSlug: string) => {
const matchingRedirect = mockData.redirects.find((redirect) => {
return where.type === redirect.redirectType && whereSlug === redirect.from && where.fromOrgId === 0;
});
if (matchingRedirect) {
tempOrgRedirects.push(matchingRedirect);
}
});
resolve(tempOrgRedirects);
});
});
}
beforeEach(() => {
mockData.redirects = [];
});
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",
slugs: "slug",
redirectType: RedirectType.User,
eventTypeSlug: "30min",
currentQuery: {},
@@ -54,7 +72,7 @@ describe("getTemporaryOrgRedirect", () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slug: "slug",
slugs: "slug",
redirectType: RedirectType.User,
eventTypeSlug: "30min",
currentQuery: {
@@ -74,7 +92,7 @@ describe("getTemporaryOrgRedirect", () => {
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
const redirect = await getTemporaryOrgRedirect({
slug: "slug",
slugs: "slug",
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: {
@@ -98,7 +116,7 @@ describe("getTemporaryOrgRedirect", () => {
});
const redirect = await getTemporaryOrgRedirect({
slug: "seeded-team",
slugs: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {
@@ -122,7 +140,7 @@ describe("getTemporaryOrgRedirect", () => {
});
const redirect = await getTemporaryOrgRedirect({
slug: "seeded-team",
slugs: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {
@@ -146,7 +164,7 @@ describe("getTemporaryOrgRedirect", () => {
});
const redirect = await getTemporaryOrgRedirect({
slug: "seeded-team",
slugs: "seeded-team",
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {},
@@ -159,4 +177,157 @@ describe("getTemporaryOrgRedirect", () => {
},
});
});
it("should generate Dynamic Group Booking Profile Url", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1",
},
});
});
it("should generate Dynamic Group Booking Profile Url - same order", async () => {
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1",
},
});
const redirect1 = await getTemporaryOrgRedirect({
slugs: ["second", "first"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {},
});
expect(redirect1).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/second-in-org1+first-in-org1",
},
});
});
it("should generate Dynamic Group Booking Profile Url with query params", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1?abc=1",
},
});
});
it("should generate Dynamic Group Booking EventType Url", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min",
},
});
});
it("should generate Dynamic Group Booking EventType Url with query params", async () => {
mockARedirectInDB({
slug: "first",
toUrl: "https://calcom.cal.com/first-in-org1",
redirectType: RedirectType.Team,
});
mockARedirectInDB({
slug: "second",
toUrl: "https://calcom.cal.com/second-in-org1",
redirectType: RedirectType.Team,
});
const redirect = await getTemporaryOrgRedirect({
slugs: ["first", "second"],
redirectType: RedirectType.Team,
eventTypeSlug: "30min",
currentQuery: {
abc: "1",
},
});
expect(redirect).toEqual({
redirect: {
permanent: false,
destination: "https://calcom.cal.com/first-in-org1+second-in-org1/30min?abc=1",
},
});
});
});
+39 -19
View File
@@ -7,45 +7,65 @@ import type { RedirectType } from "@calcom/prisma/client";
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
export const getTemporaryOrgRedirect = async ({
slug,
slugs,
redirectType,
eventTypeSlug,
currentQuery,
}: {
slug: string;
slugs: string[] | string;
redirectType: RedirectType;
eventTypeSlug: string | null;
currentQuery: ParsedUrlQuery;
}) => {
const prisma = (await import("@calcom/prisma")).default;
slugs = slugs instanceof Array ? slugs : [slugs];
log.debug(
`Looking for redirect for`,
safeStringify({
slug,
slugs,
redirectType,
eventTypeSlug,
})
);
const redirect = await prisma.tempOrgRedirect.findUnique({
const redirects = await prisma.tempOrgRedirect.findMany({
where: {
from_type_fromOrgId: {
type: redirectType,
from: slug,
fromOrgId: 0,
type: redirectType,
from: {
in: slugs,
},
fromOrgId: 0,
},
});
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: `${newDestinationWithoutQuery}${currentQueryString ? `?${currentQueryString}` : ""}`,
},
} as const;
const currentQueryString = stringify(currentQuery);
if (!redirects.length) {
return null;
}
return null;
// Use the first redirect origin as the new origin as we aren't supposed to handle different org usernames in a group
const newOrigin = new URL(redirects[0].toUrl).origin;
const query = currentQueryString ? `?${currentQueryString}` : "";
// Use the same order as in input slugs - It is important from Dynamic Group perspective as the first user's settings are used for various things
const newSlugs = slugs.map((slug) => {
const redirect = redirects.find((redirect) => redirect.from === slug);
if (!redirect) {
return slug;
}
const newSlug = new URL(redirect.toUrl).pathname.slice(1);
return newSlug;
});
const newSlug = newSlugs.join("+");
const newPath = newSlug ? `/${newSlug}` : "";
const newDestination = `${newOrigin}${newPath}${eventTypeSlug ? `/${eventTypeSlug}` : ""}${query}`;
log.debug(`Suggesting redirect from ${slugs} to ${newDestination}`);
return {
redirect: {
permanent: false,
destination: newDestination,
},
} as const;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.6.4",
"version": "3.7.0",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+2
View File
@@ -1,3 +1,5 @@
"use client";
import type { GetStaticPropsContext } from "next";
import Link from "next/link";
import { usePathname } from "next/navigation";
+28 -27
View File
@@ -318,7 +318,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
let outOfOffice = false;
const isDynamicGroup = usernameList.length > 1;
if (usernameList.length === 1) {
const result = await handleUserRedirection({ username: usernameList[0] });
if (result && result.outOfOffice) {
@@ -329,6 +329,19 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
}
}
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernameList,
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
@@ -362,21 +375,6 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
},
});
const isDynamicGroup = usersWithoutAvatar.length > 1;
if (isDynamicGroup) {
return {
redirect: {
permanent: false,
destination: `/${usernameList.join("+")}/dynamic`,
},
} as {
redirect: {
permanent: false;
destination: string;
};
};
}
const users = usersWithoutAvatar.map((user) => ({
...user,
organization: {
@@ -386,17 +384,20 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
avatar: `/${user.username}/avatar.png`,
}));
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernameList[0],
redirectType: RedirectType.User,
eventTypeSlug: null,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
logger.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
destination: destinationUrl,
},
} as {
redirect: {
permanent: false;
destination: string;
};
};
}
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
+14 -3
View File
@@ -90,6 +90,19 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const { ssrInit } = await import("@server/lib/ssr");
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
const org = isValidOrgDomain ? currentOrgDomain : null;
if (!org) {
const redirect = await getTemporaryOrgRedirect({
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
});
if (redirect) {
return redirect;
}
}
const users = await prisma.user.findMany({
where: {
@@ -112,7 +125,6 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
notFound: true,
} as const;
}
const org = isValidOrgDomain ? currentOrgDomain : null;
let booking: GetBookingType | null = null;
if (rescheduleUid) {
@@ -167,10 +179,9 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
let outOfOffice = false;
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: usernames[0],
slugs: usernames,
redirectType: RedirectType.User,
eventTypeSlug: slug,
currentQuery: context.query,
+3 -1
View File
@@ -1,3 +1,5 @@
"use client";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
@@ -16,7 +18,7 @@ import type { EmbedProps } from "@lib/withEmbedSsr";
import PageWrapper from "@components/PageWrapper";
type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
export type PageProps = Omit<inferSSRProps<typeof getServerSideProps>, "trpcState"> & EmbedProps;
export default function Type({
slug,
@@ -1,23 +1,31 @@
import { Trash2 } from "lucide-react";
import React, { useState } from "react";
import { useForm, useFormState } from "react-hook-form";
import { Controller, useForm, useFormState } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { ShellMain } from "@calcom/features/shell/Shell";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Button, Meta, showToast, Select, SkeletonText, UpgradeTeamsBadge, Switch } from "@calcom/ui";
import {
Button,
Meta,
showToast,
Select,
SkeletonText,
UpgradeTeamsBadge,
Switch,
DateRangePicker,
} from "@calcom/ui";
import { TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
import { OutOfOfficeDateRangePicker } from "@components/out-of-office/DateRangePicker";
export type BookingRedirectForm = {
startDate: string;
endDate: string;
dateRange: { startDate: Date; endDate: Date };
toTeamUserId: number | null;
};
@@ -25,18 +33,20 @@ const OutOfOfficeSection = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const [dateRange, setDateRange] = useState<[Date | null, Date | null, null | null]>([
dayjs().startOf("d").toDate(),
dayjs().add(1, "d").endOf("d").toDate(),
null,
]);
const [profileRedirect, setProfileRedirect] = useState(false);
const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null);
const { handleSubmit, setValue } = useForm<BookingRedirectForm>({
const [dateRange] = useState<{ startDate: Date; endDate: Date }>({
startDate: dayjs().startOf("d").toDate(),
endDate: dayjs().add(1, "d").endOf("d").toDate(),
});
const { handleSubmit, setValue, getValues, control } = useForm<BookingRedirectForm>({
defaultValues: {
startDate: dateRange[0]?.toISOString(),
endDate: dateRange[1]?.toISOString(),
dateRange: {
startDate: dateRange.startDate,
endDate: dateRange.endDate,
},
toTeamUserId: null,
},
});
@@ -74,75 +84,81 @@ const OutOfOfficeSection = () => {
setValue("toTeamUserId", null);
setSelectedMember(null);
})}>
<div className="border-subtle flex flex-col rounded-b-lg border border-t-0 p-6 px-6 py-8 text-sm">
<div className="border-subtle flex flex-col border border-b-0 border-t-0 p-6 px-6 py-8 text-sm">
{/* Add startDate and end date inputs */}
<div className="border-subtle mt-2 rounded-lg border bg-gray-50 p-6 dark:bg-transparent">
{/* Add toggle to enable/disable redirect */}
<div className="flex flex-row">
<Switch
disabled={!hasTeamPlan}
data-testid="profile-redirect-switch"
checked={profileRedirect}
id="profile-redirect-switch"
onCheckedChange={(state) => {
setProfileRedirect(state);
}}
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
/>
{!hasTeamPlan && (
<div className="mx-2" data-testid="upgrade-team-badge">
<UpgradeTeamsBadge />
</div>
)}
</div>
<div className="mt-4 flex flex-row">
{profileRedirect && (
<div className="mr-2 w-1/2 lg:w-1/3">
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
<Select
className="mt-1 h-4 max-w-[350px] text-white"
name="toTeamUsername"
data-testid="team_username_select"
value={selectedMember}
placeholder={t("select_team_member")}
isSearchable
innerClassNames={{
control: "h-[38px]",
}}
options={memberListOptions}
onChange={(selectedOption) => {
if (selectedOption?.value) {
setSelectedMember(selectedOption);
setValue("toTeamUserId", selectedOption?.value);
}
}}
/>
</div>
)}
<div className="w-1/2 lg:w-1/3">
<p className="text-emphasis mb-1 block text-sm font-medium">{t("time_range")}</p>
<OutOfOfficeDateRangePicker
dateRange={dateRange}
setValue={setValue}
setDateRange={setDateRange}
{/* Add toggle to enable/disable redirect */}
<div className="flex flex-row">
<Switch
disabled={!hasTeamPlan}
data-testid="profile-redirect-switch"
checked={profileRedirect}
id="profile-redirect-switch"
onCheckedChange={(state) => {
setProfileRedirect(state);
}}
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
/>
{!hasTeamPlan && (
<div className="mx-2" data-testid="upgrade-team-badge">
<UpgradeTeamsBadge />
</div>
)}
</div>
<div className="mt-4 flex flex-row">
{profileRedirect && (
<div className="mr-2 w-1/2 lg:w-1/3">
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
<Select
className="mt-1 h-4 max-w-[350px] text-white"
name="toTeamUsername"
data-testid="team_username_select"
value={selectedMember}
placeholder={t("select_team_member")}
isSearchable
innerClassNames={{
control: "h-[38px]",
}}
options={memberListOptions}
onChange={(selectedOption) => {
if (selectedOption?.value) {
setSelectedMember(selectedOption);
setValue("toTeamUserId", selectedOption?.value);
}
}}
/>
</div>
</div>
<div className="mt-7">
<Button
color="primary"
type="submit"
disabled={createOutOfOfficeEntry.isLoading}
data-testid="create-entry-ooo-redirect">
{t("create_entry")}
</Button>
)}
<div className="w-1/2 lg:w-1/3">
<p className="text-emphasis mb-1 block text-sm font-medium">{t("time_range")}</p>
<Controller
name="dateRange"
control={control}
defaultValue={dateRange}
render={() => (
<DateRangePicker
startDate={getValues("dateRange").startDate}
endDate={getValues("dateRange").endDate}
onDatesChange={({ startDate, endDate }) => {
setValue("dateRange", {
startDate,
endDate,
});
}}
/>
)}
/>
</div>
</div>
<OutOfOfficeEntriesList />
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
color="primary"
type="submit"
disabled={createOutOfOfficeEntry.isLoading}
data-testid="create-entry-ooo-redirect">
{t("create_entry")}
</Button>
</SectionBottomActions>
</form>
</>
);
@@ -166,10 +182,10 @@ const OutOfOfficeEntriesList = () => {
return (
<div className="border-subtle mt-6 rounded-lg border">
<TableNew className="border-0">
<TableHeader>
<TableHeader className="md:z-1">
<TableRow>
<TableHead className="rounded-tl-lg font-normal capitalize">{t("time_range")}</TableHead>
<TableHead className="font-normal">{t("username")}</TableHead>
<TableHead className="font-normal">{t("redirect_to")}</TableHead>
<TableHead className="rounded-tr-lg font-normal">{t("action")}</TableHead>
</TableRow>
@@ -230,6 +246,7 @@ const OutOfOfficePage = () => {
<Meta title={t("out_of_office")} description={t("out_of_office_description")} borderInShellHeader />
<ShellMain>
<OutOfOfficeSection />
<OutOfOfficeEntriesList />
</ShellMain>
</>
);
@@ -1,6 +1,8 @@
"use client";
import { AboutOrganizationForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta, WizardLayout } from "@calcom/ui";
import { Meta, WizardLayout, WizardLayoutAppDir } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -15,7 +17,7 @@ const AboutOrganizationPage = () => {
</>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
export const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={3} maxSteps={5}>
{page}
@@ -23,6 +25,14 @@ const LayoutWrapper = (page: React.ReactElement) => {
);
};
export const WrappedAboutOrganizationPage = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={3} maxSteps={5}>
{page}
</WizardLayoutAppDir>
);
};
AboutOrganizationPage.getLayout = LayoutWrapper;
AboutOrganizationPage.PageWrapper = PageWrapper;
@@ -1,8 +1,12 @@
"use client";
import type { AppProps as NextAppProps } from "next/app";
import { redirect } from "next/navigation";
import { AddNewTeamsForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta, WizardLayout } from "@calcom/ui";
import { WizardLayoutAppDir } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -33,4 +37,17 @@ AddNewTeamsPage.getLayout = (page: React.ReactElement, router: NextAppProps["rou
AddNewTeamsPage.PageWrapper = PageWrapper;
export const WrapperAddNewTeamsPage = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir
currentStep={5}
maxSteps={5}
isOptionalCallback={() => {
redirect(`/event-types`);
}}>
{page}
</WizardLayoutAppDir>
);
};
export default AddNewTeamsPage;
@@ -1,8 +1,11 @@
"use client";
import type { AppProps as NextAppProps } from "next/app";
import { redirect } from "next/navigation";
import { AddNewOrgAdminsForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta, WizardLayout } from "@calcom/ui";
import { Meta, WizardLayout, WizardLayoutAppDir } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -33,6 +36,22 @@ OnboardTeamMembersPage.getLayout = (page: React.ReactElement, router: NextAppPro
</WizardLayout>
);
export const buildWrappedOnboardTeamMembersPage = (
id: string | string[] | undefined,
page: React.ReactElement
) => {
return (
<WizardLayoutAppDir
currentStep={4}
maxSteps={5}
isOptionalCallback={() => {
redirect(`/settings/organizations/${id}/add-teams`);
}}>
{page}
</WizardLayoutAppDir>
);
};
OnboardTeamMembersPage.PageWrapper = PageWrapper;
export default OnboardTeamMembersPage;
@@ -1,6 +1,8 @@
"use client";
import { SetPasswordForm } from "@calcom/features/ee/organizations/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Meta, WizardLayout } from "@calcom/ui";
import { Meta, WizardLayout, WizardLayoutAppDir } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -23,6 +25,14 @@ const LayoutWrapper = (page: React.ReactElement) => {
);
};
export const WrappedSetPasswordPage = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={2} maxSteps={5}>
{page}
</WizardLayoutAppDir>
);
};
SetPasswordPage.getLayout = LayoutWrapper;
SetPasswordPage.PageWrapper = PageWrapper;
@@ -1,10 +1,12 @@
"use client";
import type { GetServerSidePropsContext } from "next";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { CreateANewOrganizationForm } from "@calcom/features/ee/organizations/components";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta } from "@calcom/ui";
import { WizardLayout, Meta, WizardLayoutAppDir } from "@calcom/ui";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -27,6 +29,14 @@ const LayoutWrapper = (page: React.ReactElement) => {
);
};
export const WrappedCreateNewOrganizationPage = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={1} maxSteps={5}>
{page}
</WizardLayoutAppDir>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const flags = await getFeatureFlagMap(prisma);
+6 -6
View File
@@ -468,10 +468,10 @@ export default function Signup({
</div>
</div>
</div>
<div className="border-muted mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl bg-[radial-gradient(162.05%_170%_at_109.58%_35%,_rgba(102,_117,_147,_0.7)_0%,_rgba(212,_212,_213,_0.4)_100%)] pl-4 dark:bg-none lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12">
{IS_CALCOM && (
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 dark:bg-none lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12">
{!IS_CALCOM && (
<>
<div className="-mt-4 mb-6 mr-12 grid w-full grid-cols-3 gap-3 lg:grid-cols-4">
<div className="-mt-4 mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
<div>
<img
src="/product-cards/product-of-the-day.svg"
@@ -494,7 +494,7 @@ export default function Signup({
/>
</div>
</div>
<div className="mb-6 mr-12 grid w-full grid-cols-3 gap-3 lg:grid-cols-4">
<div className="mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
<div>
<img
src="/product-cards/producthunt.svg"
@@ -521,13 +521,13 @@ export default function Signup({
</>
)}
<div
className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border-dashed lg:block lg:py-[6px] lg:pl-[6px]"
className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed dark:border-0 lg:block lg:py-[6px] lg:pl-[6px]"
style={{
backgroundColor: "rgba(236,237,239,0.9)",
}}>
<img src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
</div>
<div className="mr-12 mt-8 h-full w-full grid-cols-3 gap-4 overflow-hidden sm:grid">
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
{FEATURES.map((feature) => (
<>
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
+1 -1
View File
@@ -294,7 +294,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!isOrgContext && slug) {
const redirect = await getTemporaryOrgRedirect({
slug: slug,
slugs: slug,
redirectType: RedirectType.Team,
eventTypeSlug: null,
currentQuery: context.query,
+1 -1
View File
@@ -81,7 +81,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
if (!isOrgContext) {
const redirect = await getTemporaryOrgRedirect({
slug: teamSlug,
slugs: teamSlug,
redirectType: RedirectType.Team,
eventTypeSlug: meetingSlug,
currentQuery: context.query,
+1 -1
View File
@@ -435,7 +435,7 @@ test.describe("Reschedule for booking with seats", () => {
await page.locator('[data-testid="confirm_cancel"]').click();
await page.waitForLoadState("networkidle");
await page.waitForResponse((res) => res.url().includes("api/cancel") && res.status() === 200);
const oldBooking = await prisma.booking.findFirst({
where: { uid: booking.uid },
@@ -0,0 +1,15 @@
import { loginUser } from "../fixtures/regularBookings";
import { test } from "../lib/fixtures";
test.describe("Check availability tab in a event-type", () => {
test("Check availability in event type", async ({ eventTypePage, users }) => {
await loginUser(users);
await eventTypePage.goToEventTypesPage();
await eventTypePage.goToEventType("30 min");
await eventTypePage.goToTab("availability");
await eventTypePage.checkAvailabilityTab();
await eventTypePage.goToAvailabilityPage();
await eventTypePage.checkAvailabilityPage();
});
});
@@ -0,0 +1,93 @@
import { expect, type Page } from "@playwright/test";
import { localize } from "../lib/testUtils";
export function createEventTypeFixture(page: Page) {
return {
goToEventType: async (eventType: string) => {
await page.getByRole("link", { name: eventType }).click();
},
goToTab: async (tabName: string) => {
await page.getByTestId(`vertical-tab-${tabName}`).click();
},
goToEventTypesPage: async () => {
await page.goto("/event-types");
},
checkAvailabilityTab: async () => {
const editAvailability = (await localize("en"))("edit_availability");
// Verify if the icon is rendered
await expect(page.locator("span").filter({ hasText: "Europe/London" }).locator("svg")).toBeVisible();
await expect(page.getByText("Europe/London")).toBeVisible();
await page.getByRole("link", { name: editAvailability }).click();
},
goToAvailabilityPage: async () => {
const workingHours = (await localize("en"))("default_schedule_name");
await page.goto("/availability");
await page
.getByTestId("schedules")
.locator("div")
.filter({
hasText: workingHours,
})
.first()
.click();
},
checkAvailabilityPage: async () => {
const sunday = (await localize("en"))("sunday");
const monday = (await localize("en"))("monday");
const wednesday = (await localize("en"))("wednesday");
const saturday = (await localize("en"))("saturday");
const save = (await localize("en"))("save");
const copyTimesTo = (await localize("en"))("copy_times_to");
await page.getByTestId("availablity-title").click();
// change availability name
await page.getByTestId("availablity-title").fill("Working Hours test");
await expect(page.getByTestId("subtitle")).toBeVisible();
await page.getByTestId(sunday).getByRole("switch").click();
await page.getByTestId(monday).first().click();
await page.getByTestId(wednesday).getByRole("switch").click();
await page.getByTestId(saturday).getByRole("switch").click();
await page
.locator("div")
.filter({ hasText: "Sunday9:00am - 5:00pm" })
.getByTestId("add-time-availability")
.first()
.click();
await expect(page.locator("div").filter({ hasText: "6:00pm" }).nth(1)).toBeVisible();
await page.getByRole("button", { name: save }).click();
await expect(page.getByText("Sun - Tue, Thu - Sat, 9:00 AM - 5:00 PM")).toBeVisible();
await expect(page.getByText("Sun, 5:00 PM - 6:00 PM")).toBeVisible();
await page
.locator("div")
.filter({ hasText: "Sunday9:00am - 5:00pm" })
.getByTestId("copy-button")
.first()
.click();
await expect(page.getByText(copyTimesTo)).toBeVisible();
await page.getByRole("checkbox", { name: monday }).check();
await page.getByRole("button", { name: "Apply" }).click();
await page.getByRole("button", { name: save }).click();
await page
.locator("#availability-form div")
.filter({ hasText: "TimezoneEurope/London" })
.locator("svg")
.click();
await page.locator("#react-select-3-input").fill("bras");
await page.getByTestId("select-option-America/Sao_Paulo").click();
await page.getByRole("button", { name: save }).click();
await expect(page.getByTestId("toast-success").last()).toBeVisible();
await page.getByTestId("add-override").click();
await page.getByTestId("incrementMonth").click();
await page.getByRole("button", { name: "20" }).click();
await page.getByTestId("date-override-mark-unavailable").click();
await page.getByTestId("add-override-submit-btn").click();
await page.getByTestId("dialog-rejection").click();
await page.getByTestId("date-overrides-list").getByRole("button").nth(1).click();
await page.getByRole("button", { name: save }).click();
await expect(page.getByTestId("toast-success").last()).toBeVisible();
},
};
}
+6
View File
@@ -7,6 +7,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmailsFixture } from "../fixtures/emails";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createEventTypeFixture } from "../fixtures/eventTypes";
import { createFeatureFixture } from "../fixtures/features";
import { createOrgsFixture } from "../fixtures/orgs";
import { createPaymentsFixture } from "../fixtures/payments";
@@ -28,6 +29,7 @@ export interface Fixtures {
routingForms: ReturnType<typeof createRoutingFormsFixture>;
bookingPage: ReturnType<typeof createBookingPageFixture>;
features: ReturnType<typeof createFeatureFixture>;
eventTypePage: ReturnType<typeof createEventTypeFixture>;
}
declare global {
@@ -92,4 +94,8 @@ export const test = base.extend<Fixtures>({
await features.init();
await use(features);
},
eventTypePage: async ({ page }, use) => {
const eventTypePage = createEventTypeFixture(page);
await use(eventTypePage);
},
});
+1 -1
View File
@@ -360,5 +360,5 @@ export async function doOnOrgDomain(
}
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
export const NotFoundPageText = "This page could not be found";
export const NotFoundPageTextAppDir = "This page does not exist.";
// export const NotFoundPageText = "ERROR 404";
+68
View File
@@ -0,0 +1,68 @@
import { expect } from "@playwright/test";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Payment", () => {
test.describe("user", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
test("should create a mock payment for a user", async ({ context, users, page }) => {
test.skip(process.env.MOCK_PAYMENT_APP_ENABLED === undefined, "Skipped as Stripe is not installed");
const user = await users.create();
await user.apiLogin();
await context.addCookies([
{
name: "x-calcom-future-routes-override",
value: "1",
url: "http://localhost:3000",
},
]);
await page.goto("/apps");
await page.getByPlaceholder("Search").click();
await page.getByPlaceholder("Search").fill("mock");
await page.getByTestId("install-app-button").click();
await page.waitForURL((url) => url.pathname.endsWith("/apps/installed/payment"));
await page.getByRole("link", { name: "Event Types" }).click();
await page.getByRole("link", { name: /^30 min/ }).click();
await page.getByTestId("vertical-tab-apps").click();
await page.locator("#event-type-form").getByRole("switch").click();
await page.getByPlaceholder("Price").click();
await page.getByPlaceholder("Price").fill("1");
await page.locator("#test-mock-payment-app-currency-id").click();
await page.getByTestId("select-option-USD").click();
await page.getByTestId("update-eventtype").click();
await page.goto(`${user.username}/30-min`);
await page.waitForLoadState("networkidle");
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await page.waitForURL((url) => url.pathname.includes("/payment/"));
const dataNextJsRouter = await page.evaluate(() =>
window.document.documentElement.getAttribute("data-nextjs-router")
);
expect(dataNextJsRouter).toEqual("app");
await page.getByText("Payment", { exact: true }).waitFor();
});
});
});
+10 -8
View File
@@ -6,7 +6,7 @@ import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import {
NotFoundPageText,
NotFoundPageTextAppDir,
bookTimeSlot,
doOnOrgDomain,
fillStripeTestCheckout,
@@ -40,7 +40,7 @@ test.describe("Teams A/B tests", () => {
expect(dataNextJsRouter).toEqual("app");
const locator = page.getByRole("heading", { name: "teams" });
const locator = page.getByRole("heading", { name: "Teams", exact: true });
await expect(locator).toBeVisible();
});
@@ -268,11 +268,13 @@ test.describe("Teams - NonOrg", () => {
// Mark team as private
await page.goto(`/settings/teams/${team.id}/members`);
await page.click("[data-testid=make-team-private-check]");
await expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible();
// according to switch implementation, checked state can be set before mutation is resolved
// so we need to await for req to resolve
await page.waitForResponse((res) => res.url().includes("/api/trpc/teams/update"));
await Promise.all([
page.click("[data-testid=make-team-private-check]"),
expect(page.locator(`[data-testid=make-team-private-check][data-state="checked"]`)).toBeVisible(),
// according to switch implementation, checked state can be set before mutation is resolved
// so we need to await for req to resolve
page.waitForResponse((res) => res.url().includes("/api/trpc/teams/update")),
]);
// Go to Team's page
await page.goto(`/team/${team.slug}`);
@@ -389,7 +391,7 @@ test.describe("Teams - Org", () => {
await page.goto(`/team/${team.slug}/${teamEventSlug}`);
await expect(page.locator(`text=${NotFoundPageText}`)).toBeVisible();
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
await doOnOrgDomain(
{
orgSlug: org.slug,
@@ -1307,6 +1307,7 @@
"do_this": "Do this",
"turn_off": "Turn off",
"turn_on": "Turn on",
"cancelled_bookings_cannot_be_rescheduled":"Canceled bookings cannot be rescheduled",
"settings_updated_successfully": "Settings updated successfully",
"error_updating_settings": "Error updating settings",
"personal_cal_url": "My personal {{appName}} URL",
@@ -2222,5 +2223,6 @@
"add_a_redirect": "Add a redirect",
"create_entry": "Create entry",
"time_range": "Time range",
"redirect_to": "Redirect to",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -66,6 +66,7 @@
"update_calendar_event_error": "לא ניתן לעדכן אירוע לוח שנה.",
"delete_calendar_event_error": "לא ניתן למחוק אירוע לוח שנה.",
"already_signed_up_for_this_booking_error": "כבר נרשמת להזמנה הזאת.",
"hosts_unavailable_for_booking": "חלק מהמארחים לא פנויים להזמנות.",
"help": "עזרה",
"price": "מחיר",
"paid": "שולם",
@@ -1749,6 +1750,7 @@
"email_no_user_invite_heading_org": "הוזמנת להצטרף לארגון ב-{{appName}}",
"email_no_user_invite_subheading": "{{invitedBy}} הזמין אותך להצטרף לצוות שלו ב- {{appName}}. {{appName}} הינה מתזמן זימונים שמאפשר לך ולצוות שלך לזמן פגישות בלי כל הפינג פונג במיילים.",
"email_user_invite_subheading_team": "{{invitedBy}} הזמין/ה אותך להצטרף לצוות שלו/ה בשם '{{teamName}}' באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולצוות שלך לתזמן פגישות בלי כל הפינג פונג במיילים.",
"email_user_invite_subheading_subteam": "הוזמנת על ידי {{invitedBy}} להצטרף לצוות {{teamName}} בארגון {{parentTeamName}} תחת {{appName}}.‏ {{appName}} הוא מתזמן שיודע לסנכרן בין אירועים שמאפשר לך לצוות שלך לקבוע פגישות בלי לשחק טניס בהודעות.",
"email_user_invite_subheading_org": "{{invitedBy}} הזמין/ה אותך להצטרף לארגון שלו/ה בשם ״{{teamName}}״ באפליקציה {{appName}}. אפליקציית {{appName}} היא כלי לקביעת מועדים לאירועים שמאפשר לך ולארגון שלך לתזמן פגישות בלי הצורך לנהל התכתבויות ארוכות בדוא״ל.",
"email_no_user_invite_steps_intro": "נדריך אותך במספר קטן של צעדים ותוכל/י להתחיל ליהנות מקביעת מועדים עם ה-{{entity}} שלך במהירות ובלי בעיות.",
"email_no_user_step_one": "בחר שם משתמש",
@@ -2149,7 +2151,9 @@
"join_event_location": "הצטרפות אל {{eventLocationType}}",
"troubleshooting": "פתרון בעיות",
"calendars_were_checking_for_conflicts": "לוחות השנה לא בודקים סתירות",
"availabilty_schedules": "לוחות זמנים לזמינוּת",
"manage_calendars": "ניהול לוחות שנה",
"manage_availability_schedules": "ניהול לוחות זמנים לזמינות",
"lock_timezone_toggle_on_booking_page": "נעילת אזור הזמן בדף ההזמנות",
"description_lock_timezone_toggle_on_booking_page": "כדי לנעול את אזור הזמן בדף ההזמנות – שימושי לאירועים אישיים.",
"number_in_international_format": "נא למלא מספר בתבנית בינלאומית.",
@@ -2158,6 +2162,7 @@
"branded_subdomain_description": "קבלת תת־תחום ממותג משלך, כגון acme.cal.com",
"org_insights": "תובנות כלל־ארגוניות",
"extensive_whitelabeling": "תהליך הטמעה והנדסת תמיכה אישי",
"extensive_whitelabeling_description": "אפשר לעצב את חוויית הזימון שלך עם לוגו, צבעים ופריטי עיצוב נוספים משלך",
"unlimited_teams": "כמות בלתי מוגבלת של צוותים",
"unlimited_teams_description": "אפשר להוסיף כמה תת־צוותים שדרושים לארגון שלך",
"unified_billing": "חיוב מאוחד",
@@ -2173,6 +2178,7 @@
"unpublished": "לא מפורסם",
"publish": "פרסום",
"org_publish_error": "אי אפשר לפרסם את הארגון",
"troubleshooter_tooltip": "אפשר לפתוח את פותר הבעיות ולנסות לפענח מה לא תקין בלוח הזמנים שלך",
"need_help": "צריך עזרה?",
"troubleshooter": "פותר בעיות",
"please_install_a_calendar": "נא להתקין לוח שנה",
@@ -2181,6 +2187,7 @@
"uprade_to_create_instant_bookings": "ניתן לשדג לרישיון התאגידי ולאפשר למשתמשים להצטרף לשיחה מיידית שמשתתפים יכולים לקפוץ ישירות אליה. זה מיועד רק לסוגי אירועים של צוותים",
"dont_want_to_wait": "לא רוצה להמתין?",
"meeting_started": "הפגישה החלה",
"user_redirect_description": "בינתיים, האחריות על כל ההזמנות המתוזמנות החדשות בשם {{username}} תהיה על כתפי {{profile.username}}.",
"out_of_office": "מחוץ למשרד",
"out_of_office_description": "הגדרת פעולות בפרופיל שלך כשאינך במשרד.",
"send_request": "שליחת בקשה",
@@ -2194,14 +2201,21 @@
"success_entry_created": "רשומה חדשה נוצרה בהצלחה",
"booking_redirect_email_subject": "התראת הפניית הזמנה",
"booking_redirect_email_title": "התראת הפניית הזמנה",
"booking_redirect_email_description": "קיבלת הפניית הזמנות מאת {{toName}} כך שקישורי הפרופילים שלהם יפנו לשלך למשך: ",
"success_accept_booking_redirect": "קיבלת את בקשת הפניית ההזמנות הזאת.",
"success_reject_booking_redirect": "דחית את בקשת הפניית ההזמנות הזאת.",
"copy_link_booking_redirect_request": "העתקת קישור לבקשת שיתוף",
"booking_redirect_request_title": "בקשת הפניית הזמנות",
"select_team_member": "בחירת נציגות מהצוות",
"going_away_title": "יצאת לחופש? צריך רק לסמן את קישור הפרופיל שלך כלא זמן למשך תקופת זמן מסוימת.",
"redirect_team_enabled": "הפניית הפרופיל שלך לחברים אחרים בצוות",
"redirect_team_disabled": "הפניית הפרופיל שלך לחברים אחרים בצוות (למנויי תוכנית צוותית)",
"out_of_office_unavailable_list": "רשימת אי־זמינות למחוץ למשרד",
"success_deleted_entry_out_of_office": "רשומה נמחקה בהצלחה",
"temporarily_out_of_office": "יצאת למשרד באופן זמני?",
"add_a_redirect": "הוספת הפנייה",
"create_entry": "יצירת רשומה",
"time_range": "טווח זמן",
"redirect_to": "הפניה אל",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -56,6 +56,17 @@
"a_refund_failed": "Falhou ao reembolsar",
"awaiting_payment_subject": "A aguardar pagamento: {{title}} em {{date}}",
"meeting_awaiting_payment": "A sua reunião aguarda pagamento",
"dark_theme_contrast_error": "Cor do tema escuro não passa por uma verificação do contraste. Recomendamos que você altere esta cor para que seus botões fiquem mais visíveis.",
"light_theme_contrast_error": "Cor do tema claro não passa em uma verificação do contraste. Recomendamos que você altere esta cor para que seus botões fiquem mais visíveis.",
"payment_not_created_error": "O pagamento não foi criado",
"couldnt_charge_card_error": "Não foi possível pagar com o cartão",
"no_available_users_found_error": "Nenhum usuário disponível foi encontrado. Você poderia tentar outro horário?",
"request_body_end_time_internal_error": "Erro interno. O corpo da solicitação não contém o horário final",
"create_calendar_event_error": "Não é possível criar um calendário no calendário do organizador",
"update_calendar_event_error": "Não foi possível atualizar o evento Calendário.",
"delete_calendar_event_error": "Não é possível apagar o evento da Agenda.",
"already_signed_up_for_this_booking_error": "Você já está registrado para esta reserva.",
"hosts_unavailable_for_booking": "Alguns dos anfitriões não estão disponíveis para reserva.",
"help": "Ajuda",
"price": "Preço",
"paid": "Pago",
@@ -67,6 +78,7 @@
"cannot_repackage_codebase": "Não pode redistribuir ou vender o código",
"acquire_license": "Adquira uma licença comercial para remover estes termos através do envio de um email",
"terms_summary": "Resumo dos termos",
"signing_up_terms": "Ao se cadastrar você concorda com nossos <2>Termos</2> & <3>Política de Privacidade</3>.",
"open_env": "Abra o .env e concorde com a nossa licença",
"env_changed": "Alterei o meu .env",
"accept_license": "Aceitar licença",
@@ -101,6 +113,7 @@
"requested_to_reschedule_subject_attendee": "Acção necessária para reagendamento: Por favor reserve uma nova hora para {{eventType}} com {{name}}",
"hi_user_name": "Olá {{name}}",
"ics_event_title": "{{eventType}} com {{name}}",
"please_book_a_time_sometime_later": "Ninguém estava disponível no momento. Por favor, faça uma reserva para um horário mais tarde",
"new_event_subject": "Novo evento: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Junte-se através de {{entryPoint}}",
"notes": "Notas",
@@ -117,12 +130,16 @@
"meeting_id": "ID da reunião",
"meeting_password": "Senha da reunião",
"meeting_url": "URL da reunião",
"meeting_url_not_found": "URL da reunião não encontrada",
"token_not_found": "Token não encontrado",
"some_other_host_already_accepted_the_meeting": "Algum outro anfitrião já aceitou a reunião. Ainda deseja entrar? <1>Continue a Reunião</1>",
"meeting_request_rejected": "O seu pedido de reunião foi rejeitado",
"rejected_event_type_with_organizer": "Rejeitado: {{eventType}} com {{organizer}} em {{date}}",
"hi": "Olá",
"join_team": "Junte-se à equipa",
"manage_this_team": "Gerir esta equipa",
"team_info": "Informações da equipa",
"join_meeting": "Entrar na Reunião",
"request_another_invitation_email": "Se não quiser utilizar {{toEmail}} como email do {{appName}}, ou já tem uma conta em {{appName}}, por favor peça outro convite para esse email.",
"you_have_been_invited": "Recebeu um convite para juntar-se à equipa {{teamName}}",
"user_invited_you": "{{user}} enviou um convite para se juntar a {{team}} de {{entity}} em {{appName}}",
@@ -1046,72 +1046,77 @@ export function mockCalendar(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deleteEventCalls: any[] = [];
const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata];
appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CalendarService: function MockCalendarService() {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.creationCrash) {
throw new Error("MockCalendarService.createEvent fake error");
}
const [calEvent, credentialId] = rest;
log.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId }));
createEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
// A Calendar is always expected to return an id.
id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID",
iCalUID: normalizedCalendarData.create?.iCalUID,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.updationCrash) {
throw new Error("MockCalendarService.updateEvent fake error");
}
const [uid, event, externalCalendarId] = rest;
log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId }));
// eslint-disable-next-line prefer-rest-params
updateEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
iCalUID: normalizedCalendarData.update?.iCalUID,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID",
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
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
deleteEventCalls.push(rest);
},
getAvailability: async (): Promise<EventBusyDate[]> => {
if (calendarData?.getAvailabilityCrash) {
throw new Error("MockCalendarService.getAvailability fake error");
}
return new Promise((resolve) => {
resolve(calendarData?.busySlots || []);
});
},
};
const appMock = appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default];
appMock &&
`mockResolvedValue` in appMock &&
appMock.mockResolvedValue({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
CalendarService: function MockCalendarService() {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.creationCrash) {
throw new Error("MockCalendarService.createEvent fake error");
}
const [calEvent, credentialId] = rest;
log.silly("mockCalendar.createEvent", JSON.stringify({ calEvent, credentialId }));
createEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
// A Calendar is always expected to return an id.
id: normalizedCalendarData.create?.id || "FALLBACK_MOCK_CALENDAR_EVENT_ID",
iCalUID: normalizedCalendarData.create?.iCalUID,
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
url: "https://UNUSED_URL",
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateEvent: async function (...rest: any[]): Promise<NewCalendarEventType> {
if (calendarData?.updationCrash) {
throw new Error("MockCalendarService.updateEvent fake error");
}
const [uid, event, externalCalendarId] = rest;
log.silly("mockCalendar.updateEvent", JSON.stringify({ uid, event, externalCalendarId }));
// eslint-disable-next-line prefer-rest-params
updateEventCalls.push(rest);
return Promise.resolve({
type: app.type,
additionalInfo: {},
uid: "PROBABLY_UNUSED_UID",
iCalUID: normalizedCalendarData.update?.iCalUID,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: normalizedCalendarData.update?.uid || "FALLBACK_MOCK_ID",
// Password and URL seems useless for CalendarService, plan to remove them if that's the case
password: "MOCK_PASSWORD",
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
deleteEventCalls.push(rest);
},
getAvailability: async (): Promise<EventBusyDate[]> => {
if (calendarData?.getAvailabilityCrash) {
throw new Error("MockCalendarService.getAvailability fake error");
}
return new Promise((resolve) => {
resolve(calendarData?.busySlots || []);
});
},
};
},
},
},
});
});
return {
createEventCalls,
deleteEventCalls,
+1
View File
@@ -109,6 +109,7 @@
"turbo": "^1.10.1"
},
"resolutions": {
"types-ramda": "0.29.4",
"@apidevtools/json-schema-ref-parser": "9.0.9",
"@types/node": "16.9.1",
"@types/react": "18.0.26",
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
@@ -21,7 +21,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
eventType,
eventTypeFormMetadata,
}) {
const { asPath } = useRouter();
const searchParams = useSearchParams();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
@@ -29,6 +29,7 @@ export const EventTypeAddonMap = {
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")),
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
"mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")),
paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
@@ -17,6 +17,7 @@ import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appKeysSchema as make_zod_ts } from "./make/zod";
import { appKeysSchema as matomo_zod_ts } from "./matomo/zod";
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
@@ -55,6 +56,7 @@ export const appKeysSchemas = {
make: make_zod_ts,
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
"mock-payment-app": mock_payment_app_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,
@@ -36,6 +36,7 @@ import make_config_json from "./make/config.json";
import matomo_config_json from "./matomo/config.json";
import metapixel_config_json from "./metapixel/config.json";
import mirotalk_config_json from "./mirotalk/config.json";
import mock_payment_app_config_json from "./mock-payment-app/config.json";
import n8n_config_json from "./n8n/config.json";
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
import office365video_config_json from "./office365video/config.json";
@@ -115,6 +116,7 @@ export const appStoreMetadata = {
matomo: matomo_config_json,
metapixel: metapixel_config_json,
mirotalk: mirotalk_config_json,
"mock-payment-app": mock_payment_app_config_json,
n8n: n8n_config_json,
office365calendar: office365calendar__metadata_ts,
office365video: office365video_config_json,
@@ -17,6 +17,7 @@ import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appDataSchema as make_zod_ts } from "./make/zod";
import { appDataSchema as matomo_zod_ts } from "./matomo/zod";
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
@@ -55,6 +56,7 @@ export const appDataSchemas = {
make: make_zod_ts,
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
"mock-payment-app": mock_payment_app_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,
@@ -36,6 +36,7 @@ export const apiHandlers = {
matomo: import("./matomo/api"),
metapixel: import("./metapixel/api"),
mirotalk: import("./mirotalk/api"),
"mock-payment-app": import("./mock-payment-app/api"),
n8n: import("./n8n/api"),
office365calendar: import("./office365calendar/api"),
office365video: import("./office365video/api"),
+9 -1
View File
@@ -41,4 +41,12 @@ const appStore = {
shimmervideo: () => import("./shimmervideo"),
};
export default appStore;
const exportedAppStore: typeof appStore & {
["mock-payment-app"]?: () => Promise<typeof import("./mock-payment-app/index")>;
} = appStore;
if (process.env.MOCK_PAYMENT_APP_ENABLED !== undefined) {
exportedAppStore["mock-payment-app"] = () => import("./mock-payment-app/index");
}
export default exportedAppStore;
@@ -0,0 +1,8 @@
---
items:
- 1.jpeg
- 2.jpeg
- 3.jpeg
---
{DESCRIPTION}
@@ -0,0 +1,16 @@
import { createDefaultInstallation } from "@calcom/app-store/_utils/installation";
import type { AppDeclarativeHandler } from "@calcom/types/AppHandler";
import appConfig from "../config.json";
const handler: AppDeclarativeHandler = {
appType: appConfig.type,
variant: appConfig.variant,
slug: appConfig.slug,
supportsMultipleInstalls: false,
handlerType: "add",
createCredential: ({ appType, user, slug, teamId }) =>
createDefaultInstallation({ appType, userId: user.id, slug, key: {}, teamId }),
};
export default handler;
@@ -0,0 +1 @@
export { default as add } from "./add";
@@ -0,0 +1,136 @@
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
import {
currencyOptions,
currencySymbols,
isAcceptedCurrencyCode,
} from "@calcom/app-store/paypal/lib/currencyOptions";
import type { EventTypeAppCardComponent } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Select, TextField } from "@calcom/ui";
import type { appDataSchema } from "../zod";
import { paymentOptions } from "../zod";
type Option = { value: string; label: string };
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ app, eventType }) {
const searchParams = useSearchParams();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { t } = useLocale();
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
const currency = getAppData("currency");
const paymentOption = getAppData("paymentOption");
const enable = getAppData("enabled");
const [selectedCurrency, setSelectedCurrency] = useState(currencyOptions.find((c) => c.value === currency));
const [currencySymbol, setCurrencySymbol] = useState(
isAcceptedCurrencyCode(currency) ? currencySymbols[currency] : ""
);
const [requirePayment, setRequirePayment] = useState(enable);
const paymentOptionSelectValue = paymentOptions?.find((option) => paymentOption === option.value) || {
label: paymentOptions[0].label,
value: paymentOptions[0].value,
};
const recurringEventDefined = eventType.recurringEvent?.count !== undefined;
return (
<AppCard
returnTo={WEBAPP_URL + asPath}
app={app}
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}
description={<>Add a mock payment to your events</>}>
<>
{recurringEventDefined ? (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
) : (
requirePayment && (
<>
<div className="mt-2 block items-center sm:flex">
<TextField
id="test-mock-payment-app-price"
label="Price"
labelSrOnly
addOnLeading={currencySymbol}
addOnSuffix={currency}
step="0.01"
min="0.5"
type="number"
required
className="block w-full rounded-sm pl-2 text-sm"
placeholder="Price"
onChange={(e) => {
setAppData("price", Number(e.target.value) * 100);
if (selectedCurrency) {
setAppData("currency", selectedCurrency.value);
}
}}
value={price > 0 ? price / 100 : undefined}
/>
</div>
<div className="mt-5 w-60">
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">
{t("currency")}
</label>
<Select
id="test-mock-payment-app-currency-id"
variant="default"
options={currencyOptions}
value={selectedCurrency}
className="text-black"
defaultValue={selectedCurrency}
onChange={(e) => {
if (e) {
setSelectedCurrency(e);
setCurrencySymbol(currencySymbols[e.value]);
setAppData("currency", e.value);
}
}}
/>
</div>
<div className="mt-4 w-60">
<label className="text-default mb-1 block text-sm font-medium" htmlFor="currency">
Payment option
</label>
<Select<Option>
defaultValue={
paymentOptionSelectValue
? { ...paymentOptionSelectValue, label: t(paymentOptionSelectValue.label) }
: { ...paymentOptions[0], label: t(paymentOptions[0].label) }
}
options={paymentOptions.map((option) => {
return { ...option, label: t(option.label) || option.label };
})}
onChange={(input) => {
if (input) setAppData("paymentOption", input.value);
}}
className="mb-1 h-[38px] w-full"
isDisabled={false}
/>
</div>
</>
)
)}
</>
</AppCard>
);
};
export default EventTypeAppCard;
@@ -0,0 +1,18 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Mock Payment App",
"slug": "mock-payment-app",
"type": "mock-payment-app_payment",
"logo": "icon.svg",
"url": "https://example.com/link",
"variant": "payment",
"categories": ["payment"],
"publisher": "Intuita",
"email": "greg@intuita.io",
"description": "The mock payment app for tests",
"isTemplate": false,
"__createdUsingCli": true,
"__template": "basic",
"dirName": "mock-payment-app",
"extendsFeature": "EventType"
}
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,141 @@
import type { Booking, Payment, Prisma, PaymentOption } from "@prisma/client";
import { v4 as uuidv4 } from "uuid";
import prisma from "@calcom/prisma";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
export class PaymentService implements IAbstractPaymentService {
async create(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"]
) {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (booking === null) {
throw new Error("Booking not found");
}
const uid = uuidv4();
console.log("CREATE payment");
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "mock-payment-app",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
externalId: uid,
currency: payment.currency,
data: {} as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
},
});
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
async update(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async refund(): Promise<Payment> {
throw new Error("Method not implemented.");
}
async collectCard(
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: number,
_bookerEmail: string,
paymentOption: PaymentOption
): Promise<Payment> {
try {
const booking = await prisma.booking.findFirst({
select: {
uid: true,
title: true,
},
where: {
id: bookingId,
},
});
if (booking === null) {
throw new Error("Booking not found");
}
const uid = uuidv4();
const paymentData = await prisma.payment.create({
data: {
uid,
app: {
connect: {
slug: "paypal",
},
},
booking: {
connect: {
id: bookingId,
},
},
amount: payment.amount,
currency: payment.currency,
data: {} as Prisma.InputJsonValue,
fee: 0,
refunded: false,
success: false,
paymentOption,
externalId: "",
},
});
if (!paymentData) {
throw new Error();
}
return paymentData;
} catch (error) {
console.error(error);
throw new Error("Payment could not be created");
}
}
chargeCard(): Promise<Payment> {
throw new Error("Method not implemented.");
}
getPaymentPaidStatus(): Promise<string> {
throw new Error("Method not implemented.");
}
getPaymentDetails(): Promise<Payment> {
throw new Error("Method not implemented.");
}
afterPayment(): Promise<void> {
return Promise.resolve();
}
deletePayment(): Promise<boolean> {
return Promise.resolve(false);
}
isSetupAlready(): boolean {
return true;
}
}
@@ -0,0 +1 @@
export { PaymentService } from "./PaymentService";
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/mock-payment-app",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "The mock payment app for tests"
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" viewBox="0 0 48 48"><title>blockchain</title><g class="nc-icon-wrapper"><path d="M32.874,10.405a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L24,15.828Z" fill="#a0e6ee"></path><path d="M15.126,10.405a1,1,0,0,0-.126.484v8.878a1,1,0,0,0,.479.853l8,4.889a1,1,0,0,0,.521.147V15.828Z" fill="#6cc4f5"></path><path d="M32.874,10.405a1,1,0,0,1,.126.484v8.878a1,1,0,0,1-.479.853l-8,4.889a1,1,0,0,1-.521.147V15.828Z" fill="#2594d0"></path><path d="M47.874,19.589a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L39,25.013Z" fill="#a0e6ee"></path><path d="M30.126,19.589a1,1,0,0,0-.126.485v8.878a1,1,0,0,0,.479.853l8,4.889a1,1,0,0,0,.521.147V25.013Z" fill="#6cc4f5"></path><path d="M47.874,19.589a1,1,0,0,1,.126.485v8.878a1,1,0,0,1-.479.853l-8,4.889a1,1,0,0,1-.521.147V25.013Z" fill="#2594d0"></path><path d="M17.874,19.589a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L9,25.013Z" fill="#a0e6ee"></path><path d="M.126,19.589A1,1,0,0,0,0,20.074v8.878a1,1,0,0,0,.479.853l8,4.889A1,1,0,0,0,9,34.841V25.013Z" fill="#6cc4f5"></path><path d="M17.874,19.589a1,1,0,0,1,.126.485v8.878a1,1,0,0,1-.479.853l-8,4.889A1,1,0,0,1,9,34.841V25.013Z" fill="#2594d0"></path><path d="M32.874,29.577a.99.99,0,0,0-.353-.369l-8-4.889a1,1,0,0,0-1.042,0l-8,4.889a.99.99,0,0,0-.353.369L24,35Z" fill="#a0e6ee"></path><path d="M15.126,29.577a1,1,0,0,0-.126.484v8.878a1,1,0,0,0,.479.853l8,4.889a1,1,0,0,0,.521.147V35Z" fill="#6cc4f5"></path><path d="M32.874,29.577a1,1,0,0,1,.126.484v8.878a1,1,0,0,1-.479.853l-8,4.889a1,1,0,0,1-.521.147V35Z" fill="#2594d0"></path></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,35 @@
import { z } from "zod";
import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod";
const paymentOptionSchema = z.object({
label: z.string(),
value: z.string(),
});
export const paymentOptionsSchema = z.array(paymentOptionSchema);
export const paymentOptions = [
{
label: "on_booking_option",
value: "ON_BOOKING",
},
];
type PaymentOption = (typeof paymentOptions)[number]["value"];
const VALUES: [PaymentOption, ...PaymentOption[]] = [
paymentOptions[0].value,
...paymentOptions.slice(1).map((option) => option.value),
];
export const paymentOptionEnum = z.enum(VALUES);
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
price: z.number(),
currency: z.string(),
paymentOption: z.string().optional(),
enabled: z.boolean().optional(),
})
);
export const appKeysSchema = z.object({});
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useMemo } from "react";
import { useAppContextWithSchema } from "@calcom/app-store/EventTypeAppContext";
import AppCard from "@calcom/app-store/_components/AppCard";
@@ -24,7 +24,13 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
eventType,
eventTypeFormMetadata,
}) {
const { asPath } = useRouter();
const searchParams = useSearchParams();
/** TODO "pathname" no longer contains square-bracket expressions. Rewrite the code relying on them if required. **/
const pathname = usePathname();
const asPath = useMemo(
() => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`,
[pathname, searchParams]
);
const { getAppData, setAppData } = useAppContextWithSchema<typeof appDataSchema>();
const price = getAppData("price");
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { test } from "@calcom/web/playwright/lib/fixtures";
import { NotFoundPageText, gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
import { NotFoundPageTextAppDir, gotoRoutingLink } from "@calcom/web/playwright/lib/testUtils";
import {
addForm,
@@ -36,7 +36,7 @@ test.describe("Routing Forms", () => {
await page.goto(`apps/routing-forms/route-builder/${formId}`);
await disableForm(page);
await gotoRoutingLink({ page, formId });
await expect(page.locator(`text=${NotFoundPageText}`)).toBeVisible();
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
});
test("should be able to edit the form", async ({ page }) => {
@@ -25,7 +25,6 @@ import {
import getBookingResponsesSchema, {
getBookingResponsesPartialSchema,
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import { getFullName } from "@calcom/features/form-builder/utils";
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
@@ -531,10 +530,22 @@ const RedirectToInstantMeetingModal = ({ expiryTime }: { expiryTime?: Date }) =>
</Button>
</div>
) : (
<div>
<div className="text-center">
<p className="font-medium">{t("connecting_you_to_someone")}</p>
{/* TODO: Add countdown from 60 seconds
We are connecting you!
Please schedule a future call if we're not available in XX seconds.
*/}
{/* Once countdown ends:
Oops, we couldn't connect you this time.
Please schedule a future call instead. We value your time.
*/}
<p className="font-medium">{t("please_do_not_close_this_tab")}</p>
<Spinner className="relative mt-8" />
<div className="h-[450px]">
<iframe className="mx-auto h-full" src="https://cal.games/" />
</div>
</div>
)}
</div>
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
@@ -32,7 +33,7 @@ export function Header({
}) {
const { t, i18n } = useLocale();
const session = useSession();
const isEmbed = useIsEmbed();
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
const selectedDateString = useBookerStore((state) => state.selectedDate);
const setSelectedDate = useBookerStore((state) => state.setSelectedDate);
@@ -68,7 +69,7 @@ export function Header({
if (isMonthView) {
return (
<div className="flex gap-2">
{isMyLink ? (
{isMyLink && !isEmbed ? (
<Tooltip content={t("troubleshooter_tooltip")} side="bottom">
<Button
color="primary"
@@ -168,7 +169,7 @@ const LayoutToggle = ({
layout: string;
enabledLayouts?: BookerLayouts[];
}) => {
const isEmbed = typeof window !== "undefined" && window?.isEmbed?.();
const isEmbed = useIsEmbed();
const { t } = useLocale();
@@ -597,7 +597,8 @@ async function handler(req: CustomRequest) {
// Posible to refactor TODO:
const paymentApp = (await appStore[
paymentAppCredential?.app?.dirName as keyof typeof appStore
]()) as PaymentApp;
]?.()) as PaymentApp;
if (!paymentApp?.lib?.PaymentService) {
console.warn(`payment App service of type ${paymentApp} is not implemented`);
return null;
+177 -121
View File
@@ -6,6 +6,7 @@ import { isValidPhoneNumber } from "libphonenumber-js";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { NextApiRequest } from "next";
import type { TFunction } from "next-i18next";
import short, { uuid } from "short-uuid";
import type { Logger } from "tslog";
import { v5 as uuidv5 } from "uuid";
@@ -98,6 +99,7 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import getBookingDataSchema from "./getBookingDataSchema";
import type { BookingSeat } from "./handleSeats";
const translator = short();
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
@@ -105,7 +107,7 @@ const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
type User = Prisma.UserGetPayload<typeof userSelect>;
type BufferedBusyTimes = BufferedBusyTime[];
type BookingType = Prisma.PromiseReturnType<typeof getOriginalRescheduledBooking>;
type Booking = Prisma.PromiseReturnType<typeof createBooking>;
export type Booking = Prisma.PromiseReturnType<typeof createBooking>;
export type NewBookingEventType =
| Awaited<ReturnType<typeof getDefaultEvent>>
| Awaited<ReturnType<typeof getEventTypesFromDB>>;
@@ -113,8 +115,36 @@ export type NewBookingEventType =
// Work with Typescript to require reqBody.end
type ReqBodyWithoutEnd = z.infer<ReturnType<typeof getBookingDataSchema>>;
type ReqBodyWithEnd = ReqBodyWithoutEnd & { end: string };
export type Invitee = {
email: string;
name: string;
firstName: string;
lastName: string;
timeZone: string;
language: {
translate: TFunction;
locale: string;
};
}[];
export type OrganizerUser = Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
};
export type OriginalRescheduledBooking = Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
interface IEventTypePaymentCredentialType {
type AwaitedBookingData = Awaited<ReturnType<typeof getBookingData>>;
export type RescheduleReason = AwaitedBookingData["rescheduleReason"];
export type NoEmail = AwaitedBookingData["noEmail"];
export type AdditionalNotes = AwaitedBookingData["notes"];
export type ReqAppsStatus = AwaitedBookingData["appsStatus"];
export type SmsReminderNumber = AwaitedBookingData["smsReminderNumber"];
export type EventTypeId = AwaitedBookingData["eventTypeId"];
export type ReqBodyMetadata = ReqBodyWithEnd["metadata"];
export type IsConfirmedByDefault = ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
export type PaymentAppData = ReturnType<typeof getPaymentAppData>;
export interface IEventTypePaymentCredentialType {
appId: EventTypeAppsList;
app: {
categories: App["categories"];
@@ -148,7 +178,9 @@ async function refreshCredential(credential: CredentialPayload): Promise<Credent
*
* @param credentials
*/
async function refreshCredentials(credentials: Array<CredentialPayload>): Promise<Array<CredentialPayload>> {
export async function refreshCredentials(
credentials: Array<CredentialPayload>
): Promise<Array<CredentialPayload>> {
return await async.mapLimit(credentials, 5, refreshCredential);
}
@@ -156,7 +188,7 @@ async function refreshCredentials(credentials: Array<CredentialPayload>): Promis
* Gets credentials from the user, team, and org if applicable
*
*/
const getAllCredentials = async (
export const getAllCredentials = async (
user: User & { credentials: CredentialPayload[] },
eventType: Awaited<ReturnType<typeof getEventTypesFromDB>>
) => {
@@ -634,18 +666,18 @@ async function createBooking({
paymentAppData,
changedOrganizer,
}: {
originalRescheduledBooking: Awaited<ReturnType<typeof getOriginalRescheduledBooking>>;
originalRescheduledBooking: OriginalRescheduledBooking;
evt: CalendarEvent;
eventType: NewBookingEventType;
eventTypeId: Awaited<ReturnType<typeof getBookingData>>["eventTypeId"];
eventTypeSlug: Awaited<ReturnType<typeof getBookingData>>["eventTypeSlug"];
eventTypeId: EventTypeId;
eventTypeSlug: AwaitedBookingData["eventTypeSlug"];
reqBodyUser: ReqBodyWithEnd["user"];
reqBodyMetadata: ReqBodyWithEnd["metadata"];
reqBodyRecurringEventId: ReqBodyWithEnd["recurringEventId"];
uid: short.SUUID;
responses: ReqBodyWithEnd["responses"] | null;
isConfirmedByDefault: ReturnType<typeof getRequiresConfirmationFlags>["isConfirmedByDefault"];
smsReminderNumber: Awaited<ReturnType<typeof getBookingData>>["smsReminderNumber"];
isConfirmedByDefault: IsConfirmedByDefault;
smsReminderNumber: AwaitedBookingData["smsReminderNumber"];
organizerUser: Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
@@ -822,6 +854,76 @@ export function getCustomInputsResponses(
return customInputsResponses;
}
/** Updates the evt object with video call data found from booking references
*
* @param bookingReferences
* @param evt
*
* @returns updated evt with video call data
*/
export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt: CalendarEvent) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
return evt;
};
export const createLoggerWithEventDetails = (
eventTypeId: number,
reqBodyUser: string | string[] | undefined,
eventTypeSlug: string | undefined
) => {
return logger.getSubLogger({
prefix: ["book:user", `${eventTypeId}:${reqBodyUser}/${eventTypeSlug}`],
});
};
export function handleAppsStatus(
results: EventResult<AdditionalInformation>[],
booking: (Booking & { appsStatus?: AppsStatus[] }) | null,
reqAppsStatus: ReqAppsStatus
) {
// Taking care of apps status
let resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName,
type: app.type,
success: app.success ? 1 : 0,
failures: !app.success ? 1 : 0,
errors: app.calError ? [app.calError] : [],
warnings: app.calWarnings,
}));
if (reqAppsStatus === undefined) {
if (booking !== null) {
booking.appsStatus = resultStatus;
}
return resultStatus;
}
// From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series,
// proceeding to prepare the info for the event
const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => {
if (prev[curr.type]) {
prev[curr.type].success += curr.success;
prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors);
prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []);
} else {
prev[curr.type] = curr;
}
return prev;
}, {} as { [key: string]: AppsStatus });
resultStatus = Object.values(calcAppsStatus);
return resultStatus;
}
function getICalSequence(originalRescheduledBooking: BookingType | null) {
// If new booking set the sequence to 0
if (!originalRescheduledBooking) {
@@ -837,6 +939,52 @@ function getICalSequence(originalRescheduledBooking: BookingType | null) {
return originalRescheduledBooking.iCalSequence + 1;
}
export const findBookingQuery = async (bookingId: number) => {
const foundBooking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
uid: true,
location: true,
startTime: true,
endTime: true,
title: true,
description: true,
status: true,
responses: true,
user: {
select: {
name: true,
email: true,
timeZone: true,
username: true,
},
},
eventType: {
select: {
title: true,
description: true,
currency: true,
length: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
price: true,
},
},
},
});
// This should never happen but it's just typescript safe
if (!foundBooking) {
throw new Error("Internal Error. Couldn't find booking");
}
// Don't leak any sensitive data
return foundBooking;
};
async function handler(
req: NextApiRequest & { userId?: number | undefined },
{
@@ -884,9 +1032,7 @@ async function handler(
eventType,
});
const loggerWithEventDetails = logger.getSubLogger({
prefix: ["book:user", `${eventTypeId}:${reqBody.user}/${eventTypeSlug}`],
});
const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug);
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
logger.settings.minLevel = 0;
@@ -1052,7 +1198,7 @@ async function handler(
}
let rescheduleUid = reqBody.rescheduleUid;
let bookingSeat: Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null = null;
let bookingSeat: BookingSeat = null;
let originalRescheduledBooking: BookingType = null;
@@ -1078,6 +1224,13 @@ async function handler(
if (!originalRescheduledBooking) {
throw new HttpError({ statusCode: 404, message: "Could not find original booking" });
}
if (
originalRescheduledBooking.status === BookingStatus.CANCELLED &&
!originalRescheduledBooking.rescheduled
) {
throw new HttpError({ statusCode: 403, message: ErrorCode.CancelledBookingsCannotBeRescheduled });
}
}
//checks what users are available
@@ -1176,7 +1329,7 @@ async function handler(
}
}
const invitee = [
const invitee: Invitee = [
{
email: bookerEmail,
name: fullName,
@@ -1201,7 +1354,7 @@ async function handler(
language: { translate: tGuests, locale: "en" },
});
return guestArray;
}, [] as typeof invitee);
}, [] as Invitee);
const seed = `${organizerUser.username}:${dayjs(reqBody.start).utc().format()}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
@@ -1314,20 +1467,6 @@ async function handler(
evt.destinationCalendar?.push(...teamDestinationCalendars);
}
/* Used for seats bookings to update evt object with video data */
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
};
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
const lastAttendeeDeleteBooking = async (
@@ -1574,7 +1713,7 @@ async function handler(
},
});
addVideoCallDataToEvt(newBooking.references);
evt = addVideoCallDataToEvt(newBooking.references, evt);
const copyEvent = cloneDeep(evt);
@@ -1611,7 +1750,7 @@ async function handler(
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
evt.appsStatus = handleAppsStatus(results, newBooking);
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
}
}
}
@@ -1719,7 +1858,7 @@ async function handler(
evt.attendees = updatedBookingAttendees;
addVideoCallDataToEvt(updatedNewBooking.references);
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
const copyEvent = cloneDeep(evt);
@@ -2015,7 +2154,7 @@ async function handler(
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
isNotConfirmed: evt.requiresConfirmation || false,
isNotConfirmed: rescheduleUid ? false : evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
@@ -2167,43 +2306,6 @@ async function handler(
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
function handleAppsStatus(
results: EventResult<AdditionalInformation>[],
booking: (Booking & { appsStatus?: AppsStatus[] }) | null
) {
// Taking care of apps status
let resultStatus: AppsStatus[] = results.map((app) => ({
appName: app.appName,
type: app.type,
success: app.success ? 1 : 0,
failures: !app.success ? 1 : 0,
errors: app.calError ? [app.calError] : [],
warnings: app.calWarnings,
}));
if (reqAppsStatus === undefined) {
if (booking !== null) {
booking.appsStatus = resultStatus;
}
return resultStatus;
}
// From down here we can assume reqAppsStatus is not undefined anymore
// Other status exist, so this is the last booking of a series,
// proceeding to prepare the info for the event
const calcAppsStatus = reqAppsStatus.concat(resultStatus).reduce((prev, curr) => {
if (prev[curr.type]) {
prev[curr.type].success += curr.success;
prev[curr.type].errors = prev[curr.type].errors.concat(curr.errors);
prev[curr.type].warnings = prev[curr.type].warnings?.concat(curr.warnings || []);
} else {
prev[curr.type] = curr;
}
return prev;
}, {} as { [key: string]: AppsStatus });
resultStatus = Object.values(calcAppsStatus);
return resultStatus;
}
let videoCallUrl;
//this is the actual rescheduling logic
@@ -2219,7 +2321,7 @@ async function handler(
);
}
addVideoCallDataToEvt(originalRescheduledBooking.references);
addVideoCallDataToEvt(originalRescheduledBooking.references, evt);
//update original rescheduled booking (no seats event)
if (!eventType.seatsPerTimeSlot) {
@@ -2346,7 +2448,7 @@ async function handler(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
evt.appsStatus = handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
videoCallUrl =
metadata.hangoutLink ||
results[0].createdEvent?.url ||
@@ -2361,7 +2463,7 @@ async function handler(
: calendarResult?.updatedEvent?.iCalUID || undefined;
}
evt.appsStatus = handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
// If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient
if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) {
@@ -2516,7 +2618,7 @@ async function handler(
metadata.hangoutLink = results[0].createdEvent?.hangoutLink;
metadata.conferenceData = results[0].createdEvent?.conferenceData;
metadata.entryPoints = results[0].createdEvent?.entryPoints;
evt.appsStatus = handleAppsStatus(results, booking);
evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus);
videoCallUrl =
metadata.hangoutLink || organizerOrFirstDynamicGroupMemberDefaultLocationUrl || videoCallUrl;
@@ -2792,7 +2894,7 @@ async function handler(
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: evtWithMetadata,
isNotConfirmed: !isConfirmedByDefault,
isNotConfirmed: rescheduleUid ? false : !isConfirmedByDefault,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
hideBranding: !!eventType.owner?.hideBranding,
@@ -2911,49 +3013,3 @@ function handleCustomInputs(
}
});
}
const findBookingQuery = async (bookingId: number) => {
const foundBooking = await prisma.booking.findUnique({
where: {
id: bookingId,
},
select: {
uid: true,
location: true,
startTime: true,
endTime: true,
title: true,
description: true,
status: true,
responses: true,
user: {
select: {
name: true,
email: true,
timeZone: true,
username: true,
},
},
eventType: {
select: {
title: true,
description: true,
currency: true,
length: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
price: true,
},
},
},
});
// This should never happen but it's just typescript safe
if (!foundBooking) {
throw new Error("Internal Error. Couldn't find booking");
}
// Don't leak any sensitive data
return foundBooking;
};
@@ -850,7 +850,7 @@ describe("handleNewBooking", () => {
},
});
// expectWorkflowToBeTriggered({emails, organizer});
expectWorkflowToBeTriggered({ emails, organizer });
expectBookingRequestedEmails({
booker,
@@ -0,0 +1,796 @@
import type { Prisma, Attendee } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import type { TFunction } from "next-i18next";
import type short from "short-uuid";
import { uuid } from "short-uuid";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import EventManager from "@calcom/core/EventManager";
import { deleteMeeting } from "@calcom/core/videoClient";
import dayjs from "@calcom/dayjs";
import { sendRescheduledEmails, sendRescheduledSeatEmail, sendScheduledSeatsEmails } from "@calcom/emails";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import {
allowDisablingAttendeeConfirmationEmails,
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import type { getFullName } from "@calcom/features/form-builder/utils";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import { HttpError } from "@calcom/lib/http-error";
import { handlePayment } from "@calcom/lib/payment/handlePayment";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar";
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
import {
refreshCredentials,
addVideoCallDataToEvt,
createLoggerWithEventDetails,
handleAppsStatus,
findBookingQuery,
} from "./handleNewBooking";
import type {
Booking,
Invitee,
NewBookingEventType,
getAllCredentials,
OrganizerUser,
OriginalRescheduledBooking,
RescheduleReason,
NoEmail,
IsConfirmedByDefault,
AdditionalNotes,
ReqAppsStatus,
PaymentAppData,
IEventTypePaymentCredentialType,
SmsReminderNumber,
EventTypeId,
ReqBodyMetadata,
} from "./handleNewBooking";
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
/* Check if the original booking has no more attendees, if so delete the booking
and any calendar or video integrations */
const lastAttendeeDeleteBooking = async (
originalRescheduledBooking: OriginalRescheduledBooking,
filteredAttendees: Partial<Attendee>[],
originalBookingEvt?: CalendarEvent
) => {
let deletedReferences = false;
if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) {
const integrationsToDelete = [];
for (const reference of originalRescheduledBooking.references) {
if (reference.credentialId) {
const credential = await prisma.credential.findUnique({
where: {
id: reference.credentialId,
},
select: credentialForCalendarServiceSelect,
});
if (credential) {
if (reference.type.includes("_video")) {
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
}
if (reference.type.includes("_calendar") && originalBookingEvt) {
const calendar = await getCalendar(credential);
if (calendar) {
integrationsToDelete.push(
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
);
}
}
}
}
}
await Promise.all(integrationsToDelete).then(async () => {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
});
deletedReferences = true;
}
return deletedReferences;
};
const handleSeats = async ({
rescheduleUid,
reqBookingUid,
eventType,
evt,
invitee,
allCredentials,
organizerUser,
originalRescheduledBooking,
bookerEmail,
tAttendees,
bookingSeat,
reqUserId,
rescheduleReason,
reqBodyUser,
noEmail,
isConfirmedByDefault,
additionalNotes,
reqAppsStatus,
attendeeLanguage,
paymentAppData,
fullName,
smsReminderNumber,
eventTypeInfo,
uid,
eventTypeId,
reqBodyMetadata,
subscriberOptions,
eventTrigger,
}: {
rescheduleUid: string;
reqBookingUid: string;
eventType: NewBookingEventType;
evt: CalendarEvent;
invitee: Invitee;
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
organizerUser: OrganizerUser;
originalRescheduledBooking: OriginalRescheduledBooking;
bookerEmail: string;
tAttendees: TFunction;
bookingSeat: BookingSeat;
reqUserId: number | undefined;
rescheduleReason: RescheduleReason;
reqBodyUser: string | string[] | undefined;
noEmail: NoEmail;
isConfirmedByDefault: IsConfirmedByDefault;
additionalNotes: AdditionalNotes;
reqAppsStatus: ReqAppsStatus;
attendeeLanguage: string | null;
paymentAppData: PaymentAppData;
fullName: ReturnType<typeof getFullName>;
smsReminderNumber: SmsReminderNumber;
eventTypeInfo: EventTypeInfo;
uid: short.SUUID;
eventTypeId: EventTypeId;
reqBodyMetadata: ReqBodyMetadata;
subscriberOptions: GetSubscriberOptions;
eventTrigger: WebhookTriggerEvents;
}) => {
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
let resultBooking:
| (Partial<Booking> & {
appsStatus?: AppsStatus[];
seatReferenceUid?: string;
paymentUid?: string;
message?: string;
paymentId?: number;
})
| null = null;
const booking = await prisma.booking.findFirst({
where: {
OR: [
{
uid: rescheduleUid || reqBookingUid,
},
{
eventTypeId: eventType.id,
startTime: evt.startTime,
},
],
status: BookingStatus.ACCEPTED,
},
select: {
uid: true,
id: true,
attendees: { include: { bookingSeat: true } },
userId: true,
references: true,
startTime: true,
user: true,
status: true,
smsReminderNumber: true,
endTime: true,
scheduledJobs: true,
},
});
if (!booking) {
throw new HttpError({ statusCode: 404, message: "Could not find booking" });
}
// See if attendee is already signed up for timeslot
if (
booking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
dayjs.utc(booking.startTime).format() === evt.startTime
) {
throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." });
}
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
if (rescheduleUid) {
// See if the new date has a booking already
const newTimeSlotBooking = await prisma.booking.findFirst({
where: {
startTime: evt.startTime,
eventTypeId: eventType.id,
status: BookingStatus.ACCEPTED,
},
select: {
id: true,
uid: true,
attendees: {
include: {
bookingSeat: true,
},
},
},
});
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
if (!originalRescheduledBooking) {
// typescript isn't smart enough;
throw new Error("Internal Error.");
}
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
(filteredAttendees, attendee) => {
if (attendee.email === bookerEmail) {
return filteredAttendees; // skip current booker, as we know the language already.
}
filteredAttendees.push({
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
});
return filteredAttendees;
},
[] as Person[]
);
// If original booking has video reference we need to add the videoCallData to the new evt
const videoReference = originalRescheduledBooking.references.find((reference) =>
reference.type.includes("_video")
);
const originalBookingEvt = {
...evt,
title: originalRescheduledBooking.title,
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
attendees: updatedBookingAttendees,
// If the location is a video integration then include the videoCallData
...(videoReference && {
videoCallData: {
type: videoReference.type,
id: videoReference.meetingId,
password: videoReference.meetingPassword,
url: videoReference.meetingUrl,
},
}),
};
if (!bookingSeat) {
// if no bookingSeat is given and the userId != owner, 401.
// TODO: Next step; Evaluate ownership, what about teams?
if (booking.user?.id !== reqUserId) {
throw new HttpError({ statusCode: 401 });
}
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
};
});
// If owner reschedules the event we want to update the entire booking
// Also if owner is rescheduling there should be no bookingSeat
// If there is no booking during the new time slot then update the current booking to the new date
if (!newTimeSlotBooking) {
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
where: {
id: booking.id,
},
data: {
startTime: evt.startTime,
endTime: evt.endTime,
cancellationReason: rescheduleReason,
},
include: {
user: true,
references: true,
payment: true,
attendees: true,
},
});
evt = addVideoCallDataToEvt(newBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
// @NOTE: This code is duplicated and should be moved to a function
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
evt.description = eventType.description;
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
if (results.length > 0 && results.some((res) => !res.success)) {
const error = {
errorCode: "BookingReschedulingMeetingFailed",
message: "Booking Rescheduling failed",
};
loggerWithEventDetails.error(
`Booking ${organizerUser.name} failed`,
JSON.stringify({ error, results })
);
} else {
const metadata: AdditionalInformation = {};
if (results.length) {
// TODO: Handle created event metadata more elegantly
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
? results[0].updatedEvent
: [results[0].updatedEvent];
if (updatedEvent) {
metadata.hangoutLink = updatedEvent.hangoutLink;
metadata.conferenceData = updatedEvent.conferenceData;
metadata.entryPoints = updatedEvent.entryPoints;
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
}
}
}
if (noEmail !== true && isConfirmedByDefault) {
const copyEvent = cloneDeep(evt);
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
const foundBooking = await findBookingQuery(newBooking.id);
resultBooking = { ...foundBooking, appsStatus: newBooking.appsStatus };
} else {
// Merge two bookings together
const attendeesToMove = [],
attendeesToDelete = [];
for (const attendee of booking.attendees) {
// If the attendee already exists on the new booking then delete the attendee record of the old booking
if (
newTimeSlotBooking.attendees.some(
(newBookingAttendee) => newBookingAttendee.email === attendee.email
)
) {
attendeesToDelete.push(attendee.id);
// If the attendee does not exist on the new booking then move that attendee record to the new booking
} else {
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
}
}
// Confirm that the new event will have enough available seats
if (
!eventType.seatsPerTimeSlot ||
attendeesToMove.length +
newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
eventType.seatsPerTimeSlot
) {
throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" });
}
const moveAttendeeCalls = [];
for (const attendeeToMove of attendeesToMove) {
moveAttendeeCalls.push(
prisma.attendee.update({
where: {
id: attendeeToMove.id,
},
data: {
bookingId: newTimeSlotBooking.id,
bookingSeat: {
upsert: {
create: {
referenceUid: uuid(),
bookingId: newTimeSlotBooking.id,
},
update: {
bookingId: newTimeSlotBooking.id,
},
},
},
},
})
);
}
await Promise.all([
...moveAttendeeCalls,
// Delete any attendees that are already a part of that new time slot booking
prisma.attendee.deleteMany({
where: {
id: {
in: attendeesToDelete,
},
},
}),
]);
const updatedNewBooking = await prisma.booking.findUnique({
where: {
id: newTimeSlotBooking.id,
},
include: {
attendees: true,
references: true,
},
});
if (!updatedNewBooking) {
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
}
// Update the evt object with the new attendees
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
const evtAttendee = {
...attendee,
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
};
return evtAttendee;
});
evt.attendees = updatedBookingAttendees;
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
if (noEmail !== true && isConfirmedByDefault) {
// TODO send reschedule emails to attendees of the old booking
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
await sendRescheduledEmails({
...copyEvent,
additionalNotes, // Resets back to the additionalNote input and not the override value
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
});
}
// Update the old booking with the cancelled status
await prisma.booking.update({
where: {
id: booking.id,
},
data: {
status: BookingStatus.CANCELLED,
},
});
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking };
}
}
// seatAttendee is null when the organizer is rescheduling.
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
if (seatAttendee) {
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
// If there is no booking then remove the attendee from the old booking and create a new one
if (!newTimeSlotBooking) {
await prisma.attendee.delete({
where: {
id: seatAttendee?.id,
},
});
// Update the original calendar event by removing the attendee that is rescheduling
if (originalBookingEvt && originalRescheduledBooking) {
// Event would probably be deleted so we first check than instead of updating references
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
const deletedReference = await lastAttendeeDeleteBooking(
originalRescheduledBooking,
filteredAttendees,
originalBookingEvt
);
if (!deletedReference) {
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
}
}
// We don't want to trigger rescheduling logic of the original booking
originalRescheduledBooking = null;
return null;
}
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
if (seatAttendee?.id && bookingSeat?.id) {
await Promise.all([
await prisma.attendee.update({
where: {
id: seatAttendee.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
await prisma.bookingSeat.update({
where: {
id: bookingSeat.id,
},
data: {
bookingId: newTimeSlotBooking.id,
},
}),
]);
}
const copyEvent = cloneDeep(evt);
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
const results = updateManager.results;
const calendarResult = results.find((result) => result.type.includes("_calendar"));
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
return attendee.email !== bookerEmail;
});
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
}
} else {
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
const bookingAttendees = booking.attendees.map((attendee) => {
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
});
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= booking.attendees.length) {
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
}
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
if (videoCallReference) {
evt.videoCallData = {
type: videoCallReference.type,
id: videoCallReference.meetingId,
password: videoCallReference?.meetingPassword,
url: videoCallReference.meetingUrl,
};
}
const attendeeUniqueId = uuid();
await prisma.booking.update({
where: {
uid: reqBookingUid,
},
include: {
attendees: true,
},
data: {
attendees: {
create: {
email: invitee[0].email,
name: invitee[0].name,
timeZone: invitee[0].timeZone,
locale: invitee[0].language.locale,
bookingSeat: {
create: {
referenceUid: attendeeUniqueId,
data: {
description: additionalNotes,
},
booking: {
connect: {
id: booking.id,
},
},
},
},
},
},
...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
},
});
evt.attendeeSeatId = attendeeUniqueId;
const newSeat = booking.attendees.length !== 0;
/**
* Remember objects are passed into functions as references
* so if you modify it in a inner function it will be modified in the outer function
* deep cloning evt to avoid this
*/
if (!evt?.uid) {
evt.uid = booking?.uid ?? null;
}
const copyEvent = cloneDeep(evt);
copyEvent.uid = booking.uid;
if (noEmail !== true) {
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
if (eventType.workflows) {
isHostConfirmationEmailsDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
isAttendeeConfirmationEmailDisabled =
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
if (isHostConfirmationEmailsDisabled) {
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
}
if (isAttendeeConfirmationEmailDisabled) {
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
}
}
await sendScheduledSeatsEmails(
copyEvent,
invitee[0],
newSeat,
!!eventType.seatsShowAttendees,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled
);
}
const credentials = await refreshCredentials(allCredentials);
const eventManager = new EventManager({ ...organizerUser, credentials });
await eventManager.updateCalendarAttendees(evt, booking);
const foundBooking = await findBookingQuery(booking.id);
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) {
const credentialPaymentAppCategories = await prisma.credential.findMany({
where: {
...(paymentAppData.credentialId
? { id: paymentAppData.credentialId }
: { userId: organizerUser.id }),
app: {
categories: {
hasSome: ["payment"],
},
},
},
select: {
key: true,
appId: true,
app: {
select: {
categories: true,
dirName: true,
},
},
},
});
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
return credential.appId === paymentAppData.appId;
});
if (!eventTypePaymentAppCredential) {
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
}
if (!eventTypePaymentAppCredential?.appId) {
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
}
const payment = await handlePayment(
evt,
eventType,
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
booking,
fullName,
bookerEmail
);
resultBooking = { ...foundBooking };
resultBooking["message"] = "Payment required";
resultBooking["paymentUid"] = payment?.uid;
resultBooking["id"] = payment?.id;
} else {
resultBooking = { ...foundBooking };
}
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
}
// Here we should handle every after action that needs to be done after booking creation
// Obtain event metadata that includes videoCallUrl
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
try {
await scheduleWorkflowReminders({
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
isNotConfirmed: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
emailAttendeeSendToOverride: bookerEmail,
seatReferenceUid: evt.attendeeSeatId,
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
});
} catch (error) {
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
}
const webhookData = {
...evt,
...eventTypeInfo,
uid: resultBooking?.uid || uid,
bookingId: booking?.id,
rescheduleUid,
rescheduleStartTime: originalRescheduledBooking?.startTime
? dayjs(originalRescheduledBooking?.startTime).utc().format()
: undefined,
rescheduleEndTime: originalRescheduledBooking?.endTime
? dayjs(originalRescheduledBooking?.endTime).utc().format()
: undefined,
metadata: { ...metadata, ...reqBodyMetadata },
eventTypeId,
status: "ACCEPTED",
smsReminderNumber: booking?.smsReminderNumber || undefined,
};
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
return resultBooking;
};
export default handleSeats;
+1 -1
View File
@@ -74,7 +74,7 @@ export const Day = ({
<span
className={classNames(
"bg-brand-default absolute left-1/2 top-1/2 flex h-[5px] w-[5px] -translate-x-1/2 translate-y-[8px] items-center justify-center rounded-full align-middle sm:translate-y-[12px]",
active && "invert"
active && "bg-brand-accent"
)}>
<span className="sr-only">{t("today")}</span>
</span>
@@ -1,3 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -1,3 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { Controller, useForm } from "react-hook-form";

Some files were not shown because too many files have changed in this diff Show More