Compare commits

...
41 Commits
Author SHA1 Message Date
Udit Takkar 5d1976295c fix: hover state (#13862)
release.yml / release (push) Canceled after 0s
2024-02-27 14:32:59 +00:00
Morgan e9b1fa1f50 v3.8.4 2024-02-27 16:31:14 +02:00
Udit Takkar 80080dda7a fix: missing userPrimaryEmail in workflows (#13875)
* fix: missing userPrimaryEmail in workflows

* chore

* fix: type err
2024-02-27 09:17:34 -05:00
Udit Takkar 27c25e1f95 chore: use transaction (#13874)
* fix: use transaction

* chore
2024-02-27 10:17:46 -03:00
Shyam Raghuwanshi 858be0f5d8 fix ics-feedcalendar logo not showing (#13886) 2024-02-27 11:43:25 +00:00
Hariom Balhara 68d0090f50 Make sure that state doesnt reset (#13880) 2024-02-27 07:51:11 -03:00
Udit Takkar a43fa13b11 fix: infinite rendering in event setup tab (#13877) 2024-02-26 22:18:58 +00:00
Pritam 2bf6da30b3 fix periodDays dirtystate (#13851) 2024-02-26 19:17:53 +00:00
Alex van Andel d85e0b51e4 fix: Prevent duplicate bookings with idempotency key (#13865)
* fix: Prevent duplicate bookings with idempotency key

* Add idempotencyKey to builder.ts type fix

* Add tests for new idempotency key (booking create)

* Add endTime to idempotency key, it should be possible to book different durations

* Move reschedule before the create so the idempotency key is unset
2024-02-26 18:33:22 +00:00
Carina WollendorferandCarinaWolli 55a317d677 fix: video call url in event location for recurring events (#13842)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-26 10:44:07 -05:00
Crowdin Bot 5fff8fc58c New Crowdin translations by Github Action 2024-02-26 14:36:48 +00:00
Udit Takkar 0d68767d0a fix: seed script (#13868) 2024-02-26 14:31:25 +00:00
Shyam Raghuwanshi 2051e87436 fix: username overflow (#13832) 2024-02-23 23:01:43 +00:00
Udit Takkar 77a157a12b fix: show go back button after expiry (#13833) 2024-02-23 22:20:46 +00:00
Omar López c4f25f2c44 build: deploy script for gradual migration (#13834) 2024-02-23 12:38:13 -07:00
Peer Richelsen d967facb63 feat: demodesk app (#13733) 2024-02-23 10:14:37 -05:00
c6dad39fb0 test: Create E2E tests for Analytics Apps (teste2e-analyticsApps) (#13695)
* refactor

* refactor

* refactor

* refactor

* fix failing check

* Update apps.ts

* fix failing check

* fix failing check

* fix failing check

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
2024-02-22 21:54:49 -03:00
zomars eec7f4d7d4 Update yarn.lock 2024-02-22 15:00:52 -07:00
Udit Takkar 95d5824ba9 fix: og image (#13829) 2024-02-22 10:46:33 -07:00
Somay Chauhan 5f95a4d041 fix: New Team Members Join Org as OWNER/ADMIN (#13822) 2024-02-22 11:46:28 -05:00
Keith Williams 8e97444129 v3.8.3
release.yml / release (push) Canceled after 0s
2024-02-22 10:16:18 -03:00
Crowdin Bot 8d8264b5f5 New Crowdin translations by Github Action 2024-02-22 12:48:57 +00:00
2ad81221d0 chore: Added a period after undone (#13824)
* Added period after undone

* chore: undo yarn.lock

* Revert yarn.lock

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-02-22 09:44:43 -03:00
3d1ac4523f fix: Update credentialId when null for a given selectedCalendar (#13735)
* fix: Update credentialId when null for a given selectedCalendar

* Add returnTo return path

* chore

* chore: undo return

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-22 12:05:46 +00:00
Alex van Andel b38807cb0f fix: Type check failure in occupiedSeats (#13805) 2024-02-22 10:27:55 +00:00
Peer Richelsen 11fb387370 feat: let org admins change usernames (#13384) 2024-02-22 07:48:46 +00:00
Crowdin Bot 1546f9115d New Crowdin translations by Github Action 2024-02-22 07:42:13 +00:00
Peer RichelsenandUdit Takkar 40ecd5dd9d chore: removing wrong translations about {{consoleUrl}} (#13802)
* removing wrong translations

* fixed source string

* revert: eng translation

* chore: remove translation

* chore

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-22 07:38:47 +00:00
Omar López ac6ac14a23 fix: more og avatar fixes (#13817) 2024-02-22 07:25:31 +00:00
Bailey Pumfleet c6534160e4 chore: Shorten "aplicaciones instaladas" to "apps instaladas" (#13806) 2024-02-22 07:21:43 +00:00
Keith Williams aa2db562b8 fix: Bookings API (#13815) 2024-02-21 16:05:53 -07:00
Carina WollendorferandCarinaWolli b7ed314be2 fix: log more info on sendEmail error (#13813)
* add from and subject to error message

* remove comment

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-21 20:59:45 +00:00
Udit TakkarandCarinaWolli d102268b33 fix: sdk action events (#13755)
* fix: sdk action events

* fix: type error

* fix: type err

* fix: reschedule event

* chore: remove timezone

* fix: feedback

* chore: use bookingInfo

* fix type error

* fix type errror

* add cancellation reason

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-02-21 14:25:24 -05:00
Somay ChauhanandUdit Takkar f4397d6593 fix: error on signup page while inviting a member to an org (#13807)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-21 18:49:51 +00:00
Keith Williams 192603a1fa fix: No available users found near midnight (#13801)
* fix: No available users found near midnight

* Update date-ranges.ts
2024-02-21 10:58:38 -07:00
Keith Williams a3d969680f chore: Including original start and end dates from request in log (#13800) 2024-02-21 13:22:42 -03:00
Crowdin Bot adc3b3aec6 New Crowdin translations by Github Action 2024-02-21 15:27:50 +00:00
sean-brydonandUdit Takkar 92286e2453 feat: signup redirect query param after onboarding (#13761)
* feat:redirect - if signup url has url to goto

* fix: dont throw if url is invalid - just dont set

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-02-21 20:53:09 +05:30
51b2a15cb0 feat: Settings for dashboard theme that is separate from system (#13779)
* feat(db): add new col: user appTheme

* feat(event-types page): apply appTheme color

* add appTheme for trpc

* feat: mutate main page theme

* feat: add english translation for app theme update section

* modify keys in common (en)

* add woring

* Revert yarn.lock to its state before unintended changes

* feat(i18n): add back the i18n string (for dark/light mode switching)

* refactor: refactor ThemeLabel

* fix: add new user field "appTheme" to test files

* chore: modify TODO comment

* chore: update common.json (en) for "theme"

since we had appTheme now, I think it's better to update the wording here to avoid confusion

* fix: update button's data-testid to fix e2e test error

* chore: remove comment

* fix: fix brand-color-not-apply bug

* solve type error

* fix: fix type error

* fix: use correct storageKey for booker / dashboard

* fix: tidy up

* fix: skeleton

---------

Co-authored-by: swh00tw <a6140000@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-02-21 13:02:40 +00:00
Morgan fa9a099dd6 fix: prefillFormParams guest type (#13798) 2024-02-21 12:36:48 +00:00
ErikandOmar López 83ceb46cca chore: Refactor feature flags (#13734)
Co-authored-by: Omar López <zomars@me.com>
2024-02-20 21:49:48 -03:00
103 changed files with 1287 additions and 258 deletions
+4
View File
@@ -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,
+20 -1
View File
@@ -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");
+1 -1
View File
@@ -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",
}
+5 -3
View File
@@ -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 && (
+3 -3
View File
@@ -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;
+14 -3
View File
@@ -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 -1
View File
@@ -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>
+7
View File
@@ -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) => {
+1 -1
View File
@@ -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);
});
});
+29
View File
@@ -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"];
+29
View File
@@ -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();
},
};
}
+6
View File
@@ -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}} 上有活动时实时接收会议数据",
+47
View File
@@ -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
+16
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export { default as add } from "./add";
+25
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
export * as api from "./api";
+14
View File
@@ -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."
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

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]);
+11 -4
View File
@@ -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>;
+5 -5
View File
@@ -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 };
}
+2 -3
View File
@@ -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,
+52
View File
@@ -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;
};
+6 -5
View File
@@ -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
+9 -6
View File
@@ -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")} />
+3 -1
View File
@@ -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.
+29
View File
@@ -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;
}
+1 -1
View File
@@ -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 = !!(
+7 -1
View File
@@ -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.
+2 -1
View File
@@ -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 {
+2
View File
@@ -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);
},
},
},
});
}
+3
View File
@@ -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");
+4
View File
@@ -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
+17 -6
View File
@@ -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}
+1
View File
@@ -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>;

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