Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1976295c | ||
|
|
e9b1fa1f50 | ||
|
|
80080dda7a | ||
|
|
27c25e1f95 | ||
|
|
858be0f5d8 | ||
|
|
68d0090f50 | ||
|
|
a43fa13b11 | ||
|
|
2bf6da30b3 | ||
|
|
d85e0b51e4 | ||
|
|
55a317d677 | ||
|
|
5fff8fc58c | ||
|
|
0d68767d0a | ||
|
|
2051e87436 | ||
|
|
77a157a12b | ||
|
|
c4f25f2c44 | ||
|
|
d967facb63 | ||
|
|
c6dad39fb0 | ||
|
|
eec7f4d7d4 | ||
|
|
95d5824ba9 | ||
|
|
5f95a4d041 | ||
|
|
8e97444129 | ||
|
|
8d8264b5f5 | ||
|
|
2ad81221d0 | ||
|
|
3d1ac4523f | ||
|
|
b38807cb0f | ||
|
|
11fb387370 | ||
|
|
1546f9115d | ||
|
|
40ecd5dd9d | ||
|
|
ac6ac14a23 | ||
|
|
c6534160e4 | ||
|
|
aa2db562b8 | ||
|
|
b7ed314be2 | ||
|
|
d102268b33 | ||
|
|
f4397d6593 | ||
|
|
192603a1fa | ||
|
|
a3d969680f | ||
|
|
adc3b3aec6 | ||
|
|
92286e2453 | ||
|
|
51b2a15cb0 | ||
|
|
fa9a099dd6 | ||
|
|
83ceb46cca |
@@ -74,6 +74,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
theme: true,
|
||||
appTheme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
hideBranding: true,
|
||||
@@ -100,6 +101,7 @@ const schemaUserEditParams = z.object({
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
appTheme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
defaultScheduleId: z
|
||||
.number()
|
||||
@@ -122,6 +124,7 @@ const schemaUserCreateParams = z.object({
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
appTheme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
defaultScheduleId: z
|
||||
.number()
|
||||
@@ -159,6 +162,7 @@ export const schemaUserReadPublic = User.pick({
|
||||
weekStart: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
appTheme: true,
|
||||
theme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
@@ -23,13 +24,22 @@ type Props = {
|
||||
theme: string | null;
|
||||
allRemainingBookings: boolean;
|
||||
seatReferenceUid?: string;
|
||||
bookingCancelledEventProps: {
|
||||
booking: unknown;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { booking, allRemainingBookings, seatReferenceUid } = props;
|
||||
const { booking, allRemainingBookings, seatReferenceUid, bookingCancelledEventProps } = props;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const telemetry = useTelemetry();
|
||||
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
|
||||
@@ -97,8 +107,17 @@ export default function CancelBooking(props: Props) {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const bookingWithCancellationReason = {
|
||||
...(bookingCancelledEventProps.booking as object),
|
||||
cancellationReason,
|
||||
} as unknown;
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
// tested by apps/web/playwright/booking-pages.e2e.ts
|
||||
sdkActionManager?.fire("bookingCancelled", {
|
||||
...bookingCancelledEventProps,
|
||||
booking: bookingWithCancellationReason,
|
||||
});
|
||||
router.refresh();
|
||||
} else {
|
||||
setLoading(false);
|
||||
|
||||
@@ -360,10 +360,12 @@ export const EventLimitsTab = () => {
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(bool) =>
|
||||
// formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED", { shouldDirty: true })
|
||||
onChange(bool ? "ROLLING" : "UNLIMITED")
|
||||
}>
|
||||
onCheckedChange={(bool) => {
|
||||
if (bool && !formMethods.getValues("periodDays")) {
|
||||
formMethods.setValue("periodDays", 30, { shouldDirty: true });
|
||||
}
|
||||
return onChange(bool ? "ROLLING" : "UNLIMITED");
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<RadioGroup.Root
|
||||
value={watchPeriodType}
|
||||
|
||||
@@ -318,7 +318,7 @@ export const EventSetupTab = (
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
errors={formMethods.formState.errors.locations?.[index]}
|
||||
errors={formMethods.formState.errors?.locations?.[index]}
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 ml-6 text-sm"
|
||||
as="div"
|
||||
|
||||
@@ -37,7 +37,7 @@ const UserProfile = () => {
|
||||
if (context.avatar) {
|
||||
showToast(t("your_user_profile_updated_successfully"), "success");
|
||||
await utils.viewer.me.refetch();
|
||||
} else {
|
||||
} else
|
||||
try {
|
||||
if (eventTypes?.length === 0) {
|
||||
await Promise.all(
|
||||
@@ -50,9 +50,11 @@ const UserProfile = () => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await utils.viewer.me.refetch();
|
||||
router.push("/");
|
||||
}
|
||||
await utils.viewer.me.refetch();
|
||||
const redirectUrl = localStorage.getItem("onBoardingRedirect");
|
||||
localStorage.removeItem("onBoardingRedirect");
|
||||
|
||||
redirectUrl ? router.push(redirectUrl) : router.push("/");
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("problem_saving_user_profile"), "error");
|
||||
|
||||
@@ -113,7 +113,7 @@ const enum ThemeSupport {
|
||||
// e.g. Login Page
|
||||
None = "none",
|
||||
// Entire App except Booking Pages
|
||||
App = "systemOnly",
|
||||
App = "userConfigured",
|
||||
// Booking Pages(including Routing Forms)
|
||||
Booking = "userConfigured",
|
||||
}
|
||||
|
||||
@@ -127,9 +127,9 @@ const enum ThemeSupport {
|
||||
// e.g. Login Page
|
||||
None = "none",
|
||||
// Entire App except Booking Pages
|
||||
App = "systemOnly",
|
||||
App = "appConfigured",
|
||||
// Booking Pages(including Routing Forms)
|
||||
Booking = "userConfigured",
|
||||
Booking = "bookingConfigured",
|
||||
}
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
@@ -144,8 +144,10 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
const embedNamespace = getEmbedNamespace(props.router.query);
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
const themeProviderProps = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
|
||||
|
||||
return (
|
||||
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace })}>
|
||||
<ThemeProvider {...themeProviderProps}>
|
||||
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
|
||||
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
|
||||
// If feature flag is disabled, return not found on getServerSideProps
|
||||
export const getServerSideProps = async () => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const insightsEnabled = await getFeatureFlag(prisma, "insights");
|
||||
|
||||
if (flags.insights === false) {
|
||||
if (!insightsEnabled) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const organizations = await getFeatureFlag(prisma, "organizations");
|
||||
// Check if organizations are enabled
|
||||
if (flags["organizations"] !== true) {
|
||||
if (!organizations) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -24,11 +24,22 @@ const querySchema = z.object({
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const signupDisabled = await getFeatureFlag(prisma, "disable-signup");
|
||||
const ssr = await ssrInit(ctx);
|
||||
const token = z.string().optional().parse(ctx.query.token);
|
||||
const redirectUrlData = z
|
||||
.string()
|
||||
.refine((value) => value.startsWith(WEBAPP_URL), {
|
||||
params: (value: string) => ({ value }),
|
||||
message: "Redirect URL must start with 'cal.com'",
|
||||
})
|
||||
.optional()
|
||||
.safeParse(ctx.query.redirect);
|
||||
|
||||
const redirectUrl = redirectUrlData.success && redirectUrlData.data ? redirectUrlData.data : null;
|
||||
|
||||
const props = {
|
||||
redirectUrl,
|
||||
isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED,
|
||||
isSAMLLoginEnabled,
|
||||
trpcState: ssr.dehydrate(),
|
||||
@@ -38,7 +49,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
// username + email prepopulated from query params
|
||||
const { username: preFillusername, email: prefilEmail } = querySchema.parse(ctx.query);
|
||||
|
||||
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || flags["disable-signup"]) {
|
||||
if ((process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" && !token) || signupDisabled) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
@@ -37,12 +37,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const isOrganizationFeatureEnabled = flags["organizations"];
|
||||
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
|
||||
|
||||
log.debug("getServerSideProps", {
|
||||
isOrgProfile,
|
||||
isOrganizationFeatureEnabled,
|
||||
isOrganizationFeatureEnabled: organizationsEnabled,
|
||||
isValidOrgDomain,
|
||||
currentOrgDomain,
|
||||
});
|
||||
@@ -74,7 +73,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
if (
|
||||
(!isValidOrgDomain && team?.parent) ||
|
||||
(!isValidOrgDomain && !!metadata?.isOrganization) ||
|
||||
!isOrganizationFeatureEnabled
|
||||
!organizationsEnabled
|
||||
) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { getEventName } from "@calcom/core/event";
|
||||
import type { ConfigType } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
sdkActionManager,
|
||||
useEmbedNonStylesConfig,
|
||||
useIsBackgroundTransparent,
|
||||
useIsEmbed,
|
||||
@@ -193,22 +192,6 @@ export default function Success(props: PageProps) {
|
||||
}, [telemetry]); */
|
||||
|
||||
useEffect(() => {
|
||||
const users = eventType.users;
|
||||
if (!sdkActionManager) return;
|
||||
// TODO: We should probably make it consistent with Webhook payload. Some data is not available here, as and when requirement comes we can add
|
||||
sdkActionManager.fire("bookingSuccessful", {
|
||||
booking: bookingInfo,
|
||||
eventType,
|
||||
date: date.toString(),
|
||||
duration: calculatedDuration,
|
||||
organizer: {
|
||||
name: users[0].name || "Nameless",
|
||||
email: bookingInfo?.userPrimaryEmail || users[0].email || "Email-less",
|
||||
timeZone: users[0].timeZone,
|
||||
},
|
||||
confirmed: !needsConfirmation,
|
||||
// TODO: Add payment details
|
||||
});
|
||||
setDate(
|
||||
date.tz(localStorage.getItem("timeOption.preferredTimeZone") || dayjs.tz.guess() || "Europe/London")
|
||||
);
|
||||
@@ -294,6 +277,16 @@ export default function Success(props: PageProps) {
|
||||
const providerName = guessEventLocationType(location)?.label;
|
||||
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
|
||||
|
||||
const bookingCancelledEventProps = {
|
||||
booking: bookingInfo,
|
||||
organizer: {
|
||||
name: bookingInfo?.user?.name || "Nameless",
|
||||
email: bookingInfo?.userPrimaryEmail || bookingInfo?.user?.email || "Email-less",
|
||||
timeZone: bookingInfo?.user?.timeZone,
|
||||
},
|
||||
eventType,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||
{!isEmbed && (
|
||||
@@ -610,6 +603,7 @@ export default function Success(props: PageProps) {
|
||||
theme={isSuccessBookingPage ? props.profile.theme : "light"}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
seatReferenceUid={seatReferenceUid}
|
||||
bookingCancelledEventProps={bookingCancelledEventProps}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
|
||||
@@ -266,7 +266,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
beforeEventBuffer: eventType.beforeEventBuffer,
|
||||
eventName: eventType.eventName || "",
|
||||
scheduleName: eventType.scheduleName,
|
||||
periodDays: eventType.periodDays || 30,
|
||||
periodDays: eventType.periodDays,
|
||||
requiresBookerEmailVerification: eventType.requiresBookerEmailVerification,
|
||||
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
|
||||
seatsShowAttendees: eventType.seatsShowAttendees,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.8.2",
|
||||
"version": "3.8.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -95,6 +95,7 @@ const IntegrationsContainer = ({
|
||||
className="mb-6"
|
||||
actions={
|
||||
<Button
|
||||
data-testid="add-apps"
|
||||
href={variant ? `/apps/categories/${variant}` : "/apps"}
|
||||
color="secondary"
|
||||
StartIcon={Plus}>
|
||||
|
||||
@@ -11,8 +11,10 @@ import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
|
||||
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
|
||||
import useGetBrandingColours from "@calcom/lib/getBrandColours";
|
||||
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
SkeletonText,
|
||||
SettingsToggle,
|
||||
UpgradeTeamsBadge,
|
||||
useCalcomTheme,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
@@ -41,10 +44,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
</div>
|
||||
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
|
||||
<div className="[&>*]:bg-emphasis flex w-full items-center justify-center gap-x-2 [&>*]:animate-pulse">
|
||||
<div className="h-32 flex-1 rounded-md p-5" />
|
||||
<div className="h-32 flex-1 rounded-md p-5" />
|
||||
<div className="h-32 flex-1 rounded-md p-5" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SkeletonText className="h-8 w-1/3" />
|
||||
@@ -62,6 +65,26 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
|
||||
);
|
||||
};
|
||||
|
||||
const useBrandColors = (
|
||||
currentTheme: string | null,
|
||||
{
|
||||
brandColor,
|
||||
darkBrandColor,
|
||||
}: {
|
||||
brandColor?: string | null;
|
||||
darkBrandColor?: string | null;
|
||||
}
|
||||
): void => {
|
||||
const brandTheme = useGetBrandingColours({
|
||||
lightVal: brandColor,
|
||||
darkVal: darkBrandColor,
|
||||
});
|
||||
const selectedTheme = currentTheme ? brandTheme[currentTheme as "light" | "dark"] : {};
|
||||
useCalcomTheme({
|
||||
root: selectedTheme,
|
||||
});
|
||||
};
|
||||
|
||||
const AppearanceView = ({
|
||||
user,
|
||||
hasPaidPlan,
|
||||
@@ -77,6 +100,22 @@ const AppearanceView = ({
|
||||
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
|
||||
);
|
||||
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
|
||||
useTheme(user?.appTheme);
|
||||
useBrandColors(user?.appTheme ?? null, {
|
||||
brandColor: user?.brandColor,
|
||||
darkBrandColor: user?.darkBrandColor,
|
||||
});
|
||||
|
||||
const userAppThemeFormMethods = useForm({
|
||||
defaultValues: {
|
||||
appTheme: user.appTheme,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
formState: { isSubmitting: isUserAppThemeSubmitting, isDirty: isUserAppThemeDirty },
|
||||
reset: resetUserAppThemeReset,
|
||||
} = userAppThemeFormMethods;
|
||||
|
||||
const userThemeFormMethods = useForm({
|
||||
defaultValues: {
|
||||
@@ -131,6 +170,7 @@ const AppearanceView = ({
|
||||
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
|
||||
resetBookerLayoutThemeReset({ metadata: data.metadata });
|
||||
resetUserThemeReset({ theme: data.theme });
|
||||
resetUserAppThemeReset({ appTheme: data.appTheme });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message) {
|
||||
@@ -147,6 +187,56 @@ const AppearanceView = ({
|
||||
return (
|
||||
<div>
|
||||
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
|
||||
<div>
|
||||
<p className="text-default text-base font-semibold">{t("app_theme")}</p>
|
||||
<p className="text-default">{t("app_theme_applies_note")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form
|
||||
form={userAppThemeFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
mutation.mutate({
|
||||
appTheme: values.appTheme ?? null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={undefined}
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.appTheme === null}
|
||||
register={userAppThemeFormMethods.register}
|
||||
fieldName="appTheme"
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="light"
|
||||
value="light"
|
||||
label={t("light")}
|
||||
defaultChecked={user.appTheme === "light"}
|
||||
register={userAppThemeFormMethods.register}
|
||||
fieldName="appTheme"
|
||||
/>
|
||||
<ThemeLabel
|
||||
variant="dark"
|
||||
value="dark"
|
||||
label={t("dark")}
|
||||
defaultChecked={user.appTheme === "dark"}
|
||||
register={userAppThemeFormMethods.register}
|
||||
fieldName="appTheme"
|
||||
/>
|
||||
</div>
|
||||
<SectionBottomActions className="mb-6" align="end">
|
||||
<Button
|
||||
disabled={isUserAppThemeSubmitting || !isUserAppThemeDirty}
|
||||
type="submit"
|
||||
data-testid="update-app-theme-btn"
|
||||
color="primary">
|
||||
{t("update")}
|
||||
</Button>
|
||||
</SectionBottomActions>
|
||||
</Form>
|
||||
|
||||
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
|
||||
<div>
|
||||
<p className="text-default text-base font-semibold">{t("theme")}</p>
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function Signup({
|
||||
isGoogleLoginEnabled,
|
||||
isSAMLLoginEnabled,
|
||||
orgAutoAcceptEmail,
|
||||
redirectUrl,
|
||||
}: SignupProps) {
|
||||
const [premiumUsername, setPremiumUsername] = useState(false);
|
||||
const [usernameTaken, setUsernameTaken] = useState(false);
|
||||
@@ -180,6 +181,12 @@ export default function Signup({
|
||||
formState: { isSubmitting, errors, isSubmitSuccessful },
|
||||
} = formMethods;
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectUrl) {
|
||||
localStorage.setItem("onBoardingRedirect", redirectUrl);
|
||||
}
|
||||
}, [redirectUrl]);
|
||||
|
||||
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
|
||||
|
||||
const handleErrorsAndStripe = async (resp: Response) => {
|
||||
|
||||
@@ -123,7 +123,7 @@ function TeamPage({
|
||||
(mem) => mem.subteams?.includes(ch.slug) && mem.accepted
|
||||
).length;
|
||||
return (
|
||||
<li key={i} className="hover:bg-muted w-full">
|
||||
<li key={i} className="hover:bg-muted w-full rounded-md">
|
||||
<Link href={`/${ch.slug}`} className="flex items-center justify-between">
|
||||
<div className="flex items-center px-5 py-5">
|
||||
<div className="ms-3 inline-block truncate">
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { loginUser } from "../../fixtures/regularBookings";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"];
|
||||
|
||||
test.describe("Check analytics Apps", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/apps/");
|
||||
});
|
||||
|
||||
test("Check analytics Apps", async ({ appsPage, page }) => {
|
||||
await appsPage.goToAppsCategory("analytics");
|
||||
await appsPage.installApp("fathom");
|
||||
await appsPage.goBackToAppsPage();
|
||||
await appsPage.installApp("matomo");
|
||||
await appsPage.goBackToAppsPage();
|
||||
await appsPage.installApp("plausible");
|
||||
await appsPage.goBackToAppsPage();
|
||||
await appsPage.installApp("ga4");
|
||||
await appsPage.goBackToAppsPage();
|
||||
await appsPage.installApp("gtm");
|
||||
await appsPage.goBackToAppsPage();
|
||||
await appsPage.installApp("metapixel");
|
||||
await appsPage.goBackToAppsPage();
|
||||
await page.goto("/event-types");
|
||||
await appsPage.goToEventType("30 min");
|
||||
await appsPage.goToAppsTab();
|
||||
await appsPage.verifyAppsInfo(0);
|
||||
for (const app of ALL_APPS) {
|
||||
await appsPage.activeApp(app);
|
||||
}
|
||||
await appsPage.verifyAppsInfo(6);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,35 @@ testBothFutureAndLegacyRoutes.describe("pro user", () => {
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("can book an unconfirmed event multiple times", async ({ page, users }) => {
|
||||
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
const pageUrl = page.url();
|
||||
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
// go back to the booking page to re-book.
|
||||
await page.goto(pageUrl);
|
||||
await bookTimeSlot(page, { email: "test2@example.com" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("cannot book an unconfirmed event multiple times with the same email", async ({ page, users }) => {
|
||||
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
const pageUrl = page.url();
|
||||
|
||||
await bookTimeSlot(page);
|
||||
// go back to the booking page to re-book.
|
||||
await page.goto(pageUrl);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.getByText("Could not book the meeting.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can book with multiple guests", async ({ page, users }) => {
|
||||
const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export function createAppsFixture(page: Page) {
|
||||
return {
|
||||
goToAppsCategory: async (category: string) => {
|
||||
await page.getByTestId(`app-store-category-${category}`).nth(1).click();
|
||||
await page.goto("apps/categories/analytics");
|
||||
},
|
||||
installApp: async (app: string) => {
|
||||
await page.getByTestId(`app-store-app-card-${app}`).click();
|
||||
await page.getByTestId("install-app-button").click();
|
||||
},
|
||||
goBackToAppsPage: async () => {
|
||||
await page.getByTestId("add-apps").click();
|
||||
},
|
||||
goToEventType: async (eventType: string) => {
|
||||
await page.getByRole("link", { name: eventType }).click();
|
||||
},
|
||||
goToAppsTab: async () => {
|
||||
await page.getByTestId("vertical-tab-apps").click();
|
||||
},
|
||||
activeApp: async (app: string) => {
|
||||
await page.getByTestId(`${app}-app-switch`).click();
|
||||
},
|
||||
verifyAppsInfo: async (activeApps: number) => {
|
||||
await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { test as base } from "@playwright/test";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { ExpectedUrlDetails } from "../../../../playwright.config";
|
||||
import { createAppsFixture } from "../fixtures/apps";
|
||||
import { createBookingsFixture } from "../fixtures/bookings";
|
||||
import { createEmailsFixture } from "../fixtures/emails";
|
||||
import { createEmbedsFixture } from "../fixtures/embeds";
|
||||
@@ -32,6 +33,7 @@ export interface Fixtures {
|
||||
workflowPage: ReturnType<typeof createWorkflowPageFixture>;
|
||||
features: ReturnType<typeof createFeatureFixture>;
|
||||
eventTypePage: ReturnType<typeof createEventTypeFixture>;
|
||||
appsPage: ReturnType<typeof createAppsFixture>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -104,4 +106,8 @@ export const test = base.extend<Fixtures>({
|
||||
const eventTypePage = createEventTypeFixture(page);
|
||||
await use(eventTypePage);
|
||||
},
|
||||
appsPage: async ({ page }, use) => {
|
||||
const appsPage = createAppsFixture(page);
|
||||
await use(appsPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
"hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.",
|
||||
"danger_zone": "Danger zone",
|
||||
"account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.",
|
||||
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone",
|
||||
"team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"cancel_all_remaining": "Cancel all remaining",
|
||||
@@ -1473,8 +1473,10 @@
|
||||
"route_to_the_right_person": "Route to the right person based on the answers to your form",
|
||||
"form_description": "Create your form to route a booker",
|
||||
"copy_link_to_form": "Copy link to form",
|
||||
"theme": "Theme",
|
||||
"theme": "Booking Page Theme",
|
||||
"theme_applies_note": "This only applies to your public booking pages",
|
||||
"app_theme": "Dashboard Theme",
|
||||
"app_theme_applies_note": "This only applies to your logged in dashboard",
|
||||
"theme_system": "System default",
|
||||
"add_a_team": "Add a team",
|
||||
"add_webhook_description": "Receive meeting data in real-time when something happens in {{appName}}",
|
||||
|
||||
@@ -893,7 +893,7 @@
|
||||
"number_apps_other": "{{count}} aplicaciones",
|
||||
"trending_apps": "Aplicaciones populares",
|
||||
"most_popular": "Más popular",
|
||||
"installed_apps": "Aplicaciones instaladas",
|
||||
"installed_apps": "Apps instaladas",
|
||||
"free_to_use_apps": "Gratis",
|
||||
"no_category_apps": "No hay aplicaciones de {{category}}",
|
||||
"all_apps": "Todas las aplicaciones",
|
||||
|
||||
@@ -1475,6 +1475,8 @@
|
||||
"copy_link_to_form": "להעתיק את הקישור לטופס",
|
||||
"theme": "ערכת נושא",
|
||||
"theme_applies_note": "חלה רק על דפים של הזמנות ציבוריות",
|
||||
"app_theme": "ערכת עיצוב ללוח בקרה",
|
||||
"app_theme_applies_note": "חל רק על לוח הבקרה שנכנסת אליו",
|
||||
"theme_system": "ברירת המחדל של המערכת",
|
||||
"add_a_team": "הוספת צוות",
|
||||
"add_webhook_description": "לקבל נתוני פגישה בזמן אמת כשמשהו קורה ב-{{appName}}",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"identity_provider": "Azonosítás szolgáltató",
|
||||
"trial_days_left": "Még $t(day, {\"count\": {{days}} }) maradt a PRO próbaidőszakodból",
|
||||
"day_one": "{{count}} nap",
|
||||
"day_other": "{{count}} nap",
|
||||
@@ -7,6 +8,7 @@
|
||||
"upgrade_now": "Frissíts most",
|
||||
"accept_invitation": "Meghívó elfogadása",
|
||||
"have_any_questions": "Van kérdésed? Itt vagyunk hogy segítsünk.",
|
||||
"verify_email_email_button": "Email igazolása",
|
||||
"organizer": "Szervező",
|
||||
"rejection_reason": "Lemondás oka",
|
||||
"help": "Segítség",
|
||||
@@ -160,7 +162,7 @@
|
||||
"yearly_one": "év",
|
||||
"yearly_other": "év",
|
||||
"name": "Név",
|
||||
"logo": "Logo",
|
||||
"logo": "Logó",
|
||||
"error": "Hiba",
|
||||
"type": "Típus",
|
||||
"edit": "Szerkesztés",
|
||||
|
||||
@@ -1447,6 +1447,8 @@
|
||||
"copy_link_to_form": "将链接复制到表格",
|
||||
"theme": "主题",
|
||||
"theme_applies_note": "这仅适用于您的公开预约页面",
|
||||
"app_theme": "仪表板主题",
|
||||
"app_theme_applies_note": "这仅在您登录仪表板时适用",
|
||||
"theme_system": "系统默认设置",
|
||||
"add_a_team": "添加团队",
|
||||
"add_webhook_description": "当 {{appName}} 上有活动时实时接收会议数据",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Only deploy to production
|
||||
if [ "$VERCEL_ENV" != "production" ]; then exit 0; fi
|
||||
|
||||
checkRoute () {
|
||||
if [ "$1" != '1' ]; then rm -rf $2; fi
|
||||
}
|
||||
|
||||
# These conditionals are used to remove directories from the build that are not needed in production
|
||||
# This is to reduce the size of the build and prevent OOM errors
|
||||
checkRoute "$APP_ROUTER_EVENT_TYPES_ENABLED" app/future/event-types
|
||||
checkRoute "$APP_ROUTER_SETTINGS_ADMIN_ENABLED" app/future/settings/admin
|
||||
checkRoute "$APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED" app/future/apps/installed
|
||||
checkRoute "$APP_ROUTER_APPS_SLUG_ENABLED" app/future/apps/\[slug\]
|
||||
checkRoute "$APP_ROUTER_APPS_SLUG_SETUP_ENABLED" app/future/apps/\[slug\]/setup
|
||||
checkRoute "$APP_ROUTER_APPS_CATEGORIES_ENABLED" app/future/apps/categories
|
||||
checkRoute "$APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED" app/future/apps/categories/\[category\]
|
||||
checkRoute "$APP_ROUTER_WORKFLOWS_ENABLED" app/future/workflows
|
||||
checkRoute "$APP_ROUTER_SETTINGS_TEAMS_ENABLED" app/future/settings/teams
|
||||
checkRoute "$APP_ROUTER_GETTING_STARTED_STEP_ENABLED" app/future/getting-started
|
||||
checkRoute "$APP_ROUTER_APPS_ENABLED" app/future/apps
|
||||
checkRoute "$APP_ROUTER_BOOKINGS_STATUS_ENABLED" app/future/bookings
|
||||
checkRoute "$APP_ROUTER_VIDEO_ENABLED" app/future/video
|
||||
checkRoute "$APP_ROUTER_TEAMS_ENABLED" app/future/teams
|
||||
|
||||
# These are routes that don't have and environment variable to enable or disable them
|
||||
# Will stop removing gradually as we test and confirm that they are working
|
||||
rm -rf \
|
||||
app/future/[user]\
|
||||
app/future/auth\
|
||||
app/future/booking\
|
||||
app/future/connect-and-join\
|
||||
app/future/d\
|
||||
app/future/enterprise\
|
||||
app/future/insights\
|
||||
app/future/maintenance\
|
||||
app/future/more\
|
||||
app/future/org\
|
||||
app/future/payment\
|
||||
app/future/reschedule\
|
||||
app/future/routing-forms\
|
||||
app/future/settings\
|
||||
app/future/signup\
|
||||
app/future/team
|
||||
|
||||
exit 1
|
||||
@@ -37,6 +37,7 @@ export const getSampleUserInSession = function () {
|
||||
disableImpersonation: false,
|
||||
organizationId: null,
|
||||
theme: "",
|
||||
appTheme: "",
|
||||
createdDate: new Date(),
|
||||
trialEndsAt: new Date(),
|
||||
completedOnboarding: false,
|
||||
|
||||
@@ -14,6 +14,7 @@ import closecom_config_json from "./closecom/config.json";
|
||||
import cron_config_json from "./cron/config.json";
|
||||
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
||||
import deel_config_json from "./deel/config.json";
|
||||
import demodesk_config_json from "./demodesk/config.json";
|
||||
import discord_config_json from "./discord/config.json";
|
||||
import eightxeight_config_json from "./eightxeight/config.json";
|
||||
import element_call_config_json from "./element-call/config.json";
|
||||
@@ -97,6 +98,7 @@ export const appStoreMetadata = {
|
||||
cron: cron_config_json,
|
||||
dailyvideo: dailyvideo__metadata_ts,
|
||||
deel: deel_config_json,
|
||||
demodesk: demodesk_config_json,
|
||||
discord: discord_config_json,
|
||||
eightxeight: eightxeight_config_json,
|
||||
"element-call": element_call_config_json,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const apiHandlers = {
|
||||
closecom: import("./closecom/api"),
|
||||
cron: import("./cron/api"),
|
||||
deel: import("./deel/api"),
|
||||
demodesk: import("./demodesk/api"),
|
||||
discord: import("./discord/api"),
|
||||
eightxeight: import("./eightxeight/api"),
|
||||
"element-call": import("./element-call/api"),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import around_config_json from "./around/config.json";
|
||||
import campfire_config_json from "./campfire/config.json";
|
||||
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
|
||||
import demodesk_config_json from "./demodesk/config.json";
|
||||
import discord_config_json from "./discord/config.json";
|
||||
import eightxeight_config_json from "./eightxeight/config.json";
|
||||
import element_call_config_json from "./element-call/config.json";
|
||||
@@ -41,6 +42,7 @@ export const appStoreMetadata = {
|
||||
around: around_config_json,
|
||||
campfire: campfire_config_json,
|
||||
dailyvideo: dailyvideo__metadata_ts,
|
||||
demodesk: demodesk_config_json,
|
||||
discord: discord_config_json,
|
||||
eightxeight: eightxeight_config_json,
|
||||
"element-call": element_call_config_json,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
items:
|
||||
- iframe: { src: https://www.youtube.com/embed/X6XVzJ0E8xY }
|
||||
- 1.jpeg
|
||||
- 2.webp
|
||||
- 3.webp
|
||||
- 4.webp
|
||||
---
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
### AI superpowers for your sales meetings
|
||||
|
||||
#### Coach and onboard sellers with AI-based insights
|
||||
- Automatically record all meetings, GDPR & MiFID compliant
|
||||
- Transcribe and summarize meetings using Demodesk’s AI
|
||||
- Coach teams along conversational metrics and scorecards
|
||||
- Automatically log key information into your CRM
|
||||
|
||||
#### Schedule more customer meetings, on auto-pilot
|
||||
- Let customers schedule time without email back-and-forth
|
||||
- Qualify website leads and route them to the right person
|
||||
- Automate and control handoff processes in your team
|
||||
- Automate admin work and sync interactions to your CRM
|
||||
|
||||
#### Run secure video meetings that convert into revenue
|
||||
- Host professional, secure video meetings without downloads
|
||||
- Engage customers with interactive screen sharing
|
||||
- Coach sellers in real-time along standardized playbooks
|
||||
- Automate preparation, follow-up and CRM documentation
|
||||
@@ -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, user: user, slug, key: {}, teamId }),
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as add } from "./add";
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"/*": "Don't modify slug - If required, do it using cli edit command",
|
||||
"name": "Demodesk",
|
||||
"slug": "demodesk",
|
||||
"type": "demodesk_conferencing",
|
||||
"logo": "icon.svg",
|
||||
"url": "https://demodesk.com",
|
||||
"variant": "conferencing",
|
||||
"categories": ["conferencing"],
|
||||
"publisher": "Cal.com, Inc.",
|
||||
"email": "support@cal.com",
|
||||
"appData": {
|
||||
"location": {
|
||||
"type": "integrations:{SLUG}_video",
|
||||
"label": "{TITLE}",
|
||||
"linkType": "static",
|
||||
"organizerInputPlaceholder": "https://demodesk.com/meet/mylink",
|
||||
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?demodesk.com\\/[a-zA-Z0-9]*"
|
||||
}
|
||||
},
|
||||
"description": "Run Professional Video Meetings, Coach Sales Teams in Real-Time with AI, And Schedule Meetings on Auto-Pilot. 100% GDPR Compliant, Enterprise Ready.",
|
||||
"isTemplate": false,
|
||||
"__createdUsingCli": true,
|
||||
"__template": "event-type-location-video-static"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as api from "./api";
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"name": "@calcom/demodesk",
|
||||
"version": "0.0.1",
|
||||
"main": "./index.ts",
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@calcom/types": "*"
|
||||
},
|
||||
"description": "Run Professional Video Meetings, Coach Sales Teams in Real-Time with AI, And Schedule Meetings on Auto-Pilot. 100% GDPR Compliant, Enterprise Ready."
|
||||
}
|
||||
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg width="2484" height="2484" viewBox="0 0 2484 2484" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="2484" height="2484" rx="200" fill="#DDDDDD"/>
|
||||
<path d="M1126.24 514.004H687.083C663.823 514.004 645 532.827 645 556.088V656.4C645 679.661 663.823 698.484 687.083 698.484H1109.75C1451.81 698.484 1699.98 941.881 1699.98 1241.1C1699.98 1365.7 1657.75 1480.59 1586.85 1572.29C1599.97 1525.62 1607.02 1476.42 1607.02 1425.58C1607.02 1126.36 1418.26 882.963 1025.89 882.963H687.083C663.823 882.963 645 901.786 645 925.047V1025.36C645 1048.62 663.823 1067.44 687.083 1067.44H1019.19C1270.01 1068.59 1412.48 1228.09 1412.48 1425.58C1412.48 1623.06 1270.01 1783.75 1019.19 1783.75H850.096V1294.04C850.096 1270.79 831.274 1251.96 808.014 1251.96H687.083C663.823 1251.96 645 1270.79 645 1294.04V1926.15C645 1949.4 663.823 1968.23 687.083 1968.23H1064.45H1064.53H1126.28C1579.28 1968.23 1894.59 1642.04 1894.59 1241.13C1894.59 840.229 1607.05 512.703 1126.24 514.004Z" fill="#2B2D28"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 998 B |
@@ -21,6 +21,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
switchChecked={enabled}
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<TextField
|
||||
dataTestid={app.slug}
|
||||
name="Tracking ID"
|
||||
value={trackingId}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -21,6 +21,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
switchChecked={enabled}
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<TextField
|
||||
dataTestid={app.slug}
|
||||
name="Tracking ID"
|
||||
value={trackingId}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Auth } from "googleapis";
|
||||
import { google } from "googleapis";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import { WEBAPP_URL_FOR_OAUTH, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -105,23 +106,38 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
appId: "google-calendar",
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session.user.id,
|
||||
externalId: primaryCal.id,
|
||||
integration: "google_calendar",
|
||||
};
|
||||
|
||||
// Wrapping in a try/catch to reduce chance of race conditions-
|
||||
// also this improves performance for most of the happy-paths.
|
||||
try {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
userId: req.session.user.id,
|
||||
externalId: primaryCal.id,
|
||||
credentialId: credential.id,
|
||||
integration: "google_calendar",
|
||||
...selectedCalendarWhereUnique,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
let errorMessage = "something_went_wrong";
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// it is possible a selectedCalendar was orphaned, in this situation-
|
||||
// we want to recover by connecting the existing selectedCalendar to the new Credential.
|
||||
if (await renewSelectedCalendarCredentialId(selectedCalendarWhereUnique, credential.id)) {
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ??
|
||||
getInstalledAppPath({ variant: "calendar", slug: "google-calendar" })
|
||||
);
|
||||
return;
|
||||
}
|
||||
// else
|
||||
errorMessage = "account_already_linked";
|
||||
}
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
res.redirect(
|
||||
`${
|
||||
getSafeRedirectUrl(state?.onErrorReturnTo) ??
|
||||
|
||||
@@ -9,9 +9,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/flags/server/utils", () => ({
|
||||
getFeatureFlagMap: vi.fn().mockResolvedValue({
|
||||
"calendar-cache": true,
|
||||
}),
|
||||
getFeatureFlag: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("./getGoogleAppKeys", () => ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RRule } from "rrule";
|
||||
|
||||
import { MeetLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import type CalendarService from "@calcom/lib/CalendarService";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -264,6 +264,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
calendarId: selectedCalendar,
|
||||
eventId: event.id || "",
|
||||
requestBody: {
|
||||
location: getLocation(calEventRaw),
|
||||
description: getRichDescription({
|
||||
...calEventRaw,
|
||||
}),
|
||||
@@ -461,10 +462,10 @@ export default class GoogleCalendarService implements Calendar {
|
||||
items: { id: string }[];
|
||||
}): Promise<EventBusyDate[] | null> {
|
||||
const calendar = await this.authedCalendar();
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const calendarCacheEnabled = await getFeatureFlag(prisma, "calendar-cache");
|
||||
|
||||
let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {};
|
||||
if (!flags["calendar-cache"]) {
|
||||
if (!calendarCacheEnabled) {
|
||||
this.log.warn("Calendar Cache is disabled - Skipping");
|
||||
const { timeMin, timeMax, items } = args;
|
||||
const apires = await calendar.freebusy.query({
|
||||
|
||||
@@ -21,6 +21,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
switchChecked={enabled}
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<TextField
|
||||
dataTestid={app.slug}
|
||||
name="Tracking ID"
|
||||
value={trackingId}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "ICS Feed",
|
||||
"title": "ICS Feed",
|
||||
"slug": "ics-feed",
|
||||
"dirName":"ics-feedcalendar",
|
||||
"type": "ics-feed_calendar",
|
||||
"logo": "icon.svg",
|
||||
"variant": "calendar",
|
||||
|
||||
@@ -23,6 +23,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextField
|
||||
dataTestid={`${app.slug}-url`}
|
||||
name="Matomo URL"
|
||||
placeholder="Enter your Matomo URL here"
|
||||
value={matomoUrl}
|
||||
@@ -32,6 +33,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
dataTestid={`${app.slug}-site-id`}
|
||||
disabled={disabled}
|
||||
name="Site ID"
|
||||
placeholder="Enter your Site ID"
|
||||
|
||||
@@ -19,6 +19,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
switchChecked={enabled}
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<TextField
|
||||
dataTestid={app.slug}
|
||||
name="Pixel ID"
|
||||
value={trackingId}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
@@ -119,23 +120,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
appId: "office365-calendar",
|
||||
},
|
||||
});
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session?.user.id,
|
||||
integration: "office365_calendar",
|
||||
externalId: defaultCalendar.id,
|
||||
};
|
||||
// Wrapping in a try/catch to reduce chance of race conditions-
|
||||
// also this improves performance for most of the happy-paths.
|
||||
try {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
userId: req.session?.user.id,
|
||||
integration: "office365_calendar",
|
||||
externalId: defaultCalendar.id,
|
||||
...selectedCalendarWhereUnique,
|
||||
credentialId: credential.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
let errorMessage = "something_went_wrong";
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// it is possible a selectedCalendar was orphaned, in this situation-
|
||||
// we want to recover by connecting the existing selectedCalendar to the new Credential.
|
||||
if (await renewSelectedCalendarCredentialId(selectedCalendarWhereUnique, credential.id)) {
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ??
|
||||
getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" })
|
||||
);
|
||||
return;
|
||||
}
|
||||
// else
|
||||
errorMessage = "account_already_linked";
|
||||
}
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
res.redirect(
|
||||
`${
|
||||
getSafeRedirectUrl(state?.onErrorReturnTo) ??
|
||||
@@ -146,8 +160,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
}
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ??
|
||||
getInstalledAppPath({ variant: "calendar", slug: "office365-calendar" })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
teamId={eventType.team?.id || undefined}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<TextField
|
||||
dataTestid={`${app.slug}-url`}
|
||||
name="Plausible URL"
|
||||
defaultValue="https://plausible.io/js/script.js"
|
||||
placeholder="https://plausible.io/js/script.js"
|
||||
@@ -33,6 +34,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
dataTestid={`${app.slug}-tracking-id`}
|
||||
disabled={disabled}
|
||||
name="Tracked Domain"
|
||||
placeholder="yourdomain.com"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -86,23 +87,36 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
appId: config.slug,
|
||||
},
|
||||
});
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session?.user.id,
|
||||
integration: config.type,
|
||||
externalId: primaryCalendar.uid,
|
||||
};
|
||||
// Wrapping in a try/catch to reduce chance of race conditions-
|
||||
// also this improves performance for most of the happy-paths.
|
||||
try {
|
||||
await prisma.selectedCalendar.create({
|
||||
data: {
|
||||
userId: req.session.user.id,
|
||||
integration: config.type,
|
||||
externalId: primaryCalendar.uid,
|
||||
...selectedCalendarWhereUnique,
|
||||
credentialId: credential.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
let errorMessage = "something_went_wrong";
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// it is possible a selectedCalendar was orphaned, in this situation-
|
||||
// we want to recover by connecting the existing selectedCalendar to the new Credential.
|
||||
if (await renewSelectedCalendarCredentialId(selectedCalendarWhereUnique, credential.id)) {
|
||||
res.redirect(
|
||||
getSafeRedirectUrl(state?.returnTo) ??
|
||||
getInstalledAppPath({ variant: "calendar", slug: config.slug })
|
||||
);
|
||||
return;
|
||||
}
|
||||
// else
|
||||
errorMessage = "account_already_linked";
|
||||
}
|
||||
await prisma.credential.delete({ where: { id: credential.id } });
|
||||
res.redirect(
|
||||
`${
|
||||
getSafeRedirectUrl(state?.onErrorReturnTo) ??
|
||||
|
||||
@@ -69,7 +69,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
|
||||
name:
|
||||
searchParams?.get("name") ||
|
||||
(firstNameQueryParam ? `${firstNameQueryParam} ${lastNameQueryParam}` : null),
|
||||
guests: searchParams?.getAll("guests") || searchParams?.getAll("guest"),
|
||||
guests: (searchParams?.getAll("guests") || searchParams?.getAll("guest")) ?? [],
|
||||
};
|
||||
}, [searchParams, firstNameQueryParam, lastNameQueryParam]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createTransport } from "nodemailer";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { serverConfig } from "@calcom/lib/serverConfig";
|
||||
import { setTestEmail } from "@calcom/lib/testEmails";
|
||||
@@ -28,9 +28,9 @@ export default class BaseEmail {
|
||||
return {};
|
||||
}
|
||||
public async sendEmail() {
|
||||
const featureFlags = await getFeatureFlagMap(prisma);
|
||||
const emailsDisabled = await getFeatureFlag(prisma, "emails");
|
||||
/** If email kill switch exists and is active, we prevent emails being sent. */
|
||||
if (featureFlags.emails) {
|
||||
if (emailsDisabled) {
|
||||
console.warn("Skipped Sending Email due to active Kill Switch");
|
||||
return new Promise((r) => r("Skipped Sending Email due to active Kill Switch"));
|
||||
}
|
||||
@@ -65,7 +65,14 @@ export default class BaseEmail {
|
||||
}
|
||||
}
|
||||
)
|
||||
).catch((e) => console.error("sendEmail", e));
|
||||
).catch((e) =>
|
||||
console.error(
|
||||
"sendEmail",
|
||||
`from: ${"from" in payloadWithUnEscapedSubject ? payloadWithUnEscapedSubject.from : ""}`,
|
||||
`subject: ${"subject" in payloadWithUnEscapedSubject ? payloadWithUnEscapedSubject.subject : ""}`,
|
||||
e
|
||||
)
|
||||
);
|
||||
return new Promise((resolve) => resolve("send mail async"));
|
||||
}
|
||||
protected getMailerOptions() {
|
||||
|
||||
@@ -35,6 +35,27 @@ export type EventDataMap = {
|
||||
};
|
||||
confirmed: boolean;
|
||||
};
|
||||
rescheduleBookingSuccessful: {
|
||||
booking: unknown;
|
||||
eventType: unknown;
|
||||
date: string;
|
||||
duration: number | undefined;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
};
|
||||
confirmed: boolean;
|
||||
};
|
||||
bookingCancelled: {
|
||||
booking: unknown;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone?: string | undefined;
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
"*": Record<string, unknown>;
|
||||
__routeChanged: Record<string, never>;
|
||||
__windowLoadComplete: Record<string, never>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sendEmailVerificationLink,
|
||||
sendChangeOfEmailVerificationLink,
|
||||
} from "@calcom/emails/email-manager";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -24,9 +24,9 @@ interface VerifyEmailType {
|
||||
export const sendEmailVerification = async ({ email, language, username }: VerifyEmailType) => {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const translation = await getTranslation(language ?? "en", "common");
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
if (!flags["email-verification"]) {
|
||||
if (!emailVerification) {
|
||||
log.warn("Email verification is disabled - Skipping");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
@@ -93,9 +93,9 @@ interface ChangeOfEmail {
|
||||
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const translation = await getTranslation(language ?? "en", "common");
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
if (!flags["email-verification"]) {
|
||||
if (!emailVerification) {
|
||||
log.warn("Email verification is disabled - Skipping");
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
|
||||
@@ -123,7 +123,8 @@ const BookerComponent = ({
|
||||
|
||||
const { bookerFormErrorRef, key, formEmail, bookingForm, errors: formErrors } = bookerForm;
|
||||
|
||||
const { handleBookEvent, hasInstantMeetingTokenExpired, errors, loadingStates, expiryTime } = bookings;
|
||||
const { handleBookEvent, errors, loadingStates, expiryTime } = bookings;
|
||||
|
||||
const {
|
||||
isEmailVerificationModalVisible,
|
||||
setEmailVerificationModalVisible,
|
||||
@@ -180,7 +181,6 @@ const BookerComponent = ({
|
||||
/>
|
||||
<RedirectToInstantMeetingModal
|
||||
expiryTime={expiryTime}
|
||||
hasInstantMeetingTokenExpired={hasInstantMeetingTokenExpired}
|
||||
bookingId={parseInt(getQueryParam("bookingId") || "0")}
|
||||
onGoBack={() => {
|
||||
onGoBackInstantMeeting();
|
||||
@@ -203,7 +203,6 @@ const BookerComponent = ({
|
||||
formErrors,
|
||||
handleBookEvent,
|
||||
handleVerifyEmail,
|
||||
hasInstantMeetingTokenExpired,
|
||||
isEmailVerificationModalVisible,
|
||||
key,
|
||||
loadingStates,
|
||||
|
||||
@@ -55,13 +55,6 @@ export function Header({
|
||||
|
||||
if (isMobile || !enabledLayouts) return null;
|
||||
|
||||
// Only reason we create this component, is because it is used 3 times in this component,
|
||||
// and this way we can't forget to update one of the props in all places :)
|
||||
const LayoutToggleWithData = () => {
|
||||
return enabledLayouts.length <= 1 ? null : (
|
||||
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
|
||||
);
|
||||
};
|
||||
// In month view we only show the layout toggle.
|
||||
if (isMonthView) {
|
||||
return (
|
||||
@@ -78,7 +71,11 @@ export function Header({
|
||||
) : (
|
||||
renderOverlay?.()
|
||||
)}
|
||||
<LayoutToggleWithData />
|
||||
<LayoutToggleWithData
|
||||
layout={layout}
|
||||
enabledLayouts={enabledLayouts}
|
||||
onLayoutToggle={onLayoutToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +137,11 @@ export function Header({
|
||||
{renderOverlay?.()}
|
||||
<TimeFormatToggle />
|
||||
<div className="fixed top-4 ltr:right-4 rtl:left-4">
|
||||
<LayoutToggleWithData />
|
||||
<LayoutToggleWithData
|
||||
layout={layout}
|
||||
enabledLayouts={enabledLayouts}
|
||||
onLayoutToggle={onLayoutToggle}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
This second layout toggle is hidden, but needed to reserve the correct spot in the DIV
|
||||
@@ -150,7 +151,11 @@ export function Header({
|
||||
while it actually already was on place. That's why we have this element twice.
|
||||
*/}
|
||||
<div className="pointer-events-none opacity-0" aria-hidden>
|
||||
<LayoutToggleWithData />
|
||||
<LayoutToggleWithData
|
||||
layout={layout}
|
||||
enabledLayouts={enabledLayouts}
|
||||
onLayoutToggle={onLayoutToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,3 +203,17 @@ const LayoutToggle = ({
|
||||
|
||||
return <ToggleGroup onValueChange={onLayoutToggle} defaultValue={layout} options={layoutOptions} />;
|
||||
};
|
||||
|
||||
const LayoutToggleWithData = ({
|
||||
enabledLayouts,
|
||||
onLayoutToggle,
|
||||
layout,
|
||||
}: {
|
||||
enabledLayouts: BookerLayouts[];
|
||||
onLayoutToggle: (layout: string) => void;
|
||||
layout: string;
|
||||
}) => {
|
||||
return enabledLayouts.length <= 1 ? null : (
|
||||
<LayoutToggle onLayoutToggle={onLayoutToggle} layout={layout} enabledLayouts={enabledLayouts} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,18 +12,17 @@ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
};
|
||||
|
||||
export const RedirectToInstantMeetingModal = ({
|
||||
hasInstantMeetingTokenExpired,
|
||||
bookingId,
|
||||
onGoBack,
|
||||
expiryTime,
|
||||
}: {
|
||||
hasInstantMeetingTokenExpired: boolean;
|
||||
bookingId: number;
|
||||
onGoBack: () => void;
|
||||
expiryTime?: Date;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [timeRemaining, setTimeRemaining] = useState(calculateTimeRemaining());
|
||||
const [hasInstantMeetingTokenExpired, setHasInstantMeetingTokenExpired] = useState(false);
|
||||
|
||||
function calculateTimeRemaining() {
|
||||
const now = dayjs();
|
||||
@@ -37,6 +36,7 @@ export const RedirectToInstantMeetingModal = ({
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining(calculateTimeRemaining());
|
||||
setHasInstantMeetingTokenExpired(expiryTime && new Date(expiryTime) < new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState, useEffect } from "react";
|
||||
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
|
||||
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import type { useEventReturnType } from "@calcom/features/bookings/Booker/utils/event";
|
||||
import { updateQueryParam, getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import { useBookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { showToast } from "@calcom/ui";
|
||||
@@ -54,7 +56,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
|
||||
const { t, i18n } = useLocale();
|
||||
const bookingSuccessRedirect = useBookingSuccessRedirect();
|
||||
const bookerFormErrorRef = useRef<HTMLDivElement>(null);
|
||||
const [expiryTime, setExpiryTime] = useState<Date | undefined>();
|
||||
const [instantMeetingTokenExpiryTime, setExpiryTime] = useState<Date | undefined>();
|
||||
const recurringEventCount = useBookerStore((state) => state.recurringEventCount);
|
||||
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
|
||||
const duration = useBookerStore((state) => state.selectedDuration);
|
||||
@@ -64,13 +66,13 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
|
||||
const isRescheduling = !!rescheduleUid && !!bookingData;
|
||||
|
||||
const bookingId = parseInt(getQueryParam("bookingId") || "0");
|
||||
const hasInstantMeetingTokenExpired = expiryTime && new Date(expiryTime) < new Date();
|
||||
|
||||
const _instantBooking = trpc.viewer.bookings.getInstantBookingLocation.useQuery(
|
||||
{
|
||||
bookingId: bookingId,
|
||||
},
|
||||
{
|
||||
enabled: !!bookingId && !hasInstantMeetingTokenExpired,
|
||||
enabled: !!bookingId,
|
||||
refetchInterval: 2000,
|
||||
refetchIntervalInBackground: true,
|
||||
}
|
||||
@@ -78,6 +80,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
|
||||
useEffect(
|
||||
function refactorMeWithoutEffect() {
|
||||
const data = _instantBooking.data;
|
||||
|
||||
if (!data) return;
|
||||
try {
|
||||
showToast(t("something_went_wrong_on_our_end"), "error");
|
||||
@@ -103,6 +106,45 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
|
||||
onSuccess: (responseData) => {
|
||||
const { uid, paymentUid } = responseData;
|
||||
const fullName = getFullName(bookingForm.getValues("responses.name"));
|
||||
|
||||
const users = !!event.data?.hosts?.length
|
||||
? event.data?.hosts.map((host) => host.user)
|
||||
: event.data?.users;
|
||||
|
||||
const validDuration = event.data?.isDynamic
|
||||
? duration || event.data?.length
|
||||
: duration && event.data?.metadata?.multipleDuration?.includes(duration)
|
||||
? duration
|
||||
: event.data?.length;
|
||||
|
||||
if (isRescheduling) {
|
||||
sdkActionManager?.fire("rescheduleBookingSuccessful", {
|
||||
booking: responseData,
|
||||
eventType: event.data,
|
||||
date: responseData?.startTime?.toString() || "",
|
||||
duration: validDuration,
|
||||
organizer: {
|
||||
name: users?.[0].name || "Nameless",
|
||||
email: responseData?.userPrimaryEmail || responseData.user?.email || "Email-less",
|
||||
timeZone: responseData.user?.timeZone || "Europe/London",
|
||||
},
|
||||
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
|
||||
});
|
||||
} else {
|
||||
sdkActionManager?.fire("bookingSuccessful", {
|
||||
booking: responseData,
|
||||
eventType: event.data,
|
||||
date: responseData?.startTime?.toString() || "",
|
||||
duration: validDuration,
|
||||
organizer: {
|
||||
name: users?.[0].name || "Nameless",
|
||||
email: responseData?.userPrimaryEmail || responseData.user?.email || "Email-less",
|
||||
timeZone: responseData.user?.timeZone || "Europe/London",
|
||||
},
|
||||
confirmed: !(responseData.status === BookingStatus.PENDING && event.data?.requiresConfirmation),
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentUid) {
|
||||
router.push(
|
||||
createPaymentLink({
|
||||
@@ -260,11 +302,10 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata }: IUseBo
|
||||
|
||||
return {
|
||||
handleBookEvent,
|
||||
expiryTime,
|
||||
expiryTime: instantMeetingTokenExpiryTime,
|
||||
bookingForm,
|
||||
bookerFormErrorRef,
|
||||
errors,
|
||||
loadingStates,
|
||||
hasInstantMeetingTokenExpired: Boolean(hasInstantMeetingTokenExpired),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -379,7 +379,7 @@ export async function ensureAvailableUsers(
|
||||
safeStringify({
|
||||
startDateTimeUtc,
|
||||
endDateTimeUtc,
|
||||
timeZone: input.timeZone,
|
||||
input,
|
||||
})
|
||||
);
|
||||
continue;
|
||||
@@ -406,7 +406,7 @@ export async function ensureAvailableUsers(
|
||||
safeStringify({
|
||||
startDateTimeUtc,
|
||||
endDateTimeUtc,
|
||||
timeZone: input.timeZone,
|
||||
input,
|
||||
})
|
||||
);
|
||||
continue;
|
||||
@@ -428,7 +428,7 @@ export async function ensureAvailableUsers(
|
||||
safeStringify({
|
||||
startDateTimeUtc,
|
||||
endDateTimeUtc,
|
||||
timeZone: input.timeZone,
|
||||
input,
|
||||
})
|
||||
);
|
||||
throw new Error(ErrorCode.NoAvailableUsersFound);
|
||||
@@ -1289,7 +1289,7 @@ async function handler(
|
||||
}
|
||||
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
|
||||
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
|
||||
luckyUserResponse = { luckyUsers };
|
||||
luckyUserResponse = { luckyUsers: luckyUsers.map((u) => u.id) };
|
||||
} else if (req.body.allRecurringDates && eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
|
||||
// all recurring slots except the first one
|
||||
const luckyUsersFromFirstBooking = luckyUsers
|
||||
@@ -1576,7 +1576,18 @@ async function handler(
|
||||
});
|
||||
if (newBooking) {
|
||||
req.statusCode = 201;
|
||||
return { ...newBooking, ...luckyUserResponse };
|
||||
const bookingResponse = {
|
||||
...newBooking,
|
||||
user: {
|
||||
...newBooking.user,
|
||||
email: null,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...bookingResponse,
|
||||
...luckyUserResponse,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (isTeamEventType) {
|
||||
@@ -1616,6 +1627,19 @@ async function handler(
|
||||
})
|
||||
);
|
||||
|
||||
// update original rescheduled booking (no seats event)
|
||||
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: originalRescheduledBooking.id,
|
||||
},
|
||||
data: {
|
||||
rescheduled: true,
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
booking = await createBooking({
|
||||
originalRescheduledBooking,
|
||||
@@ -1683,7 +1707,7 @@ async function handler(
|
||||
err.message
|
||||
);
|
||||
if (err.code === "P2002") {
|
||||
throw new HttpError({ statusCode: 409, message: "booking.conflict" });
|
||||
throw new HttpError({ statusCode: 409, message: "booking_conflict" });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1709,20 +1733,7 @@ async function handler(
|
||||
|
||||
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
|
||||
|
||||
//update original rescheduled booking (no seats event)
|
||||
if (!eventType.seatsPerTimeSlot) {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: originalRescheduledBooking.id,
|
||||
},
|
||||
data: {
|
||||
rescheduled: true,
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newDesinationCalendar = evt.destinationCalendar;
|
||||
const newDestinationCalendar = evt.destinationCalendar;
|
||||
|
||||
evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar
|
||||
? [originalRescheduledBooking?.destinationCalendar]
|
||||
@@ -1741,7 +1752,7 @@ async function handler(
|
||||
originalRescheduledBooking.uid,
|
||||
undefined,
|
||||
changedOrganizer,
|
||||
newDesinationCalendar
|
||||
newDestinationCalendar
|
||||
);
|
||||
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
@@ -2170,8 +2181,18 @@ async function handler(
|
||||
});
|
||||
|
||||
req.statusCode = 201;
|
||||
return {
|
||||
// TODO: Refactor better so this booking object is not passed
|
||||
// all around and instead the individual fields are sent as args.
|
||||
const bookingReponse = {
|
||||
...booking,
|
||||
user: {
|
||||
...booking.user,
|
||||
email: null,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...bookingReponse,
|
||||
...luckyUserResponse,
|
||||
message: "Payment required",
|
||||
paymentUid: payment?.uid,
|
||||
@@ -2299,8 +2320,19 @@ async function handler(
|
||||
|
||||
// booking successful
|
||||
req.statusCode = 201;
|
||||
return {
|
||||
|
||||
// TODO: Refactor better so this booking object is not passed
|
||||
// all around and instead the individual fields are sent as args.
|
||||
const bookingResponse = {
|
||||
...booking,
|
||||
user: {
|
||||
...booking.user,
|
||||
email: null,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...bookingResponse,
|
||||
...luckyUserResponse,
|
||||
references: referencesToCreate,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
import {
|
||||
createBookingScenario,
|
||||
getGoogleCalendarCredential,
|
||||
TestData,
|
||||
getDate,
|
||||
getOrganizer,
|
||||
getBooker,
|
||||
getScenarioData,
|
||||
mockSuccessfulVideoMeetingCreation,
|
||||
mockCalendarToHaveNoBusySlots,
|
||||
BookingLocations,
|
||||
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
import { createMockNextJsRequest } from "@calcom/web/test/utils/bookingScenario/createMockNextJsRequest";
|
||||
import {
|
||||
expectSuccessfulBookingCreationEmails,
|
||||
expectBookingToBeInDatabase,
|
||||
expectSuccessfulCalendarEventCreationInCalendar,
|
||||
expectICalUIDAsString,
|
||||
} from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
|
||||
export const Timezones = {
|
||||
"-05:00": "America/New_York",
|
||||
"00:00": "Europe/London",
|
||||
};
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
// Local test runs sometime gets too slow
|
||||
const timeout = process.env.CI ? 5000 : 20000;
|
||||
|
||||
describe("handleNewBooking", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
describe("Complex schedules:", () => {
|
||||
test(
|
||||
`should be able to book the last slot before midnight`,
|
||||
async ({ emails }) => {
|
||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||
const newYorkTimeZone = Timezones["-05:00"];
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
// Using .endOf("day") here to ensure our date doesn't change when we set the time zone
|
||||
const startDateTimeOrganizerTz = dayjs(plus1DateString)
|
||||
.endOf("day")
|
||||
.tz(newYorkTimeZone)
|
||||
.hour(23)
|
||||
.minute(0)
|
||||
.second(0);
|
||||
|
||||
const endDateTimeOrganizerTz = dayjs(plus1DateString)
|
||||
.endOf("day")
|
||||
.tz(newYorkTimeZone)
|
||||
.startOf("day")
|
||||
.add(1, "day");
|
||||
|
||||
const schedule = {
|
||||
name: "4:00PM to 11:59PM in New York",
|
||||
availability: [
|
||||
{
|
||||
days: [0, 1, 2, 3, 4, 5, 6],
|
||||
startTime: dayjs("1970-01-01").utc().hour(16).toDate(), // These times are stored with Z offset
|
||||
endTime: dayjs("1970-01-01").utc().hour(23).minute(59).toDate(), // These times are stored with Z offset
|
||||
date: null,
|
||||
},
|
||||
],
|
||||
timeZone: newYorkTimeZone,
|
||||
};
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [schedule],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 60,
|
||||
length: 60,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "dailyvideo",
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock a Scenario where iCalUID isn't returned by Google Calendar in which case booking UID is used as the ics UID
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
id: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
uid: "MOCK_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: BookingLocations.CalVideo },
|
||||
},
|
||||
start: startDateTimeOrganizerTz.format(),
|
||||
end: endDateTimeOrganizerTz.format(),
|
||||
timeZone: Timezones["-05:00"],
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
expect(createdBooking.responses).toContain({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
});
|
||||
|
||||
expect(createdBooking).toContain({
|
||||
location: BookingLocations.CalVideo,
|
||||
});
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
},
|
||||
{
|
||||
type: appStoreMetadata.googlecalendar.type,
|
||||
uid: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingId: "GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
},
|
||||
],
|
||||
iCalUID: createdBooking.iCalUID,
|
||||
});
|
||||
|
||||
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, {
|
||||
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
// We won't be sending evt.destinationCalendar in this case.
|
||||
// Google Calendar in this case fallbacks to the "primary" calendar - https://github.com/calcom/cal.com/blob/7d5dad7fea78ff24dddbe44f1da5d7e08e1ff568/packages/app-store/googlecalendar/lib/CalendarService.ts#L217
|
||||
// Not sure if it's the correct behaviour. Right now, it isn't possible to have an organizer with connected calendar but no destination calendar - As soon as the Google Calendar app is installed, a destination calendar is created.
|
||||
calendarId: null,
|
||||
});
|
||||
|
||||
const iCalUID = expectICalUIDAsString(createdBooking.iCalUID);
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
emails,
|
||||
iCalUID,
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ export const handleNewRecurringBooking = async (
|
||||
};
|
||||
|
||||
const firstBookingResult = await handleNewBooking(recurringEventReq);
|
||||
luckyUsers = firstBookingResult.luckyUsers?.map((user) => user.id);
|
||||
luckyUsers = firstBookingResult.luckyUsers;
|
||||
}
|
||||
|
||||
for (let key = isRoundRobin ? 1 : 0; key < data.length; key++) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
|
||||
export const getServerSideProps = async ({ req, res }: GetServerSidePropsContext) => {
|
||||
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const organizationsEnabled = await getFeatureFlag(prisma, "organizations");
|
||||
// Check if organizations are enabled
|
||||
if (flags["organizations"] !== true) {
|
||||
if (!organizationsEnabled) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
switch (reminder.workflowStep.action) {
|
||||
case WorkflowActions.EMAIL_HOST:
|
||||
sendTo = reminder.booking.user?.email;
|
||||
sendTo = reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email;
|
||||
break;
|
||||
case WorkflowActions.EMAIL_ATTENDEE:
|
||||
sendTo = reminder.booking.attendees[0].email;
|
||||
@@ -227,7 +227,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email,
|
||||
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
|
||||
attachments: reminder.workflowStep.includeCalendarEvent
|
||||
? [
|
||||
{
|
||||
@@ -297,7 +297,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
html: emailContent.emailBody,
|
||||
batchId: batchId,
|
||||
sendAt: dayjs(reminder.scheduledDate).unix(),
|
||||
replyTo: reminder.booking.user?.email,
|
||||
replyTo: reminder.booking?.userPrimaryEmail ?? reminder.booking.user?.email,
|
||||
},
|
||||
{ sender: reminder.workflowStep?.sender }
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ type PartialBooking =
|
||||
| "responses"
|
||||
| "uid"
|
||||
| "attendees"
|
||||
| "userPrimaryEmail"
|
||||
> & { eventType: Partial<EventType> | null } & { user: Partial<User> | null })
|
||||
| null;
|
||||
|
||||
@@ -134,6 +135,7 @@ export async function getAllUnscheduledReminders(): Promise<PartialWorkflowRemin
|
||||
endTime: true,
|
||||
location: true,
|
||||
description: true,
|
||||
userPrimaryEmail: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
|
||||
@@ -25,6 +25,26 @@ import {
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
const userSelect = Prisma.validator<Prisma.UserSelect>()({
|
||||
id: true,
|
||||
avatarUrl: true,
|
||||
username: true,
|
||||
name: true,
|
||||
weekStart: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
metadata: true,
|
||||
organization: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
calVideoLogo: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
id: true,
|
||||
title: true,
|
||||
@@ -78,32 +98,12 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
avatarUrl: true,
|
||||
username: true,
|
||||
name: true,
|
||||
weekStart: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
metadata: true,
|
||||
},
|
||||
select: userSelect,
|
||||
},
|
||||
},
|
||||
},
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
avatarUrl: true,
|
||||
weekStart: true,
|
||||
username: true,
|
||||
name: true,
|
||||
theme: true,
|
||||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
},
|
||||
select: userSelect,
|
||||
},
|
||||
hidden: true,
|
||||
assignAllTeamMembers: true,
|
||||
@@ -291,12 +291,12 @@ type Event = Prisma.EventTypeGetPayload<typeof eventData>;
|
||||
|
||||
function getProfileFromEvent(event: Event) {
|
||||
const { team, hosts, owner } = event;
|
||||
const profile = team || hosts?.[0]?.user || owner;
|
||||
const nonTeamprofile = hosts?.[0]?.user || owner;
|
||||
const profile = team || nonTeamprofile;
|
||||
if (!profile) throw new Error("Event has no owner");
|
||||
|
||||
const username = "username" in profile ? profile.username : team?.slug;
|
||||
const weekStart = hosts?.[0]?.user?.weekStart || owner?.weekStart || "Monday";
|
||||
const basePath = team ? `/team/${username}` : `/${username}`;
|
||||
const eventMetaData = EventTypeMetaDataSchema.parse(event.metadata || {});
|
||||
const userMetaData = userMetadataSchema.parse(profile.metadata || {});
|
||||
|
||||
@@ -304,7 +304,22 @@ function getProfileFromEvent(event: Event) {
|
||||
username,
|
||||
name: profile.name,
|
||||
weekStart,
|
||||
image: team ? undefined : `${basePath}/avatar.png`,
|
||||
image: team
|
||||
? undefined
|
||||
: // TODO: There must be a better way to do this, maybe a prisma middleware?
|
||||
// This should come pre-proccessed from the database IMO instead of replacing everywhere
|
||||
getUserAvatarUrl({
|
||||
username: username || "",
|
||||
profile: {
|
||||
id: nonTeamprofile?.id || null,
|
||||
username: username || null,
|
||||
organizationId: nonTeamprofile?.organization?.id || null,
|
||||
organization: nonTeamprofile?.organization
|
||||
? { ...nonTeamprofile?.organization, requestedSlug: null }
|
||||
: null,
|
||||
},
|
||||
avatarUrl: nonTeamprofile?.avatarUrl,
|
||||
}),
|
||||
logo: !team ? undefined : team.logo,
|
||||
brandColor: profile.brandColor,
|
||||
darkBrandColor: profile.darkBrandColor,
|
||||
|
||||
@@ -12,3 +12,55 @@ export async function getFeatureFlagMap(prisma: PrismaClient) {
|
||||
return acc;
|
||||
}, {} as Partial<AppFlags>);
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
value: boolean; // adapt to other supported value types in the future
|
||||
expiry: number;
|
||||
}
|
||||
|
||||
interface CacheOptions {
|
||||
ttl: number; // time in ms
|
||||
}
|
||||
|
||||
const featureFlagCache = new Map<keyof AppFlags, CacheEntry>();
|
||||
|
||||
const isExpired = (entry: CacheEntry): boolean => {
|
||||
return Date.now() > entry.expiry;
|
||||
};
|
||||
|
||||
export const getFeatureFlag = async (
|
||||
prisma: PrismaClient,
|
||||
slug: keyof AppFlags,
|
||||
options: CacheOptions = { ttl: 5 * 60 * 1000 }
|
||||
): Promise<boolean> => {
|
||||
// pre-compute all app flags, each one will independelty reload it's own state after expiry.
|
||||
|
||||
if (featureFlagCache.size === 0) {
|
||||
const flags = await prisma.feature.findMany({ orderBy: { slug: "asc" } });
|
||||
flags.forEach((flag) => {
|
||||
featureFlagCache.set(flag.slug as keyof AppFlags, {
|
||||
value: flag.enabled,
|
||||
expiry: Date.now() + options.ttl,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const cacheEntry = featureFlagCache.get(slug);
|
||||
|
||||
if (cacheEntry && !isExpired(cacheEntry)) {
|
||||
return cacheEntry.value;
|
||||
}
|
||||
|
||||
const flag = await prisma.feature.findUnique({
|
||||
where: {
|
||||
slug,
|
||||
},
|
||||
});
|
||||
|
||||
const isEnabled = Boolean(flag && flag.enabled);
|
||||
const expiry = Date.now() + options.ttl;
|
||||
|
||||
featureFlagCache.set(slug, { value: isEnabled, expiry });
|
||||
|
||||
return isEnabled;
|
||||
};
|
||||
|
||||
@@ -4,23 +4,24 @@ interface ThemeLabelProps {
|
||||
label: string;
|
||||
defaultChecked?: boolean;
|
||||
register: any;
|
||||
fieldName?: string;
|
||||
}
|
||||
|
||||
export default function ThemeLabel(props: ThemeLabelProps) {
|
||||
const { variant, label, value, defaultChecked, register } = props;
|
||||
const { variant, label, value, defaultChecked, register, fieldName = "theme" } = props;
|
||||
|
||||
return (
|
||||
<label
|
||||
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mb-0 sm:mr-4"
|
||||
htmlFor={`theme-${variant}`}
|
||||
data-testid={`theme-${variant}`}>
|
||||
htmlFor={`${fieldName}-${variant}`}
|
||||
data-testid={`${fieldName}-${variant}`}>
|
||||
<input
|
||||
className="peer absolute left-8 top-8"
|
||||
type="radio"
|
||||
value={value}
|
||||
id={`theme-${variant}`}
|
||||
id={`${fieldName}-${variant}`}
|
||||
defaultChecked={defaultChecked}
|
||||
{...register("theme")}
|
||||
{...register(fieldName)}
|
||||
/>
|
||||
<div className="ring-inverted relative z-10 rounded-lg ring-offset-2 transition-all peer-checked:ring-2">
|
||||
<img
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client";
|
||||
import getBrandColours from "@calcom/lib/getBrandColours";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
@@ -329,13 +330,14 @@ type LayoutProps = {
|
||||
hideHeadingOnMobile?: boolean;
|
||||
};
|
||||
|
||||
const useBrandColors = () => {
|
||||
const useAppTheme = () => {
|
||||
const { data: user } = useMeQuery();
|
||||
const brandTheme = getBrandColours({
|
||||
lightVal: user?.brandColor,
|
||||
darkVal: user?.darkBrandColor,
|
||||
});
|
||||
useCalcomTheme(brandTheme);
|
||||
useTheme(user?.appTheme);
|
||||
};
|
||||
|
||||
const KBarWrapper = ({ children, withKBar = false }: { withKBar: boolean; children: React.ReactNode }) =>
|
||||
@@ -361,9 +363,7 @@ export default function Shell(props: LayoutProps) {
|
||||
// if a page is unauthed and isPublic is true, the redirect does not happen.
|
||||
useRedirectToLoginIfUnauthenticated(props.isPublic);
|
||||
useRedirectToOnboardingIfNeeded();
|
||||
// System Theme is automatically supported using ThemeProvider. If we intend to use user theme throughout the app we need to uncomment this.
|
||||
// useTheme(profile.theme);
|
||||
useBrandColors();
|
||||
useAppTheme();
|
||||
|
||||
return !props.isPublic ? (
|
||||
<KBarWrapper withKBar>
|
||||
@@ -462,8 +462,10 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||
</span>
|
||||
{!small && (
|
||||
<span className="flex flex-grow items-center gap-2">
|
||||
<span className="line-clamp-1 flex-grow text-sm leading-none">
|
||||
<span className="text-emphasis block font-medium">{user.name || "Nameless User"}</span>
|
||||
<span className="w-24 flex-shrink-0 text-sm leading-none">
|
||||
<span className="text-emphasis block truncate font-medium">
|
||||
{user.name || "Nameless User"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="group-hover:text-subtle text-muted h-4 w-4 flex-shrink-0 rtl:mr-4"
|
||||
@@ -729,6 +731,7 @@ const NavigationItem: React.FC<{
|
||||
<Fragment>
|
||||
<Tooltip side="right" content={t(item.name)} className="lg:hidden">
|
||||
<Link
|
||||
data-test-id={item.name}
|
||||
href={item.href}
|
||||
aria-label={t(item.name)}
|
||||
className={classNames(
|
||||
|
||||
@@ -30,6 +30,7 @@ type MembershipOption = {
|
||||
|
||||
const editSchema = z.object({
|
||||
name: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string().email(),
|
||||
avatar: z.string(),
|
||||
bio: z.string(),
|
||||
@@ -59,6 +60,7 @@ export function EditForm({
|
||||
resolver: zodResolver(editSchema),
|
||||
defaultValues: {
|
||||
name: selectedUser?.name ?? "",
|
||||
username: selectedUser?.username ?? "",
|
||||
email: selectedUser?.email ?? "",
|
||||
avatar: avatarUrl,
|
||||
bio: selectedUser?.bio ?? "",
|
||||
@@ -121,6 +123,7 @@ export function EditForm({
|
||||
mutation.mutate({
|
||||
userId: selectedUser?.id ?? "",
|
||||
role: values.role,
|
||||
username: values.username,
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
avatar: values.avatar,
|
||||
@@ -157,6 +160,7 @@ export function EditForm({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col space-y-3">
|
||||
<TextField label={t("username")} {...form.register("username")} />
|
||||
<TextField label={t("name")} {...form.register("name")} />
|
||||
<TextField label={t("email")} {...form.register("email")} />
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ const joinMultipleNames = (names: string[] = []) => {
|
||||
return `${names.length > 0 ? `${names.join(", ")} & ${lastName}` : lastName}`;
|
||||
};
|
||||
|
||||
const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CAL_URL}${url}`);
|
||||
|
||||
/**
|
||||
* Test urls:
|
||||
* 1. 1 user http://localhost:3000/api/social/og/image?type=meeting&title=super%20long%20event%20title%20for%20testing%20purposes&meetingProfileName=Pro%20Example&meetingImage=http://localhost:3000/pro/avatar.png&names=Pro%20Example&usernames=pro
|
||||
@@ -62,7 +64,7 @@ export const constructMeetingImage = (
|
||||
`?type=meeting`,
|
||||
`&title=${encodeURIComponent(title)}`,
|
||||
`&meetingProfileName=${encodeURIComponent(profile.name)}`,
|
||||
profile.image && `&meetingImage=${encodeURIComponent(profile.image)}`,
|
||||
profile.image && `&meetingImage=${encodeURIComponent(makeAbsoluteUrl(profile.image))}`,
|
||||
`${users.map((user) => `&names=${encodeURIComponent(user.name)}`).join("")}`,
|
||||
`${users.map((user) => `&usernames=${encodeURIComponent(user.username)}`).join("")}`,
|
||||
// Joining a multiline string for readability.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export async function renewSelectedCalendarCredentialId(
|
||||
selectedCalendarWhereUnique: {
|
||||
userId: number;
|
||||
integration: string;
|
||||
externalId: string;
|
||||
},
|
||||
credentialId: number
|
||||
): Promise<boolean> /* True if renewed, false if not */ {
|
||||
const selectedCalendar = await prisma.selectedCalendar.findFirst({
|
||||
where: {
|
||||
...selectedCalendarWhereUnique,
|
||||
credentialId: null,
|
||||
},
|
||||
});
|
||||
if (selectedCalendar) {
|
||||
await prisma.selectedCalendar.update({
|
||||
where: {
|
||||
userId_integration_externalId: selectedCalendarWhereUnique,
|
||||
},
|
||||
data: {
|
||||
credentialId: credentialId,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export const SEO_IMG_DEFAULT = `${CAL_URL}/og-image.png`;
|
||||
// parameters you pass to the /api/social/og/image endpoint, you wrap them in encodeURIComponent
|
||||
// as well, otherwise the URL won't be valid.
|
||||
export const SEO_IMG_OGIMG = `${CAL_URL}/_next/image?w=1200&q=100&url=${encodeURIComponent(
|
||||
"/api/social/og/image"
|
||||
`/api/social/og/image`
|
||||
)}`;
|
||||
export const SEO_IMG_OGIMG_VIDEO = `${CAL_URL}/video-og-image.png`;
|
||||
export const IS_STRIPE_ENABLED = !!(
|
||||
|
||||
@@ -46,7 +46,13 @@ export function processWorkingHours({
|
||||
end = end.add(offsetDiff, "minute");
|
||||
|
||||
const startResult = dayjs.max(start, dateFrom);
|
||||
const endResult = dayjs.min(end, dateTo.tz(timeZone));
|
||||
let endResult = dayjs.min(end, dateTo.tz(timeZone));
|
||||
|
||||
// INFO: We only allow users to set availability up to 11:59PM which ends up not making them available
|
||||
// up to midnight.
|
||||
if (endResult.hour() === 23 && endResult.minute() === 59) {
|
||||
endResult = endResult.add(1, "minute");
|
||||
}
|
||||
|
||||
if (endResult.isBefore(startResult)) {
|
||||
// if an event ends before start, it's not a result.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProfileRepository } from "./repository/profile";
|
||||
|
||||
export async function checkRegularUsername(_username: string, currentOrgDomain?: string | null) {
|
||||
const username = slugify(_username);
|
||||
|
||||
const premium = !!process.env.NEXT_PUBLIC_IS_E2E && username.length < 5;
|
||||
|
||||
const profiles = currentOrgDomain
|
||||
@@ -13,7 +14,7 @@ export async function checkRegularUsername(_username: string, currentOrgDomain?:
|
||||
})
|
||||
: null;
|
||||
|
||||
const user = profiles ? profiles[0].user : null;
|
||||
const user = profiles?.length ? profiles[0].user : null;
|
||||
|
||||
if (user) {
|
||||
return {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const buildBooking = (booking?: Partial<Booking>): Booking => {
|
||||
uid,
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
idempotencyKey: null,
|
||||
userPrimaryEmail: null,
|
||||
title: faker.lorem.sentence(),
|
||||
description: faker.lorem.paragraph(),
|
||||
@@ -234,6 +235,7 @@ export const buildUser = <T extends Partial<UserPayload>>(
|
||||
selectedCalendars: [],
|
||||
startTime: 0,
|
||||
theme: null,
|
||||
appTheme: null,
|
||||
timeFormat: null,
|
||||
trialEndsAt: null,
|
||||
twoFactorEnabled: false,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export function bookingIdempotencyKeyExtension() {
|
||||
return Prisma.defineExtension({
|
||||
query: {
|
||||
booking: {
|
||||
async create({ args, query }) {
|
||||
const uniqueEmailJoinInput: string[] = [];
|
||||
if (args.data.attendees?.create && !Array.isArray(args.data.attendees?.create)) {
|
||||
uniqueEmailJoinInput.push(args.data.attendees?.create.email);
|
||||
}
|
||||
if (args.data.attendees?.createMany && Array.isArray(args.data.attendees?.createMany.data)) {
|
||||
uniqueEmailJoinInput.push(...args.data.attendees?.createMany.data.map((record) => record.email));
|
||||
}
|
||||
const idempotencyKey = uuidv5(
|
||||
`${
|
||||
args.data.eventType?.connect?.id
|
||||
}.${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${uniqueEmailJoinInput.join(
|
||||
","
|
||||
)}`,
|
||||
uuidv5.URL
|
||||
);
|
||||
args.data.idempotencyKey = idempotencyKey;
|
||||
return query(args);
|
||||
},
|
||||
async update({ args, query }) {
|
||||
if (args.data.status === BookingStatus.CANCELLED || args.data.status === BookingStatus.REJECTED) {
|
||||
args.data.idempotencyKey = null;
|
||||
}
|
||||
return query(args);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
|
||||
import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client";
|
||||
import { withAccelerate } from "@prisma/extension-accelerate";
|
||||
|
||||
import { bookingIdempotencyKeyExtension } from "./extensions/booking-idempotency-key";
|
||||
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
|
||||
import { bookingReferenceMiddleware } from "./middleware";
|
||||
|
||||
@@ -21,6 +22,7 @@ const prismaWithoutClientExtensions =
|
||||
export const customPrisma = (options?: Prisma.PrismaClientOptions) =>
|
||||
new PrismaClientWithoutExtension({ ...prismaOptions, ...options })
|
||||
.$extends(excludePendingPaymentsExtension())
|
||||
.$extends(bookingIdempotencyKeyExtension())
|
||||
.$extends(withAccelerate());
|
||||
|
||||
// If any changed on middleware server restart is required
|
||||
@@ -31,6 +33,7 @@ bookingReferenceMiddleware(prismaWithoutClientExtensions);
|
||||
// Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact<string | Date | null | undefined, string | Date | null | undefined>'`
|
||||
const prismaWithClientExtensions = prismaWithoutClientExtensions
|
||||
.$extends(excludePendingPaymentsExtension())
|
||||
.$extends(bookingIdempotencyKeyExtension())
|
||||
.$extends(withAccelerate());
|
||||
|
||||
export const prisma = globalForPrisma.prismaWithClientExtensions || prismaWithClientExtensions;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "appTheme" TEXT;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[idempotencyKey]` on the table `Booking` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Booking" ADD COLUMN "idempotencyKey" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Booking_idempotencyKey_key" ON "Booking"("idempotencyKey");
|
||||
@@ -221,7 +221,9 @@ model User {
|
||||
// </DEPRECATED>
|
||||
bufferTime Int @default(0)
|
||||
hideBranding Boolean @default(false)
|
||||
// TODO: should be renamed since it only affects the booking page
|
||||
theme String?
|
||||
appTheme String?
|
||||
createdDate DateTime @default(now()) @map(name: "created")
|
||||
trialEndsAt DateTime?
|
||||
eventTypes EventType[] @relation("user_eventtype")
|
||||
@@ -460,6 +462,8 @@ enum BookingStatus {
|
||||
model Booking {
|
||||
id Int @id @default(autoincrement())
|
||||
uid String @unique
|
||||
// (optional) UID based on slot start/end time & email against duplicates
|
||||
idempotencyKey String? @unique
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
// User's email at the time of booking
|
||||
|
||||
@@ -38,13 +38,9 @@ export async function createUserAndEventType({
|
||||
appId: string;
|
||||
} | null)[];
|
||||
}) {
|
||||
const { password, ...restOfUser } = user;
|
||||
const userData = {
|
||||
...user,
|
||||
password: {
|
||||
create: {
|
||||
hash: await hashPassword(user.password),
|
||||
},
|
||||
},
|
||||
...restOfUser,
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: user.completedOnboarding ?? true,
|
||||
locale: "en",
|
||||
@@ -69,6 +65,21 @@ export async function createUserAndEventType({
|
||||
create: userData,
|
||||
});
|
||||
|
||||
await prisma.userPassword.upsert({
|
||||
where: { userId: theUser.id },
|
||||
update: {
|
||||
hash: await hashPassword(user.password),
|
||||
},
|
||||
create: {
|
||||
hash: await hashPassword(user.password),
|
||||
user: {
|
||||
connect: {
|
||||
id: theUser.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`👤 Upserted '${user.username}' with email "${user.email}" & password "${user.password}". Booking page 👉 ${process.env.NEXT_PUBLIC_WEBAPP_URL}/${user.username}`
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
||||
defaultScheduleId: true,
|
||||
bufferTime: true,
|
||||
theme: true,
|
||||
appTheme: true,
|
||||
createdDate: true,
|
||||
hideBranding: true,
|
||||
twoFactorEnabled: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
|
||||
bio: user.bio,
|
||||
weekStart: user.weekStart,
|
||||
theme: user.theme,
|
||||
appTheme: user.appTheme,
|
||||
hideBranding: user.hideBranding,
|
||||
metadata: user.metadata,
|
||||
defaultBookerLayouts: user.defaultBookerLayouts,
|
||||
|
||||
@@ -6,7 +6,7 @@ import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
|
||||
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -63,7 +63,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
const { user } = ctx;
|
||||
const userMetadata = handleUserMetadata({ ctx, input });
|
||||
const locale = input.locale || user.locale;
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
...input,
|
||||
@@ -135,10 +135,8 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
hasEmailBeenChanged && user.identityProvider !== IdentityProvider.CAL;
|
||||
const hasEmailChangedOnCalProvider = hasEmailBeenChanged && user.identityProvider === IdentityProvider.CAL;
|
||||
|
||||
const sendEmailVerification = flags["email-verification"];
|
||||
|
||||
if (hasEmailBeenChanged) {
|
||||
if (sendEmailVerification && hasEmailChangedOnCalProvider) {
|
||||
if (emailVerification && hasEmailChangedOnCalProvider) {
|
||||
// Set metadata of the user so we can set it to this updated email once it is confirmed
|
||||
data.metadata = {
|
||||
...userMetadata,
|
||||
@@ -284,12 +282,12 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions)
|
||||
|
||||
return {
|
||||
...input,
|
||||
email: sendEmailVerification && hasEmailChangedOnCalProvider ? user.email : input.email,
|
||||
email: emailVerification && hasEmailChangedOnCalProvider ? user.email : input.email,
|
||||
signOutUser,
|
||||
passwordReset,
|
||||
avatarUrl: updatedUser.avatarUrl,
|
||||
hasEmailBeenChanged,
|
||||
sendEmailVerification,
|
||||
sendEmailVerification: emailVerification,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ZUpdateProfileInputSchema = z.object({
|
||||
brandColor: z.string().optional(),
|
||||
darkBrandColor: z.string().optional(),
|
||||
theme: z.string().optional().nullable(),
|
||||
appTheme: z.string().optional().nullable(),
|
||||
completedOnboarding: z.boolean().optional(),
|
||||
locale: z.string().optional(),
|
||||
timeFormat: z.number().optional(),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Prisma, PrismaPromise, User, Membership, Profile } from "@prisma/client";
|
||||
|
||||
import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername";
|
||||
import { isOrganisationAdmin, isOrganisationOwner } from "@calcom/lib/server/queries/organisations";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { uploadAvatar } from "@calcom/trpc/server/routers/loggedInViewer/updateProfile.handler";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -32,6 +36,7 @@ const applyRoleToAllTeams = async (userId: number, teamIds: number[], role: Memb
|
||||
export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
|
||||
const { user } = ctx;
|
||||
const { id: userId, organizationId } = user;
|
||||
|
||||
if (!organizationId)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be a memeber of an organizaiton" });
|
||||
|
||||
@@ -74,30 +79,58 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!requestedMember)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "User does not belong to your organization" });
|
||||
|
||||
let avatar = input.avatar;
|
||||
if (input.avatar) {
|
||||
avatar = await resizeBase64Image(input.avatar);
|
||||
const hasUsernameUpdated = input.username !== requestedMember.user.username;
|
||||
|
||||
if (input.username && hasUsernameUpdated && user.profile.organization?.slug) {
|
||||
const checkRegularUsernameRes = await checkRegularUsername(
|
||||
input.username,
|
||||
user.profile.organization.slug
|
||||
);
|
||||
if (!checkRegularUsernameRes.available) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: checkRegularUsernameRes.message });
|
||||
}
|
||||
}
|
||||
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
bio: input.bio,
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
timeZone: input.timeZone,
|
||||
username: input.username,
|
||||
avatar: undefined,
|
||||
};
|
||||
|
||||
if (input.avatar && input.avatar.startsWith("data:image/png;base64,")) {
|
||||
const avatar = await resizeBase64Image(input.avatar);
|
||||
data.avatarUrl = await uploadAvatar({
|
||||
avatar,
|
||||
userId: user.id,
|
||||
});
|
||||
data.avatar = avatar;
|
||||
}
|
||||
if (input.avatar === "") {
|
||||
data.avatarUrl = null;
|
||||
data.avatar = null;
|
||||
}
|
||||
|
||||
// Update user
|
||||
await prisma.$transaction([
|
||||
const transactions: PrismaPromise<User | Membership | Profile>[] = [
|
||||
prisma.user.update({
|
||||
where: {
|
||||
id: input.userId,
|
||||
},
|
||||
data: {
|
||||
bio: input.bio,
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
timeZone: input.timeZone,
|
||||
avatar,
|
||||
},
|
||||
data,
|
||||
}),
|
||||
prisma.membership.update({
|
||||
where: {
|
||||
@@ -110,7 +143,25 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => {
|
||||
role: input.role,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
];
|
||||
|
||||
if (hasUsernameUpdated) {
|
||||
transactions.push(
|
||||
prisma.profile.update({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: input.userId,
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
username: input.username,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(transactions);
|
||||
|
||||
if (input.role === MembershipRole.ADMIN || input.role === MembershipRole.OWNER) {
|
||||
const teamIds = requestedMember.team.children
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const ZUpdateUserInputSchema = z.object({
|
||||
userId: z.number(),
|
||||
username: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
|
||||
@@ -515,22 +515,20 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
|
||||
);
|
||||
if (occupiedSeats?.length) {
|
||||
const addedToCurrentSeats: string[] = [];
|
||||
if (typeof availabilityCheckProps.currentSeats !== undefined) {
|
||||
availabilityCheckProps.currentSeats = (availabilityCheckProps.currentSeats as CurrentSeats).map(
|
||||
(item) => {
|
||||
const attendees =
|
||||
occupiedSeats.filter(
|
||||
(seat) => seat.slotUtcStartDate.toISOString() === item.startTime.toISOString()
|
||||
)?.length || 0;
|
||||
if (attendees) addedToCurrentSeats.push(item.startTime.toISOString());
|
||||
return {
|
||||
...item,
|
||||
_count: {
|
||||
attendees: item._count.attendees + attendees,
|
||||
},
|
||||
};
|
||||
}
|
||||
) as CurrentSeats;
|
||||
if (typeof availabilityCheckProps.currentSeats !== "undefined") {
|
||||
availabilityCheckProps.currentSeats = availabilityCheckProps.currentSeats.map((item) => {
|
||||
const attendees =
|
||||
occupiedSeats.filter(
|
||||
(seat) => seat.slotUtcStartDate.toISOString() === item.startTime.toISOString()
|
||||
)?.length || 0;
|
||||
if (attendees) addedToCurrentSeats.push(item.startTime.toISOString());
|
||||
return {
|
||||
...item,
|
||||
_count: {
|
||||
attendees: item._count.attendees + attendees,
|
||||
},
|
||||
};
|
||||
});
|
||||
occupiedSeats = occupiedSeats.filter(
|
||||
(item) => !addedToCurrentSeats.includes(item.slotUtcStartDate.toISOString())
|
||||
);
|
||||
|
||||
@@ -23,17 +23,20 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => {
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) await cancelTeamSubscriptionFromStripe(input.teamId);
|
||||
|
||||
// delete all memberships
|
||||
await prisma.membership.deleteMany({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
},
|
||||
});
|
||||
const deletedTeam = await prisma.$transaction(async (tx) => {
|
||||
// delete all memberships
|
||||
await tx.membership.deleteMany({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const deletedTeam = await prisma.team.delete({
|
||||
where: {
|
||||
id: input.teamId,
|
||||
},
|
||||
const deletedTeam = await tx.team.delete({
|
||||
where: {
|
||||
id: input.teamId,
|
||||
},
|
||||
});
|
||||
return deletedTeam;
|
||||
});
|
||||
|
||||
const deletedTeamMetadata = teamMetadataSchema.parse(deletedTeam.metadata);
|
||||
|
||||
@@ -285,7 +285,7 @@ export async function createNewUsersConnectToOrgIfExists({
|
||||
data: {
|
||||
teamId: parentId,
|
||||
userId: createdUser.id,
|
||||
role: input.role as MembershipRole,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: autoAccept,
|
||||
},
|
||||
});
|
||||
@@ -332,7 +332,7 @@ export async function createMemberships({
|
||||
accepted,
|
||||
teamId: parentId,
|
||||
userId: invitee.id,
|
||||
role: input.role as MembershipRole,
|
||||
role: MembershipRole.MEMBER,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
|
||||
@@ -43,25 +43,26 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
|
||||
message: "You can not remove yourself from a team you own.",
|
||||
});
|
||||
|
||||
const membership = await ctx.prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
// remove user as host from team events associated with this membership
|
||||
await ctx.prisma.host.deleteMany({
|
||||
where: {
|
||||
userId: input.memberId,
|
||||
eventType: {
|
||||
teamId: input.teamId,
|
||||
const [membership] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.membership.delete({
|
||||
where: {
|
||||
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
||||
},
|
||||
},
|
||||
});
|
||||
include: {
|
||||
user: true,
|
||||
team: true,
|
||||
},
|
||||
}),
|
||||
// remove user as host from team events associated with this membership
|
||||
ctx.prisma.host.deleteMany({
|
||||
where: {
|
||||
userId: input.memberId,
|
||||
eventType: {
|
||||
teamId: input.teamId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (input.isOrg) {
|
||||
log.debug("Removing a member from the organization");
|
||||
|
||||
@@ -81,6 +81,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||
onClickAddon,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
t: __t,
|
||||
dataTestid,
|
||||
...passThrough
|
||||
} = props;
|
||||
|
||||
@@ -114,6 +115,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||
</Addon>
|
||||
)}
|
||||
<Input
|
||||
data-testid={`${dataTestid}-input` ?? "input-field"}
|
||||
id={id}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type InputFieldProps = {
|
||||
containerClassName?: string;
|
||||
showAsteriskIndicator?: boolean;
|
||||
t?: (key: string) => string;
|
||||
dataTestid?: string;
|
||||
onClickAddon?: () => void;
|
||||
} & React.ComponentProps<typeof Input> & {
|
||||
labelProps?: React.ComponentProps<typeof Label>;
|
||||
|
||||