Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f637d94c | ||
|
|
e5eb121ab7 | ||
|
|
6612926aa5 | ||
|
|
93dc1247f1 | ||
|
|
24607873c0 | ||
|
|
8b37d91108 | ||
|
|
22b0644700 | ||
|
|
404a7c1550 | ||
|
|
19a2cd5ae4 | ||
|
|
0798e2940a | ||
|
|
b1bf94aa58 | ||
|
|
0a921c2929 | ||
|
|
67c318e527 | ||
|
|
41b8d2ab1a | ||
|
|
57124b48fe | ||
|
|
b76a2a4019 | ||
|
|
a28b9cacd2 | ||
|
|
abd90f6af8 | ||
|
|
0bdc45a1a5 | ||
|
|
65d9704f2b | ||
|
|
d8ba783369 |
@@ -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({
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.6.4",
|
||||
"version": "3.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { GetStaticPropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
+28
-27
@@ -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))) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user