From 96af17d8d7b047284fd026957ebf2baae42753aa Mon Sep 17 00:00:00 2001
From: alannnc
Date: Wed, 10 Jan 2024 10:04:02 -0700
Subject: [PATCH] feat: OOO booking-forwarding (#12653)
* feat-profile-forwarding
* fix for promises of handling
* fix badge success color
* clean up
* fix suggested changes
* Changed design on booking-forward pages and moar test
* taking care of suggested changes, trpc errors and code cleaning
* improve text
* fix conflicting data-testid
* fix unique data-testid
* fix error css-global, email button styles, error conditional
* rename files to match functionality, remove away ui
* Add translations and migration
* remove log
* small fixes + improvements
* fix styles to match new design
* merge fixes
* Fix styles dark mode
* Solving merge conflicts from earlier
* Fix/change test to match new elements
* use trash icon button insted of dots (design issues)
* only send email if toUserId is set
* Fix date picker dark mode
* merge with remote
* removed status field from table and email its now for notify
* small text improvement in email
* check for team plan not paid plan
* fixes and clean up due to removing status
* fix old send request name to new behaviour
* more naming improvements and text
* remove status from handle-type
* code clean up
* fix type error
---------
Co-authored-by: Peer Richelsen
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli
---
.../DateRangePicker/DateSelect.css | 110 ++++++++
.../out-of-office/DateRangePicker/index.tsx | 47 ++++
apps/web/pages/[user].tsx | 49 +++-
apps/web/pages/[user]/[type].tsx | 19 +-
apps/web/pages/availability/index.tsx | 45 ++--
.../my-account/out-of-office/index.tsx | 241 ++++++++++++++++++
apps/web/playwright/out-of-office.e2e.ts | 101 ++++++++
apps/web/public/static/locales/en/common.json | 30 +++
packages/emails/email-manager.ts | 6 +
.../BookingRedirectEmailNotification.tsx | 33 +++
packages/emails/src/templates/index.ts | 1 +
.../booking-redirect-notification.ts | 36 +++
.../features/booking-redirect/handle-type.ts | 71 ++++++
.../features/booking-redirect/handle-user.ts | 59 +++++
packages/features/bookings/Booker/Booker.tsx | 29 ++-
.../features/insights/filters/DateSelect.css | 62 +++--
.../settings/layouts/SettingsLayout.tsx | 1 +
packages/features/shell/Shell.tsx | 7 +-
.../migration.sql | 34 +++
packages/prisma/schema.prisma | 21 ++
.../server/routers/loggedInViewer/_router.tsx | 45 +++-
.../loggedInViewer/outOfOffice.handler.ts | 239 +++++++++++++++++
.../loggedInViewer/outOfOffice.schema.ts | 15 ++
packages/ui/index.tsx | 10 +
24 files changed, 1249 insertions(+), 62 deletions(-)
create mode 100644 apps/web/components/out-of-office/DateRangePicker/DateSelect.css
create mode 100644 apps/web/components/out-of-office/DateRangePicker/index.tsx
create mode 100644 apps/web/pages/settings/my-account/out-of-office/index.tsx
create mode 100644 apps/web/playwright/out-of-office.e2e.ts
create mode 100644 packages/emails/src/templates/BookingRedirectEmailNotification.tsx
create mode 100644 packages/emails/templates/booking-redirect-notification.ts
create mode 100644 packages/features/booking-redirect/handle-type.ts
create mode 100644 packages/features/booking-redirect/handle-user.ts
create mode 100644 packages/prisma/migrations/20240109041925_add_out_of_office_entry_table/migration.sql
create mode 100644 packages/trpc/server/routers/loggedInViewer/outOfOffice.handler.ts
create mode 100644 packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts
diff --git a/apps/web/components/out-of-office/DateRangePicker/DateSelect.css b/apps/web/components/out-of-office/DateRangePicker/DateSelect.css
new file mode 100644
index 0000000000..6ea26081a2
--- /dev/null
+++ b/apps/web/components/out-of-office/DateRangePicker/DateSelect.css
@@ -0,0 +1,110 @@
+.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
+ box-shadow: none;
+ width: 100%;
+ background-color: transparent;
+ }
+
+ /* Media query for screens larger than 768px */
+ @media (max-width: 639) {
+ .custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
+ max-width: 400px;
+ }
+ }
+
+ .recharts-cartesian-grid-horizontal line{
+ @apply stroke-emphasis
+ }
+
+ .tremor-DateRangePicker-button button{
+ @apply !h-9 !max-h-9 border-default hover:border-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarButton,
+ .tremor-DateRangePicker-dropdownButton {
+ @apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
+ }
+
+ .tremor-DateRangePicker-dropdownModal{
+ @apply divide-none
+ }
+
+ .tremor-DropdownItem-root{
+ @apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarButtonText,
+ .tremor-DateRangePicker-dropdownButtonText {
+ @apply text-default;
+ }
+
+ .tremor-DateRangePicker-calendarHeaderText{
+ @apply !text-default
+ }
+
+ .tremor-DateRangePicker-calendarHeader svg{
+ @apply text-default
+ }
+
+ .tremor-DateRangePicker-calendarHeader button{
+ @apply hover:bg-emphasis shadow-none focus:ring-0
+ }
+
+
+ .tremor-DateRangePicker-calendarHeader button:hover svg{
+ @apply text-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarButtonIcon{
+ @apply text-default
+ }
+
+ .tremor-DateRangePicker-calendarModal,
+ .tremor-DateRangePicker-dropdownModal {
+ @apply bg-default border-subtle shadow-dropdown
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate button{
+ @apply text-default hover:bg-emphasis
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate button:disabled,
+ .tremor-DateRangePicker-calendarBodyDate button[disabled]{
+ @apply opacity-25
+ }
+
+ .tremor-DateRangePicker-calendarHeader button{
+ @apply border-default text-default
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate .bg-gray-100{
+ @apply bg-subtle
+ }
+
+ .tremor-DateRangePicker-calendarBodyDate .bg-gray-500{
+ @apply !bg-brand-default text-inverted
+ }
+
+
+ .tremor-Card-root {
+ @apply p-5 bg-default;
+ }
+
+ .tremor-TableCell-root {
+ @apply pl-0;
+ }
+
+ .recharts-responsive-container {
+ @apply -mx-4;
+ }
+ .tremor-Card-root > p {
+ @apply mb-2 text-base font-semibold;
+ }
+
+ .tremor-Legend-legendItem {
+ @apply ml-2;
+ }
+
+ .tremor-TableBody-root {
+ @apply divide-subtle;
+ }
+
\ No newline at end of file
diff --git a/apps/web/components/out-of-office/DateRangePicker/index.tsx b/apps/web/components/out-of-office/DateRangePicker/index.tsx
new file mode 100644
index 0000000000..5a20178822
--- /dev/null
+++ b/apps/web/components/out-of-office/DateRangePicker/index.tsx
@@ -0,0 +1,47 @@
+import type { BookingRedirectForm } from "@pages/settings/my-account/out-of-office";
+import { DateRangePicker } from "@tremor/react";
+import type { UseFormSetValue } from "react-hook-form";
+
+import dayjs from "@calcom/dayjs";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+
+import "./DateSelect.css";
+
+interface IOutOfOfficeDateRangeSelectProps {
+ dateRange: [Date | null, Date | null, null];
+ setDateRange: React.Dispatch>;
+ setValue: UseFormSetValue;
+}
+
+const OutOfOfficeDateRangePicker = (props: IOutOfOfficeDateRangeSelectProps) => {
+ const { t } = useLocale();
+ const { dateRange, setDateRange, setValue } = props;
+ return (
+
+ {
+ const [start, end] = datesArray;
+
+ if (start) {
+ setDateRange([start, end as Date | null, null]);
+ }
+ if (start && end) {
+ setValue("startDate", start.toISOString());
+ setValue("endDate", end.toISOString());
+ }
+ }}
+ color="gray"
+ options={undefined}
+ enableDropdown={false}
+ placeholder={t("select_date_range")}
+ enableYearPagination={true}
+ minDate={dayjs().startOf("d").toDate()}
+ maxDate={dayjs().add(2, "y").endOf("d").toDate()}
+ />
+
+ );
+};
+
+export { OutOfOfficeDateRangePicker };
diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx
index 349c8be4b6..acb97c9bce 100644
--- a/apps/web/pages/[user].tsx
+++ b/apps/web/pages/[user].tsx
@@ -2,6 +2,8 @@ import type { DehydratedState } from "@tanstack/react-query";
import classNames from "classnames";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { encode } from "querystring";
import { Toaster } from "react-hot-toast";
import type { z } from "zod";
@@ -11,6 +13,7 @@ import {
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
+import { handleUserRedirection } from "@calcom/features/booking-redirect/handle-user";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
@@ -40,6 +43,7 @@ import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
export function UserPage(props: InferGetServerSidePropsType) {
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
+ const searchParams = useSearchParams();
const [user] = users; //To be used when we only have a single user, not dynamic group
useTheme(profile.theme);
@@ -59,6 +63,8 @@ export function UserPage(props: InferGetServerSidePropsType {
@@ -77,6 +83,7 @@ export function UserPage(props: InferGetServerSidePropsType
+ {isRedirect && (
+
+
+ {t("user_redirect_title", {
+ username: fromUserNameRedirected,
+ })}{" "}
+ 🏝️
+
+
+ {t("user_redirect_description", {
+ profile: {
+ username: user.username,
+ },
+ username: fromUserNameRedirected,
+ })}{" "}
+ 😄
+
+
+ )}
= async (cont
const usernameList = getUsernameList(context.query.user as string);
const isOrgContext = isValidOrgDomain && currentOrgDomain;
const dataFetchStart = Date.now();
+ let outOfOffice = false;
+
+ if (usernameList.length === 1) {
+ const result = await handleUserRedirection({ username: usernameList[0] });
+ if (result && result.outOfOffice) {
+ outOfOffice = true;
+ }
+ if (result && result.redirect?.destination) {
+ return result;
+ }
+ }
+
const usersWithoutAvatar = await prisma.user.findMany({
where: {
username: {
@@ -400,11 +438,16 @@ export const getServerSideProps: GetServerSideProps = async (cont
}));
// if profile only has one public event-type, redirect to it
- if (eventTypes.length === 1 && context.query.redirect !== "false") {
+ if (eventTypes.length === 1 && context.query.redirect !== "false" && !outOfOffice) {
+ // Redirect but don't change the URL
+ const urlDestination = `/${user.username}/${eventTypes[0].slug}`;
+ const { query } = context;
+ const urlQuery = new URLSearchParams(encode(query));
+
return {
redirect: {
permanent: false,
- destination: `/${user.username}/${eventTypes[0].slug}`,
+ destination: `${urlDestination}?${urlQuery}`,
},
};
}
@@ -421,7 +464,7 @@ export const getServerSideProps: GetServerSideProps = async (cont
username: user.username,
bio: user.bio,
avatarUrl: user.avatarUrl,
- away: user.away,
+ away: usernameList.length === 1 ? outOfOffice : user.away,
verified: user.verified,
})),
entity: {
diff --git a/apps/web/pages/[user]/[type].tsx b/apps/web/pages/[user]/[type].tsx
index b328800c45..f36ecdc15b 100644
--- a/apps/web/pages/[user]/[type].tsx
+++ b/apps/web/pages/[user]/[type].tsx
@@ -4,6 +4,7 @@ import { z } from "zod";
import { Booker } from "@calcom/atoms";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
+import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
@@ -164,7 +165,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
const username = usernames[0];
const { rescheduleUid, bookingUid } = context.query;
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
-
+ let outOfOffice = false;
const isOrgContext = currentOrgDomain && isValidOrgDomain;
if (!isOrgContext) {
@@ -188,7 +189,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
organization: userOrgQuery(context.req, context.params?.orgSlug),
},
select: {
- away: true,
+ id: true,
hideBranding: true,
allowSEOIndexing: true,
},
@@ -199,6 +200,18 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
notFound: true,
} as const;
}
+ // If user is found, quickly verify bookingRedirects
+ const result = await handleTypeRedirection({
+ userId: user.id,
+ username,
+ slug,
+ });
+ if (result && result.outOfOffice) {
+ outOfOffice = true;
+ }
+ if (result && result.redirect?.destination) {
+ return result;
+ }
let booking: GetBookingType | null = null;
if (rescheduleUid) {
@@ -230,7 +243,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
length: eventData.length,
metadata: eventData.metadata,
},
- away: user?.away,
+ away: outOfOffice,
user: username,
slug,
trpcState: ssr.dehydrate(),
diff --git a/apps/web/pages/availability/index.tsx b/apps/web/pages/availability/index.tsx
index d368c2a233..97fcdb0fe2 100644
--- a/apps/web/pages/availability/index.tsx
+++ b/apps/web/pages/availability/index.tsx
@@ -1,4 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
+import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback } from "react";
@@ -104,24 +105,32 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
/>
) : (
-
-
- {schedules.map((schedule) => (
-
- ))}
-
-
+ <>
+
+
+ {schedules.map((schedule) => (
+
+ ))}
+
+
+
+ {t("temporarily_out_of_office")}{" "}
+
+ {t("add_a_redirect")}
+
+
+ >
)}
>
);
diff --git a/apps/web/pages/settings/my-account/out-of-office/index.tsx b/apps/web/pages/settings/my-account/out-of-office/index.tsx
new file mode 100644
index 0000000000..1524422af4
--- /dev/null
+++ b/apps/web/pages/settings/my-account/out-of-office/index.tsx
@@ -0,0 +1,241 @@
+import { Trash2 } from "lucide-react";
+import React, { useState } from "react";
+import { useForm, useFormState } from "react-hook-form";
+
+import dayjs from "@calcom/dayjs";
+import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
+import { ShellMain } from "@calcom/features/shell/Shell";
+import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { trpc } from "@calcom/trpc/react";
+import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
+import { Button, Meta, showToast, Select, SkeletonText, UpgradeTeamsBadge, Switch } from "@calcom/ui";
+import { TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui";
+
+import PageWrapper from "@components/PageWrapper";
+import { OutOfOfficeDateRangePicker } from "@components/out-of-office/DateRangePicker";
+
+export type BookingRedirectForm = {
+ startDate: string;
+ endDate: string;
+ toTeamUserId: number | null;
+};
+
+const OutOfOfficeSection = () => {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+
+ const [dateRange, setDateRange] = useState<[Date | null, Date | null, null | null]>([
+ dayjs().startOf("d").toDate(),
+ dayjs().add(1, "d").endOf("d").toDate(),
+ null,
+ ]);
+ const [profileRedirect, setProfileRedirect] = useState(false);
+ const [selectedMember, setSelectedMember] = useState<{ label: string; value: number | null } | null>(null);
+
+ const { handleSubmit, setValue } = useForm({
+ defaultValues: {
+ startDate: dateRange[0]?.toISOString(),
+ endDate: dateRange[1]?.toISOString(),
+ toTeamUserId: null,
+ },
+ });
+
+ const createOutOfOfficeEntry = trpc.viewer.outOfOfficeCreate.useMutation({
+ onSuccess: () => {
+ showToast(t("success_entry_created"), "success");
+ utils.viewer.outOfOfficeEntriesList.invalidate();
+ setProfileRedirect(false);
+ },
+ onError: (error) => {
+ showToast(t(error.message), "error");
+ },
+ });
+
+ const { hasTeamPlan } = useHasTeamPlan();
+ const { data: listMembers } = trpc.viewer.teams.listMembers.useQuery({});
+ const me = useMeQuery();
+ const memberListOptions: {
+ value: number | null;
+ label: string;
+ }[] =
+ listMembers
+ ?.filter((member) => me?.data?.id !== member.id)
+ .map((member) => ({
+ value: member.id || null,
+ label: member.name || "",
+ })) || [];
+
+ return (
+ <>
+
+ >
+ );
+};
+
+const OutOfOfficeEntriesList = () => {
+ const { t } = useLocale();
+ const utils = trpc.useContext();
+ const { data, isLoading } = trpc.viewer.outOfOfficeEntriesList.useQuery();
+ const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
+ onSuccess: () => {
+ showToast(t("success_deleted_entry_out_of_office"), "success");
+ utils.viewer.outOfOfficeEntriesList.invalidate();
+ useFormState;
+ },
+ onError: () => {
+ showToast(`An error ocurred`, "error");
+ },
+ });
+ if (data === null || data?.length === 0 || data === undefined) return null;
+ return (
+
+
+
+
+ {t("time_range")}
+ {t("username")}
+
+ {t("action")}
+
+
+
+ {data?.map((item) => (
+
+
+
+ {dayjs(item.start).format("ll")} - {dayjs(item.end).format("ll")}
+
+
+
+ {item.toUser?.username || "N/A"}
+
+
+
+
+ ))}
+ {isLoading && (
+
+ {new Array(6).fill(0).map((_, index) => (
+
+
+
+ ))}
+
+ )}
+
+ {!isLoading && (data === undefined || data.length === 0) && (
+
+
+ {t("no_redirects_found")}
+
+
+ )}
+
+
+
+ );
+};
+
+const OutOfOfficePage = () => {
+ const { t } = useLocale();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+OutOfOfficePage.getLayout = getLayout;
+OutOfOfficePage.PageWrapper = PageWrapper;
+
+export default OutOfOfficePage;
diff --git a/apps/web/playwright/out-of-office.e2e.ts b/apps/web/playwright/out-of-office.e2e.ts
new file mode 100644
index 0000000000..65264ea4c6
--- /dev/null
+++ b/apps/web/playwright/out-of-office.e2e.ts
@@ -0,0 +1,101 @@
+import { expect } from "@playwright/test";
+import { v4 as uuidv4 } from "uuid";
+
+import dayjs from "@calcom/dayjs";
+import { randomString } from "@calcom/lib/random";
+import prisma from "@calcom/prisma";
+
+import { test } from "./lib/fixtures";
+
+test.describe.configure({ mode: "parallel" });
+test.afterEach(({ users }) => users.deleteAll());
+
+test.describe("Out of office", () => {
+ test("User can create out of office entry", async ({ page, users }) => {
+ const user = await users.create({ name: "userOne" });
+
+ await user.apiLogin();
+
+ await page.goto("/settings/my-account/out-of-office");
+
+ await page.locator("data-testid=create-entry-ooo-redirect").click();
+
+ await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
+ });
+
+ test("User can configure booking redirect", async ({ page, users }) => {
+ const user = await users.create({ name: "userOne" });
+ const userTo = await users.create({ name: "userTwo" });
+
+ const team = await prisma.team.create({
+ data: {
+ name: "test-insights",
+ slug: `test-insights-${Date.now()}-${randomString(5)}}`,
+ },
+ });
+
+ // create memberships
+ await prisma.membership.createMany({
+ data: [
+ {
+ userId: user.id,
+ teamId: team.id,
+ accepted: true,
+ role: "ADMIN",
+ },
+ {
+ userId: userTo.id,
+ teamId: team.id,
+ accepted: true,
+ role: "ADMIN",
+ },
+ ],
+ });
+
+ await user.apiLogin();
+
+ await page.goto(`/settings/my-account/out-of-office`);
+
+ await page.getByTestId("profile-redirect-switch").click();
+ await page
+ .getByTestId("team_username_select")
+ .locator("div")
+ .filter({ hasText: "Select team member" })
+ .first()
+ .click();
+ await page.locator("#react-select-2-option-0 div").click();
+
+ // send request
+ await page.getByTestId("create-entry-ooo-redirect").click();
+
+ // expect table-redirect-toUserId to be visible
+ await expect(page.locator(`data-testid=table-redirect-${userTo.username}`)).toBeVisible();
+ });
+
+ test("Profile redirection", async ({ page, users }) => {
+ const user = await users.create({ name: "userOne" });
+ const userTo = await users.create({ name: "userTwo" });
+ const uuid = uuidv4();
+ await prisma.outOfOfficeEntry.create({
+ data: {
+ start: dayjs().startOf("day").toDate(),
+ end: dayjs().startOf("day").add(1, "w").toDate(),
+ uuid,
+ user: { connect: { id: user.id } },
+ toUser: { connect: { id: userTo.id } },
+ createdAt: new Date(),
+ },
+ });
+
+ await page.goto(`/${user.username}`);
+
+ await page.waitForLoadState("networkidle");
+
+ // regex to match username
+ expect(page.url()).toMatch(new RegExp(`/${userTo.username}`));
+
+ await page.goto(`/${userTo.username}/30-min`);
+
+ expect(page.url()).toMatch(new RegExp(`/${userTo.username}/30-min`));
+ });
+});
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index 188d9d21df..0f6213c745 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -2190,5 +2190,35 @@
"uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types",
"dont_want_to_wait": "Don't want to wait?",
"meeting_started": "Meeting Started",
+ "user_redirect_title": "{{username}} is currently away for a brief period of time.",
+ "user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.",
+ "out_of_office": "Out of office",
+ "out_of_office_description": "Configure actions in your profile while you are away.",
+ "send_request": "Send request",
+ "start_date_and_end_date_required": "Start date and end date are required",
+ "start_date_must_be_before_end_date": "Start date must be before end date",
+ "start_date_must_be_in_the_future": "Start date must be in the future",
+ "user_not_found": "User not found",
+ "out_of_office_entry_already_exists": "Out of office entry already exists",
+ "out_of_office_id_required": "Out of office entry id required",
+ "booking_redirect_infinite_not_allowed": "There is already a booking redirect from that user to you.",
+ "success_entry_created": "Successfully created a new entry",
+ "booking_redirect_email_subject": "Booking redirect notification",
+ "booking_redirect_email_title": "Booking Redirect Notification",
+ "booking_redirect_email_description": "You have received a booking redirection from {{toName}} so their profile links will be redirect to yours for the time interval: ",
+ "success_accept_booking_redirect":"You have accepted this booking redirect request.",
+ "success_reject_booking_redirect":"You have rejected this booking redirect request.",
+ "copy_link_booking_redirect_request": "Copy link to share request",
+ "booking_redirect_request_title": "Booking Redirect Request",
+ "select_team_member": "Select team member",
+ "going_away_title": "Going away? Simply mark your profile link as unavailable for a period of time.",
+ "redirect_team_enabled": "Redirect your profile to another team member",
+ "redirect_team_disabled": "Redirect your profile to another team member (Team plan required)",
+ "out_of_office_unavailable_list": "Out of office unavailability list",
+ "success_deleted_entry_out_of_office": "Successfully deleted entry",
+ "temporarily_out_of_office":"Temporarily Out-Of-Office?",
+ "add_a_redirect": "Add a redirect",
+ "create_entry": "Create entry",
+ "time_range": "Time range",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts
index 8774278fa1..987e0b49f1 100644
--- a/packages/emails/email-manager.ts
+++ b/packages/emails/email-manager.ts
@@ -24,6 +24,8 @@ import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
import type { EmailVerifyCode } from "./templates/attendee-verify-email";
import AttendeeVerifyEmail from "./templates/attendee-verify-email";
import AttendeeWasRequestedToRescheduleEmail from "./templates/attendee-was-requested-to-reschedule-email";
+import BookingRedirectEmailNotification from "./templates/booking-redirect-notification";
+import type { IBookingRedirect } from "./templates/booking-redirect-notification";
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import DisabledAppEmail from "./templates/disabled-app-email";
import type { Feedback } from "./templates/feedback-email";
@@ -437,3 +439,7 @@ export const sendMonthlyDigestEmails = async (eventData: MonthlyDigestEmailData)
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
await sendEmail(() => new AdminOrganizationNotification(input));
};
+
+export const sendBookingRedirectNotification = async (bookingRedirect: IBookingRedirect) => {
+ await sendEmail(() => new BookingRedirectEmailNotification(bookingRedirect));
+};
diff --git a/packages/emails/src/templates/BookingRedirectEmailNotification.tsx b/packages/emails/src/templates/BookingRedirectEmailNotification.tsx
new file mode 100644
index 0000000000..cbe11a5581
--- /dev/null
+++ b/packages/emails/src/templates/BookingRedirectEmailNotification.tsx
@@ -0,0 +1,33 @@
+import type { IBookingRedirect } from "../../templates/booking-redirect-notification";
+import { BaseEmailHtml } from "../components";
+
+export const BookingRedirectEmailNotification = (
+ props: IBookingRedirect & Partial>
+) => {
+ return (
+
+
+ {props.language("booking_redirect_email_description", {
+ toName: props.toName,
+ })}
+ {props.dates}
+
+
+
+
+ );
+};
diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts
index 1d7f642203..072401fb86 100644
--- a/packages/emails/src/templates/index.ts
+++ b/packages/emails/src/templates/index.ts
@@ -31,3 +31,4 @@ export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
+export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
diff --git a/packages/emails/templates/booking-redirect-notification.ts b/packages/emails/templates/booking-redirect-notification.ts
new file mode 100644
index 0000000000..db51fe7385
--- /dev/null
+++ b/packages/emails/templates/booking-redirect-notification.ts
@@ -0,0 +1,36 @@
+import type { TFunction } from "next-i18next";
+
+import { APP_NAME } from "@calcom/lib/constants";
+
+import { renderEmail } from "..";
+import BaseEmail from "./_base-email";
+
+export interface IBookingRedirect {
+ language: TFunction;
+ fromEmail: string;
+ toEmail: string;
+ toName: string;
+ dates: string;
+}
+
+export default class BookingRedirectNotification extends BaseEmail {
+ bookingRedirect: IBookingRedirect;
+
+ constructor(bookingRedirect: IBookingRedirect) {
+ super();
+ this.name = "BOOKING_REDIRECT_NOTIFICATION";
+ this.bookingRedirect = bookingRedirect;
+ }
+
+ protected async getNodeMailerPayload(): Promise> {
+ return {
+ to: `${this.bookingRedirect.toEmail} <${this.bookingRedirect.toName}>`,
+ from: `${APP_NAME} <${this.getMailerOptions().from}>`,
+ subject: this.bookingRedirect.language("booking_redirect_email_subject"),
+ html: await renderEmail("BookingRedirectEmailNotification", {
+ ...this.bookingRedirect,
+ }),
+ text: "",
+ };
+ }
+}
diff --git a/packages/features/booking-redirect/handle-type.ts b/packages/features/booking-redirect/handle-type.ts
new file mode 100644
index 0000000000..732113d7a8
--- /dev/null
+++ b/packages/features/booking-redirect/handle-type.ts
@@ -0,0 +1,71 @@
+import prisma from "@calcom/prisma";
+
+interface HandleTypeRedirectionProps {
+ userId: number;
+ slug: string;
+ username: string;
+}
+
+export const handleTypeRedirection = async (props: HandleTypeRedirectionProps) => {
+ const { userId, slug, username } = props;
+ const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
+ select: {
+ userId: true,
+ start: true,
+ end: true,
+ toUserId: true,
+ toUser: {
+ select: {
+ id: true,
+ username: true,
+ },
+ },
+ },
+ where: {
+ userId: userId,
+ start: {
+ lte: new Date(),
+ },
+ end: {
+ gte: new Date(),
+ },
+ },
+ });
+
+ let redirectToSameEventSlug = false;
+
+ // @TODO: Should I validate if new user has same type as slug?
+ if (outOfOfficeEntryActive) {
+ if (outOfOfficeEntryActive.toUserId === null) {
+ return {
+ outOfOffice: true,
+ };
+ }
+ if (outOfOfficeEntryActive?.toUser) {
+ const eventType = await prisma.eventType.findFirst({
+ select: {
+ slug: true,
+ userId: true,
+ },
+ where: {
+ slug,
+ userId: outOfOfficeEntryActive.toUserId,
+ },
+ });
+
+ if (eventType) {
+ redirectToSameEventSlug = true;
+ }
+
+ return {
+ redirect: {
+ destination: `/${outOfOfficeEntryActive.toUser.username}${
+ redirectToSameEventSlug ? `/${slug}` : ""
+ }?redirected=true&username=${username}`,
+ permanent: false,
+ },
+ };
+ }
+ }
+ return {};
+};
diff --git a/packages/features/booking-redirect/handle-user.ts b/packages/features/booking-redirect/handle-user.ts
new file mode 100644
index 0000000000..a7c7281592
--- /dev/null
+++ b/packages/features/booking-redirect/handle-user.ts
@@ -0,0 +1,59 @@
+import prisma from "@calcom/prisma";
+
+interface HandleUserRedirectionProps {
+ username: string;
+}
+
+export const handleUserRedirection = async (props: HandleUserRedirectionProps) => {
+ const { username } = props;
+ const fromUser = await prisma.user.findFirst({
+ where: {
+ username,
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ if (fromUser) {
+ // If user is found quickly verify bookingRedirect
+ const outOfOfficeEntryActive = await prisma.outOfOfficeEntry.findFirst({
+ select: {
+ toUser: {
+ select: {
+ username: true,
+ },
+ },
+ toUserId: true,
+ userId: true,
+ start: true,
+ end: true,
+ },
+ where: {
+ userId: fromUser.id,
+ start: {
+ lte: new Date(),
+ },
+ end: {
+ gte: new Date(),
+ },
+ },
+ });
+
+ if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUserId === null) {
+ return {
+ outOfOffice: true,
+ };
+ }
+
+ if (outOfOfficeEntryActive && outOfOfficeEntryActive.toUser?.username) {
+ return {
+ redirect: {
+ destination: `/${outOfOfficeEntryActive.toUser.username}?redirected=true&username=${username}`,
+ permanent: false,
+ },
+ };
+ }
+ }
+ return {};
+};
diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx
index 15749a95a6..9c4b204209 100644
--- a/packages/features/bookings/Booker/Booker.tsx
+++ b/packages/features/bookings/Booker/Booker.tsx
@@ -1,6 +1,6 @@
import { LazyMotion, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
-import { usePathname, useRouter } from "next/navigation";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
@@ -81,6 +81,12 @@ const BookerComponent = ({
const hasDarkBackground = isEmbed && embedType !== "inline";
const embedUiConfig = useEmbedUiConfig();
+ const { t } = useLocale();
+
+ const searchParams = useSearchParams();
+ const isRedirect = searchParams?.get("redirected") === "true" || false;
+ const fromUserNameRedirected = searchParams?.get("username") || "";
+
// In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout
// But if it's mobile view, there is only one layout supported which is 'mobile'
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
@@ -221,6 +227,7 @@ const BookerComponent = ({
return (
<>
{event.data ? : null}
+
{bookerState !== "booking" && event.data?.isInstantEvent && (
+ {/* redirect from other user profile */}
+ {isRedirect && (
+
+
+ {t("user_redirect_title", {
+ username: fromUserNameRedirected,
+ })}{" "}
+ 🏝️
+
+
+ {t("user_redirect_description", {
+ profile: {
+ username: username,
+ },
+ username: fromUserNameRedirected,
+ })}{" "}
+ 😄
+
+
+ )}
setMenuOpen((menuOpen) => !menuOpen)}>