Merge branch 'main' into fix-13112
This commit is contained in:
commit
df36ac27b8
|
@ -62,18 +62,18 @@ jobs:
|
||||||
|
|
||||||
- name: Get comment body
|
- name: Get comment body
|
||||||
id: get-comment-body
|
id: get-comment-body
|
||||||
if: success()
|
if: success() && github.event.number
|
||||||
run: |
|
run: |
|
||||||
cd apps/web
|
cd apps/web
|
||||||
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
body=$(cat .next/analyze/__bundle_analysis_comment.txt)
|
||||||
body="${body//'%'/'%25'}"
|
body="${body//'%'/'%25'}"
|
||||||
body="${body//$'\n'/'%0A'}"
|
body="${body//$'\n'/'%0A'}"
|
||||||
body="${body//$'\r'/'%0D'}"
|
body="${body//$'\r'/'%0D'}"
|
||||||
echo "{name}={$body}" >> $GITHUB_OUTPUT
|
echo "{body}={$body}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Find Comment
|
- name: Find Comment
|
||||||
uses: peter-evans/find-comment@v2
|
uses: peter-evans/find-comment@v2
|
||||||
if: success()
|
if: success() && github.event.number
|
||||||
id: fc
|
id: fc
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.number }}
|
issue-number: ${{ github.event.number }}
|
||||||
|
|
|
@ -87,6 +87,10 @@ export async function patchHandler(req: NextApiRequest) {
|
||||||
args.data.userId = bodyUserId;
|
args.data.userId = bodyUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.data.eventTriggers) {
|
||||||
|
args.data.eventTriggers = [...new Set(args.data.eventTriggers)];
|
||||||
|
}
|
||||||
|
|
||||||
const result = await prisma.webhook.update(args);
|
const result = await prisma.webhook.update(args);
|
||||||
return { webhook: schemaWebhookReadPublic.parse(result) };
|
return { webhook: schemaWebhookReadPublic.parse(result) };
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,6 +87,10 @@ async function postHandler(req: NextApiRequest) {
|
||||||
args.data.userId = bodyUserId;
|
args.data.userId = bodyUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.data.eventTriggers) {
|
||||||
|
args.data.eventTriggers = [...new Set(args.data.eventTriggers)];
|
||||||
|
}
|
||||||
|
|
||||||
const data = await prisma.webhook.create(args);
|
const data = await prisma.webhook.create(args);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import LegacyPage from "@pages/maintenance";
|
||||||
|
import { _generateMetadata } from "app/_utils";
|
||||||
|
import { WithLayout } from "app/layoutHOC";
|
||||||
|
|
||||||
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
|
|
||||||
|
export const generateMetadata = async () =>
|
||||||
|
await _generateMetadata(
|
||||||
|
(t) => `${t("under_maintenance")} | ${APP_NAME}`,
|
||||||
|
(t) => t("under_maintenance_description", { appName: APP_NAME })
|
||||||
|
);
|
||||||
|
|
||||||
|
// @ts-expect-error Page type
|
||||||
|
export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">;
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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<React.SetStateAction<[Date | null, Date | null, null]>>;
|
||||||
|
setValue: UseFormSetValue<BookingRedirectForm>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OutOfOfficeDateRangePicker = (props: IOutOfOfficeDateRangeSelectProps) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
const { dateRange, setDateRange, setValue } = props;
|
||||||
|
return (
|
||||||
|
<div className="custom-date">
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
defaultValue={dateRange}
|
||||||
|
onValueChange={(datesArray) => {
|
||||||
|
const [start, end] = datesArray;
|
||||||
|
|
||||||
|
if (start) {
|
||||||
|
setDateRange([start, end as Date | null, null]);
|
||||||
|
}
|
||||||
|
if (start && end) {
|
||||||
|
setValue("startDate", start.toISOString());
|
||||||
|
setValue("endDate", end.toISOString());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="gray"
|
||||||
|
options={undefined}
|
||||||
|
enableDropdown={false}
|
||||||
|
placeholder={t("select_date_range")}
|
||||||
|
enableYearPagination={true}
|
||||||
|
minDate={dayjs().startOf("d").toDate()}
|
||||||
|
maxDate={dayjs().add(2, "y").endOf("d").toDate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { OutOfOfficeDateRangePicker };
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "3.6.2",
|
"version": "3.6.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|
|
@ -2,6 +2,8 @@ import type { DehydratedState } from "@tanstack/react-query";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
|
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { encode } from "querystring";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
@ -11,6 +13,7 @@ import {
|
||||||
useEmbedStyles,
|
useEmbedStyles,
|
||||||
useIsEmbed,
|
useIsEmbed,
|
||||||
} from "@calcom/embed-core/embed-iframe";
|
} 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 { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
import { orgDomainConfig } 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";
|
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||||
|
@ -40,6 +43,7 @@ import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
|
||||||
|
|
||||||
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||||
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
|
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
|
const [user] = users; //To be used when we only have a single user, not dynamic group
|
||||||
useTheme(profile.theme);
|
useTheme(profile.theme);
|
||||||
|
@ -59,6 +63,8 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||||
...query
|
...query
|
||||||
} = useRouterQuery();
|
} = useRouterQuery();
|
||||||
|
|
||||||
|
const isRedirect = searchParams?.get("redirected") === "true" || false;
|
||||||
|
const fromUserNameRedirected = searchParams?.get("username") || "";
|
||||||
/*
|
/*
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -77,6 +83,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEventListEmpty = eventTypes.length === 0;
|
const isEventListEmpty = eventTypes.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeadSeo
|
<HeadSeo
|
||||||
|
@ -100,6 +107,25 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||||
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
isEmbed ? "border-booker border-booker-width bg-default rounded-md border" : "",
|
||||||
"max-w-3xl px-4 py-24"
|
"max-w-3xl px-4 py-24"
|
||||||
)}>
|
)}>
|
||||||
|
{isRedirect && (
|
||||||
|
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent dark:bg-transparent">
|
||||||
|
<h2 className="text-default mb-2 text-sm font-semibold dark:text-white">
|
||||||
|
{t("user_redirect_title", {
|
||||||
|
username: fromUserNameRedirected,
|
||||||
|
})}{" "}
|
||||||
|
🏝️
|
||||||
|
</h2>
|
||||||
|
<p className="text-default text-sm">
|
||||||
|
{t("user_redirect_description", {
|
||||||
|
profile: {
|
||||||
|
username: user.username,
|
||||||
|
},
|
||||||
|
username: fromUserNameRedirected,
|
||||||
|
})}{" "}
|
||||||
|
😄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size="xl"
|
size="xl"
|
||||||
|
@ -290,6 +316,18 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||||
const usernameList = getUsernameList(context.query.user as string);
|
const usernameList = getUsernameList(context.query.user as string);
|
||||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||||
const dataFetchStart = Date.now();
|
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({
|
const usersWithoutAvatar = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
username: {
|
username: {
|
||||||
|
@ -400,11 +438,16 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// if profile only has one public event-type, redirect to it
|
// 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 {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
destination: `/${user.username}/${eventTypes[0].slug}`,
|
destination: `${urlDestination}?${urlQuery}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -421,7 +464,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||||
username: user.username,
|
username: user.username,
|
||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
avatarUrl: user.avatarUrl,
|
avatarUrl: user.avatarUrl,
|
||||||
away: user.away,
|
away: usernameList.length === 1 ? outOfOffice : user.away,
|
||||||
verified: user.verified,
|
verified: user.verified,
|
||||||
})),
|
})),
|
||||||
entity: {
|
entity: {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { z } from "zod";
|
||||||
|
|
||||||
import { Booker } from "@calcom/atoms";
|
import { Booker } from "@calcom/atoms";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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 { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||||
|
@ -164,7 +165,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
const username = usernames[0];
|
const username = usernames[0];
|
||||||
const { rescheduleUid, bookingUid } = context.query;
|
const { rescheduleUid, bookingUid } = context.query;
|
||||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||||
|
let outOfOffice = false;
|
||||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||||
|
|
||||||
if (!isOrgContext) {
|
if (!isOrgContext) {
|
||||||
|
@ -188,7 +189,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
organization: userOrgQuery(context.req, context.params?.orgSlug),
|
organization: userOrgQuery(context.req, context.params?.orgSlug),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
away: true,
|
id: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
allowSEOIndexing: true,
|
allowSEOIndexing: true,
|
||||||
},
|
},
|
||||||
|
@ -199,6 +200,18 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
} as const;
|
} 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;
|
let booking: GetBookingType | null = null;
|
||||||
if (rescheduleUid) {
|
if (rescheduleUid) {
|
||||||
|
@ -230,7 +243,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||||
length: eventData.length,
|
length: eventData.length,
|
||||||
metadata: eventData.metadata,
|
metadata: eventData.metadata,
|
||||||
},
|
},
|
||||||
away: user?.away,
|
away: outOfOffice,
|
||||||
user: username,
|
user: username,
|
||||||
slug,
|
slug,
|
||||||
trpcState: ssr.dehydrate(),
|
trpcState: ssr.dehydrate(),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
@ -104,24 +105,32 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border-subtle bg-default mb-16 overflow-hidden rounded-md border">
|
<>
|
||||||
<ul className="divide-subtle divide-y" data-testid="schedules" ref={animationParentRef}>
|
<div className="border-subtle bg-default overflow-hidden rounded-md border">
|
||||||
{schedules.map((schedule) => (
|
<ul className="divide-subtle divide-y" data-testid="schedules" ref={animationParentRef}>
|
||||||
<ScheduleListItem
|
{schedules.map((schedule) => (
|
||||||
displayOptions={{
|
<ScheduleListItem
|
||||||
hour12: meQuery.data?.timeFormat ? meQuery.data.timeFormat === 12 : undefined,
|
displayOptions={{
|
||||||
timeZone: meQuery.data?.timeZone,
|
hour12: meQuery.data?.timeFormat ? meQuery.data.timeFormat === 12 : undefined,
|
||||||
}}
|
timeZone: meQuery.data?.timeZone,
|
||||||
key={schedule.id}
|
}}
|
||||||
schedule={schedule}
|
key={schedule.id}
|
||||||
isDeletable={schedules.length !== 1}
|
schedule={schedule}
|
||||||
updateDefault={updateMutation.mutate}
|
isDeletable={schedules.length !== 1}
|
||||||
deleteFunction={deleteMutation.mutate}
|
updateDefault={updateMutation.mutate}
|
||||||
duplicateFunction={duplicateMutation.mutate}
|
deleteFunction={deleteMutation.mutate}
|
||||||
/>
|
duplicateFunction={duplicateMutation.mutate}
|
||||||
))}
|
/>
|
||||||
</ul>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="text-default mb-16 mt-4 hidden text-center text-sm md:block">
|
||||||
|
{t("temporarily_out_of_office")}{" "}
|
||||||
|
<Link href="settings/my-account/out-of-office" className="underline">
|
||||||
|
{t("add_a_redirect")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
|
||||||
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
import { APP_NAME, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
|
|
|
@ -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<BookingRedirectForm>({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((data) => {
|
||||||
|
createOutOfOfficeEntry.mutate(data);
|
||||||
|
setValue("toTeamUserId", null);
|
||||||
|
setSelectedMember(null);
|
||||||
|
})}>
|
||||||
|
<div className="border-subtle flex flex-col rounded-b-lg border border-t-0 p-6 px-6 py-8 text-sm">
|
||||||
|
{/* Add startDate and end date inputs */}
|
||||||
|
<div className="border-subtle mt-2 rounded-lg border bg-gray-50 p-6 dark:bg-transparent">
|
||||||
|
{/* Add toggle to enable/disable redirect */}
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<Switch
|
||||||
|
disabled={!hasTeamPlan}
|
||||||
|
data-testid="profile-redirect-switch"
|
||||||
|
checked={profileRedirect}
|
||||||
|
id="profile-redirect-switch"
|
||||||
|
onCheckedChange={(state) => {
|
||||||
|
setProfileRedirect(state);
|
||||||
|
}}
|
||||||
|
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
|
||||||
|
/>
|
||||||
|
{!hasTeamPlan && (
|
||||||
|
<div className="mx-2" data-testid="upgrade-team-badge">
|
||||||
|
<UpgradeTeamsBadge />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-row">
|
||||||
|
{profileRedirect && (
|
||||||
|
<div className="mr-2 w-1/2 lg:w-1/3">
|
||||||
|
<p className="text-emphasis block text-sm font-medium">{t("team_member")}</p>
|
||||||
|
<Select
|
||||||
|
className="mt-1 h-4 max-w-[350px] text-white"
|
||||||
|
name="toTeamUsername"
|
||||||
|
data-testid="team_username_select"
|
||||||
|
value={selectedMember}
|
||||||
|
placeholder={t("select_team_member")}
|
||||||
|
isSearchable
|
||||||
|
innerClassNames={{
|
||||||
|
control: "h-[38px]",
|
||||||
|
}}
|
||||||
|
options={memberListOptions}
|
||||||
|
onChange={(selectedOption) => {
|
||||||
|
if (selectedOption?.value) {
|
||||||
|
setSelectedMember(selectedOption);
|
||||||
|
setValue("toTeamUserId", selectedOption?.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-1/2 lg:w-1/3">
|
||||||
|
<p className="text-emphasis mb-1 block text-sm font-medium">{t("time_range")}</p>
|
||||||
|
|
||||||
|
<OutOfOfficeDateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
setValue={setValue}
|
||||||
|
setDateRange={setDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={createOutOfOfficeEntry.isLoading}
|
||||||
|
data-testid="create-entry-ooo-redirect">
|
||||||
|
{t("create_entry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OutOfOfficeEntriesList />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="border-subtle mt-6 rounded-lg border">
|
||||||
|
<TableNew className="border-0">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="rounded-tl-lg font-normal capitalize">{t("time_range")}</TableHead>
|
||||||
|
<TableHead className="font-normal">{t("username")}</TableHead>
|
||||||
|
|
||||||
|
<TableHead className="rounded-tr-lg font-normal">{t("action")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.map((item) => (
|
||||||
|
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
|
||||||
|
<TableCell>
|
||||||
|
<p className="px-2">
|
||||||
|
{dayjs(item.start).format("ll")} - {dayjs(item.end).format("ll")}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<p className="px-2">{item.toUser?.username || "N/A"}</p>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
variant="icon"
|
||||||
|
disabled={deleteOutOfOfficeEntryMutation.isLoading}
|
||||||
|
StartIcon={Trash2}
|
||||||
|
onClick={() => {
|
||||||
|
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
{new Array(6).fill(0).map((_, index) => (
|
||||||
|
<TableCell key={index}>
|
||||||
|
<SkeletonText className="h-8 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && (data === undefined || data.length === 0) && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center">
|
||||||
|
<p className="text-subtle text-sm">{t("no_redirects_found")}</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</TableNew>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OutOfOfficePage = () => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Meta title={t("out_of_office")} description={t("out_of_office_description")} borderInShellHeader />
|
||||||
|
<ShellMain>
|
||||||
|
<OutOfOfficeSection />
|
||||||
|
</ShellMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
OutOfOfficePage.getLayout = getLayout;
|
||||||
|
OutOfOfficePage.PageWrapper = PageWrapper;
|
||||||
|
|
||||||
|
export default OutOfOfficePage;
|
|
@ -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`));
|
||||||
|
});
|
||||||
|
});
|
|
@ -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",
|
"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?",
|
"dont_want_to_wait": "Don't want to wait?",
|
||||||
"meeting_started": "Meeting Started",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -2149,6 +2149,7 @@
|
||||||
"manage_calendars": "ניהול לוחות שנה",
|
"manage_calendars": "ניהול לוחות שנה",
|
||||||
"lock_timezone_toggle_on_booking_page": "נעילת אזור הזמן בדף ההזמנות",
|
"lock_timezone_toggle_on_booking_page": "נעילת אזור הזמן בדף ההזמנות",
|
||||||
"description_lock_timezone_toggle_on_booking_page": "כדי לנעול את אזור הזמן בדף ההזמנות – שימושי לאירועים אישיים.",
|
"description_lock_timezone_toggle_on_booking_page": "כדי לנעול את אזור הזמן בדף ההזמנות – שימושי לאירועים אישיים.",
|
||||||
|
"number_in_international_format": "נא למלא מספר בתבנית בינלאומית.",
|
||||||
"install_calendar": "התקנת לוח שנה",
|
"install_calendar": "התקנת לוח שנה",
|
||||||
"branded_subdomain": "תת־תחום ממותג",
|
"branded_subdomain": "תת־תחום ממותג",
|
||||||
"branded_subdomain_description": "קבלת תת־תחום ממותג משלך, כגון acme.cal.com",
|
"branded_subdomain_description": "קבלת תת־תחום ממותג משלך, כגון acme.cal.com",
|
||||||
|
@ -2177,5 +2178,26 @@
|
||||||
"uprade_to_create_instant_bookings": "ניתן לשדג לרישיון התאגידי ולאפשר למשתמשים להצטרף לשיחה מיידית שמשתתפים יכולים לקפוץ ישירות אליה. זה מיועד רק לסוגי אירועים של צוותים",
|
"uprade_to_create_instant_bookings": "ניתן לשדג לרישיון התאגידי ולאפשר למשתמשים להצטרף לשיחה מיידית שמשתתפים יכולים לקפוץ ישירות אליה. זה מיועד רק לסוגי אירועים של צוותים",
|
||||||
"dont_want_to_wait": "לא רוצה להמתין?",
|
"dont_want_to_wait": "לא רוצה להמתין?",
|
||||||
"meeting_started": "הפגישה החלה",
|
"meeting_started": "הפגישה החלה",
|
||||||
|
"out_of_office": "מחוץ למשרד",
|
||||||
|
"out_of_office_description": "הגדרת פעולות בפרופיל שלך כשאינך במשרד.",
|
||||||
|
"send_request": "שליחת בקשה",
|
||||||
|
"start_date_and_end_date_required": "צריך תאריכי התחלה וסיום",
|
||||||
|
"start_date_must_be_before_end_date": "תאריך ההתחלה חייב להיות לפני תאריך הסיום",
|
||||||
|
"start_date_must_be_in_the_future": "תאריך ההתחלה חייב להיות בעתיד",
|
||||||
|
"user_not_found": "המשתמש לא נמצא",
|
||||||
|
"out_of_office_entry_already_exists": "רשומת מחוץ למשרד כבר קיימת",
|
||||||
|
"out_of_office_id_required": "צריך מזהה רשומת מחוץ למשרד",
|
||||||
|
"booking_redirect_infinite_not_allowed": "כבר יש הפניית הזמנה מהמשתמש הזה אליך.",
|
||||||
|
"success_entry_created": "רשומה חדשה נוצרה בהצלחה",
|
||||||
|
"booking_redirect_email_subject": "התראת הפניית הזמנה",
|
||||||
|
"booking_redirect_email_title": "התראת הפניית הזמנה",
|
||||||
|
"booking_redirect_request_title": "בקשת הפניית הזמנות",
|
||||||
|
"select_team_member": "בחירת נציגות מהצוות",
|
||||||
|
"out_of_office_unavailable_list": "רשימת אי־זמינות למחוץ למשרד",
|
||||||
|
"success_deleted_entry_out_of_office": "רשומה נמחקה בהצלחה",
|
||||||
|
"temporarily_out_of_office": "יצאת למשרד באופן זמני?",
|
||||||
|
"add_a_redirect": "הוספת הפנייה",
|
||||||
|
"create_entry": "יצירת רשומה",
|
||||||
|
"time_range": "טווח זמן",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|
|
@ -143,7 +143,8 @@ html.todesktop header {
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.todesktop header button {
|
html.todesktop header button,
|
||||||
|
html.todesktop header a {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,8 @@ import AttendeeScheduledEmail from "./templates/attendee-scheduled-email";
|
||||||
import type { EmailVerifyCode } from "./templates/attendee-verify-email";
|
import type { EmailVerifyCode } from "./templates/attendee-verify-email";
|
||||||
import AttendeeVerifyEmail from "./templates/attendee-verify-email";
|
import AttendeeVerifyEmail from "./templates/attendee-verify-email";
|
||||||
import AttendeeWasRequestedToRescheduleEmail from "./templates/attendee-was-requested-to-reschedule-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 BrokenIntegrationEmail from "./templates/broken-integration-email";
|
||||||
import DisabledAppEmail from "./templates/disabled-app-email";
|
import DisabledAppEmail from "./templates/disabled-app-email";
|
||||||
import type { Feedback } from "./templates/feedback-email";
|
import type { Feedback } from "./templates/feedback-email";
|
||||||
|
@ -437,3 +439,7 @@ export const sendMonthlyDigestEmails = async (eventData: MonthlyDigestEmailData)
|
||||||
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
|
export const sendAdminOrganizationNotification = async (input: OrganizationNotification) => {
|
||||||
await sendEmail(() => new AdminOrganizationNotification(input));
|
await sendEmail(() => new AdminOrganizationNotification(input));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const sendBookingRedirectNotification = async (bookingRedirect: IBookingRedirect) => {
|
||||||
|
await sendEmail(() => new BookingRedirectEmailNotification(bookingRedirect));
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { IBookingRedirect } from "../../templates/booking-redirect-notification";
|
||||||
|
import { BaseEmailHtml } from "../components";
|
||||||
|
|
||||||
|
export const BookingRedirectEmailNotification = (
|
||||||
|
props: IBookingRedirect & Partial<React.ComponentProps<typeof BaseEmailHtml>>
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<BaseEmailHtml
|
||||||
|
subject={props.language("booking_redirect_email_subject")}
|
||||||
|
title={props.language("booking_redirect_email_title")}>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "black",
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
fontWeight: "400",
|
||||||
|
}}>
|
||||||
|
{props.language("booking_redirect_email_description", {
|
||||||
|
toName: props.toName,
|
||||||
|
})}
|
||||||
|
{props.dates}
|
||||||
|
<br />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginTop: "16px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</BaseEmailHtml>
|
||||||
|
);
|
||||||
|
};
|
|
@ -31,3 +31,4 @@ export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail
|
||||||
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
|
export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail";
|
||||||
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
|
export { MonthlyDigestEmail } from "./MonthlyDigestEmail";
|
||||||
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
|
export { AdminOrganizationNotificationEmail } from "./AdminOrganizationNotificationEmail";
|
||||||
|
export { BookingRedirectEmailNotification } from "./BookingRedirectEmailNotification";
|
||||||
|
|
|
@ -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<Record<string, unknown>> {
|
||||||
|
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: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {};
|
||||||
|
};
|
|
@ -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 {};
|
||||||
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
import { LazyMotion, m, AnimatePresence } from "framer-motion";
|
import { LazyMotion, m, AnimatePresence } from "framer-motion";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import StickyBox from "react-sticky-box";
|
import StickyBox from "react-sticky-box";
|
||||||
import { shallow } from "zustand/shallow";
|
import { shallow } from "zustand/shallow";
|
||||||
|
@ -81,6 +81,12 @@ const BookerComponent = ({
|
||||||
const hasDarkBackground = isEmbed && embedType !== "inline";
|
const hasDarkBackground = isEmbed && embedType !== "inline";
|
||||||
const embedUiConfig = useEmbedUiConfig();
|
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
|
// 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'
|
// 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;
|
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
|
||||||
|
@ -221,6 +227,7 @@ const BookerComponent = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{event.data ? <BookingPageTagManager eventType={event.data} /> : null}
|
{event.data ? <BookingPageTagManager eventType={event.data} /> : null}
|
||||||
|
|
||||||
{bookerState !== "booking" && event.data?.isInstantEvent && (
|
{bookerState !== "booking" && event.data?.isInstantEvent && (
|
||||||
<div
|
<div
|
||||||
className="animate-fade-in-up fixed bottom-2 z-40 my-2 opacity-0"
|
className="animate-fade-in-up fixed bottom-2 z-40 my-2 opacity-0"
|
||||||
|
@ -235,6 +242,26 @@ const BookerComponent = ({
|
||||||
"text-default flex min-h-full w-full flex-col items-center",
|
"text-default flex min-h-full w-full flex-col items-center",
|
||||||
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip"
|
layout === BookerLayouts.MONTH_VIEW ? "overflow-visible" : "overflow-clip"
|
||||||
)}>
|
)}>
|
||||||
|
{/* redirect from other user profile */}
|
||||||
|
{isRedirect && (
|
||||||
|
<div className="mb-8 rounded-md bg-blue-100 p-4 dark:border dark:bg-transparent">
|
||||||
|
<h2 className="text-default mb-2 text-sm font-semibold">
|
||||||
|
{t("user_redirect_title", {
|
||||||
|
username: fromUserNameRedirected,
|
||||||
|
})}{" "}
|
||||||
|
🏝️
|
||||||
|
</h2>
|
||||||
|
<p className="text-default text-sm">
|
||||||
|
{t("user_redirect_description", {
|
||||||
|
profile: {
|
||||||
|
username: username,
|
||||||
|
},
|
||||||
|
username: fromUserNameRedirected,
|
||||||
|
})}{" "}
|
||||||
|
😄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={animationScope}
|
ref={animationScope}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
|
@ -209,6 +209,7 @@ export const BookEventFormChild = ({
|
||||||
absolute: false,
|
absolute: false,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
/* Schedule any workflow reminder that falls within 72 hours for email */
|
/* Schedule any workflow reminder that falls within 72 hours for email */
|
||||||
import client from "@sendgrid/client";
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
@ -19,7 +18,12 @@ import {
|
||||||
getAllUnscheduledReminders,
|
getAllUnscheduledReminders,
|
||||||
} from "../lib/getWorkflowReminders";
|
} from "../lib/getWorkflowReminders";
|
||||||
import { getiCalEventAsString } from "../lib/getiCalEventAsString";
|
import { getiCalEventAsString } from "../lib/getiCalEventAsString";
|
||||||
import { sendSendgridMail } from "../lib/reminders/providers/sendgridProvider";
|
import {
|
||||||
|
cancelScheduledEmail,
|
||||||
|
deleteScheduledSend,
|
||||||
|
getBatchId,
|
||||||
|
sendSendgridMail,
|
||||||
|
} from "../lib/reminders/providers/sendgridProvider";
|
||||||
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
|
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
|
||||||
import customTemplate from "../lib/reminders/templates/customTemplate";
|
import customTemplate from "../lib/reminders/templates/customTemplate";
|
||||||
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
|
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
|
||||||
|
@ -42,10 +46,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const deletePromises: Promise<any>[] = [];
|
const deletePromises: Promise<any>[] = [];
|
||||||
|
|
||||||
for (const reminder of remindersToDelete) {
|
for (const reminder of remindersToDelete) {
|
||||||
const deletePromise = client.request({
|
const deletePromise = deleteScheduledSend(reminder.referenceId);
|
||||||
url: `/v3/user/scheduled_sends/${reminder.referenceId}`,
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
deletePromises.push(deletePromise);
|
deletePromises.push(deletePromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,14 +74,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const cancelUpdatePromises: Promise<any>[] = [];
|
const cancelUpdatePromises: Promise<any>[] = [];
|
||||||
|
|
||||||
for (const reminder of remindersToCancel) {
|
for (const reminder of remindersToCancel) {
|
||||||
const cancelPromise = client.request({
|
const cancelPromise = cancelScheduledEmail(reminder.referenceId);
|
||||||
url: "/v3/user/scheduled_sends",
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
batch_id: reminder.referenceId,
|
|
||||||
status: "cancel",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatePromise = prisma.workflowReminder.update({
|
const updatePromise = prisma.workflowReminder.update({
|
||||||
where: {
|
where: {
|
||||||
|
@ -222,12 +216,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
|
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
|
||||||
const batchIdResponse = await client.request({
|
const batchId = await getBatchId();
|
||||||
url: "/v3/mail/batch",
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
const batchId = batchIdResponse[1].batch_id;
|
|
||||||
|
|
||||||
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
|
if (reminder.workflowStep.action !== WorkflowActions.EMAIL_ADDRESS) {
|
||||||
sendEmailPromises.push(
|
sendEmailPromises.push(
|
||||||
|
@ -298,12 +287,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
!!reminder.booking.user?.hideBranding
|
!!reminder.booking.user?.hideBranding
|
||||||
);
|
);
|
||||||
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
|
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
|
||||||
const batchIdResponse = await client.request({
|
const batchId = await getBatchId();
|
||||||
url: "/v3/mail/batch",
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
const batchId = batchIdResponse[1].batch_id;
|
|
||||||
|
|
||||||
sendEmailPromises.push(
|
sendEmailPromises.push(
|
||||||
sendSendgridMail(
|
sendSendgridMail(
|
||||||
|
@ -343,7 +327,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({ message: `${unscheduledReminders.length} Emails scheduled` });
|
res.status(200).json({ message: `${unscheduledReminders.length} Emails to schedule` });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defaultHandler({
|
export default defaultHandler({
|
||||||
|
|
|
@ -77,3 +77,35 @@ export function sendSendgridMail(
|
||||||
sendAt: mailData.sendAt,
|
sendAt: mailData.sendAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelScheduledEmail(referenceId: string | null) {
|
||||||
|
if (!referenceId) {
|
||||||
|
console.info("No referenceId provided, skip canceling email");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSendgrid();
|
||||||
|
|
||||||
|
return client.request({
|
||||||
|
url: "/v3/user/scheduled_sends",
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
batch_id: referenceId,
|
||||||
|
status: "cancel",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteScheduledSend(referenceId: string | null) {
|
||||||
|
if (!referenceId) {
|
||||||
|
console.info("No referenceId provided, skip deleting scheduledSend");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSendgrid();
|
||||||
|
|
||||||
|
return client.request({
|
||||||
|
url: `/v3/user/scheduled_sends/${referenceId}`,
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -11,12 +11,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-cartesian-grid-horizontal line{
|
.recharts-cartesian-grid-horizontal line {
|
||||||
@apply stroke-emphasis
|
@apply stroke-emphasis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-button button{
|
.tremor-DateRangePicker-button button {
|
||||||
@apply !h-9 !max-h-9 border-default hover:border-emphasis
|
@apply border-default hover:border-emphasis !h-9 !max-h-9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarButton,
|
.tremor-DateRangePicker-calendarButton,
|
||||||
|
@ -24,12 +24,12 @@
|
||||||
@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;
|
@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{
|
.tremor-DateRangePicker-dropdownModal {
|
||||||
@apply divide-none
|
@apply divide-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DropdownItem-root{
|
.tremor-DropdownItem-root {
|
||||||
@apply !h-9 !max-h-9 bg-default hover:bg-subtle text-default hover:text-emphasis
|
@apply bg-default hover:bg-subtle text-default hover:text-emphasis !h-9 !max-h-9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarButtonText,
|
.tremor-DateRangePicker-calendarButtonText,
|
||||||
|
@ -37,56 +37,54 @@
|
||||||
@apply text-default;
|
@apply text-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarHeaderText{
|
.tremor-DateRangePicker-calendarHeaderText {
|
||||||
@apply !text-default
|
@apply !text-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarHeader svg{
|
.tremor-DateRangePicker-calendarHeader svg {
|
||||||
@apply text-default
|
@apply text-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarHeader button{
|
.tremor-DateRangePicker-calendarHeader button {
|
||||||
@apply hover:bg-emphasis shadow-none focus:ring-0
|
@apply hover:bg-emphasis shadow-none focus:ring-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tremor-DateRangePicker-calendarHeader button:hover svg {
|
||||||
.tremor-DateRangePicker-calendarHeader button:hover svg{
|
@apply text-emphasis;
|
||||||
@apply text-emphasis
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarButtonIcon{
|
.tremor-DateRangePicker-calendarButtonIcon {
|
||||||
@apply text-default
|
@apply text-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarModal,
|
.tremor-DateRangePicker-calendarModal,
|
||||||
.tremor-DateRangePicker-dropdownModal {
|
.tremor-DateRangePicker-dropdownModal {
|
||||||
@apply bg-default border-subtle shadow-dropdown
|
@apply bg-default border-subtle shadow-dropdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarBodyDate button{
|
.tremor-DateRangePicker-calendarBodyDate button {
|
||||||
@apply text-default hover:bg-emphasis
|
@apply text-default hover:bg-emphasis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarBodyDate button:disabled,
|
.tremor-DateRangePicker-calendarBodyDate button:disabled,
|
||||||
.tremor-DateRangePicker-calendarBodyDate button[disabled]{
|
.tremor-DateRangePicker-calendarBodyDate button[disabled] {
|
||||||
@apply opacity-25
|
@apply opacity-25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarHeader button{
|
.tremor-DateRangePicker-calendarHeader button {
|
||||||
@apply border-default text-default
|
@apply border-default text-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100{
|
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100 {
|
||||||
@apply bg-subtle
|
@apply bg-subtle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500{
|
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500 {
|
||||||
@apply !bg-brand-default text-inverted
|
@apply !bg-brand-default text-inverted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.tremor-Card-root {
|
.tremor-Card-root {
|
||||||
@apply p-5 bg-default;
|
@apply bg-default p-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tremor-TableCell-root {
|
.tremor-TableCell-root {
|
||||||
|
|
|
@ -43,6 +43,7 @@ const tabs: VerticalTabItemProps[] = [
|
||||||
{ name: "calendars", href: "/settings/my-account/calendars" },
|
{ name: "calendars", href: "/settings/my-account/calendars" },
|
||||||
{ name: "conferencing", href: "/settings/my-account/conferencing" },
|
{ name: "conferencing", href: "/settings/my-account/conferencing" },
|
||||||
{ name: "appearance", href: "/settings/my-account/appearance" },
|
{ name: "appearance", href: "/settings/my-account/appearance" },
|
||||||
|
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
|
||||||
// TODO
|
// TODO
|
||||||
// { name: "referrals", href: "/settings/my-account/referrals" },
|
// { name: "referrals", href: "/settings/my-account/referrals" },
|
||||||
],
|
],
|
||||||
|
|
|
@ -420,6 +420,7 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||||
<Dropdown open={menuOpen}>
|
<Dropdown open={menuOpen}>
|
||||||
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)}>
|
<DropdownMenuTrigger asChild onClick={() => setMenuOpen((menuOpen) => !menuOpen)}>
|
||||||
<button
|
<button
|
||||||
|
data-testid="user-dropdown-trigger-button"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"hover:bg-emphasis todesktop:!bg-transparent group mx-0 flex w-full cursor-pointer appearance-none items-center rounded-full text-left outline-none transition focus:outline-none focus:ring-0 md:rounded-none lg:rounded",
|
"hover:bg-emphasis todesktop:!bg-transparent group mx-0 flex w-full cursor-pointer appearance-none items-center rounded-full text-left outline-none transition focus:outline-none focus:ring-0 md:rounded-none lg:rounded",
|
||||||
small ? "p-2" : "px-2 py-1.5"
|
small ? "p-2" : "px-2 py-1.5"
|
||||||
|
@ -496,10 +497,8 @@ function UserDropdown({ small }: UserDropdownProps) {
|
||||||
StartIcon={(props) => (
|
StartIcon={(props) => (
|
||||||
<Moon className={classNames("text-default", props.className)} aria-hidden="true" />
|
<Moon className={classNames("text-default", props.className)} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
href="/settings/my-account/out-of-office">
|
||||||
mutation.mutate({ away: !user.away });
|
{t("out_of_office")}
|
||||||
}}>
|
|
||||||
{user.away ? t("set_as_free") : t("set_as_away")}
|
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
@ -893,7 +892,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
|
||||||
<div className="flex h-full flex-col justify-between py-3 lg:pt-4">
|
<div className="flex h-full flex-col justify-between py-3 lg:pt-4">
|
||||||
<header className="todesktop:-mt-3 todesktop:flex-col-reverse todesktop:[-webkit-app-region:drag] items-center justify-between md:hidden lg:flex">
|
<header className="todesktop:-mt-3 todesktop:flex-col-reverse todesktop:[-webkit-app-region:drag] items-center justify-between md:hidden lg:flex">
|
||||||
{orgBranding ? (
|
{orgBranding ? (
|
||||||
<Link href="/settings/organizations/profile" className="px-1.5">
|
<Link href="/settings/organizations/profile" className="mt-3 w-full px-1.5">
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="flex items-center gap-2 font-medium">
|
||||||
<Avatar
|
<Avatar
|
||||||
alt={`${orgBranding.name} logo`}
|
alt={`${orgBranding.name} logo`}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "OutOfOfficeEntry" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"uuid" TEXT NOT NULL,
|
||||||
|
"start" TIMESTAMP(3) NOT NULL,
|
||||||
|
"end" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"toUserId" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "OutOfOfficeEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "OutOfOfficeEntry_uuid_key" ON "OutOfOfficeEntry"("uuid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutOfOfficeEntry_uuid_idx" ON "OutOfOfficeEntry"("uuid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutOfOfficeEntry_userId_idx" ON "OutOfOfficeEntry"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutOfOfficeEntry_toUserId_idx" ON "OutOfOfficeEntry"("toUserId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "OutOfOfficeEntry_start_end_idx" ON "OutOfOfficeEntry"("start", "end");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OutOfOfficeEntry" ADD CONSTRAINT "OutOfOfficeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "OutOfOfficeEntry" ADD CONSTRAINT "OutOfOfficeEntry_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -257,6 +257,8 @@ model User {
|
||||||
organizationId Int?
|
organizationId Int?
|
||||||
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
|
organization Team? @relation("scope", fields: [organizationId], references: [id], onDelete: SetNull)
|
||||||
accessCodes AccessCode[]
|
accessCodes AccessCode[]
|
||||||
|
bookingRedirects OutOfOfficeEntry[]
|
||||||
|
bookingRedirectsTo OutOfOfficeEntry[] @relation(name: "toUser")
|
||||||
// Linking account code for orgs v2
|
// Linking account code for orgs v2
|
||||||
//linkedByUserId Int?
|
//linkedByUserId Int?
|
||||||
//linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade)
|
//linkedBy User? @relation("linked_account", fields: [linkedByUserId], references: [id], onDelete: Cascade)
|
||||||
|
@ -1053,3 +1055,22 @@ model Avatar {
|
||||||
@@unique([teamId, userId])
|
@@unique([teamId, userId])
|
||||||
@@map(name: "avatars")
|
@@map(name: "avatars")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model OutOfOfficeEntry {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String @unique
|
||||||
|
start DateTime
|
||||||
|
end DateTime
|
||||||
|
userId Int
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
toUserId Int?
|
||||||
|
toUser User? @relation(name: "toUser", fields: [toUserId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([uuid])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([toUserId])
|
||||||
|
@@index([start, end])
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schem
|
||||||
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
||||||
import { ZIntegrationsInputSchema } from "./integrations.schema";
|
import { ZIntegrationsInputSchema } from "./integrations.schema";
|
||||||
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
|
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
|
||||||
|
import { ZOutOfOfficeInputSchema, ZOutOfOfficeDelete } from "./outOfOffice.schema";
|
||||||
import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
|
import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
|
||||||
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
|
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
|
||||||
import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema";
|
import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema";
|
||||||
|
@ -47,6 +48,9 @@ type AppsRouterHandlerCache = {
|
||||||
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
|
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
|
||||||
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
|
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
|
||||||
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
|
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
|
||||||
|
outOfOfficeCreate?: typeof import("./outOfOffice.handler").outOfOfficeCreate;
|
||||||
|
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
|
||||||
|
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
||||||
|
@ -434,7 +438,6 @@ export const loggedInViewerRouter = router({
|
||||||
|
|
||||||
return UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery({ ctx });
|
return UNSTABLE_HANDLER_CACHE.teamsAndUserProfilesQuery({ ctx });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
connectAndJoin: authedProcedure.input(ZConnectAndJoinInputSchema).mutation(async ({ ctx, input }) => {
|
connectAndJoin: authedProcedure.input(ZConnectAndJoinInputSchema).mutation(async ({ ctx, input }) => {
|
||||||
if (!UNSTABLE_HANDLER_CACHE.connectAndJoin) {
|
if (!UNSTABLE_HANDLER_CACHE.connectAndJoin) {
|
||||||
UNSTABLE_HANDLER_CACHE.connectAndJoin = (await import("./connectAndJoin.handler")).Handler;
|
UNSTABLE_HANDLER_CACHE.connectAndJoin = (await import("./connectAndJoin.handler")).Handler;
|
||||||
|
@ -447,4 +450,44 @@ export const loggedInViewerRouter = router({
|
||||||
|
|
||||||
return UNSTABLE_HANDLER_CACHE.connectAndJoin({ ctx, input });
|
return UNSTABLE_HANDLER_CACHE.connectAndJoin({ ctx, input });
|
||||||
}),
|
}),
|
||||||
|
outOfOfficeCreate: authedProcedure.input(ZOutOfOfficeInputSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreate) {
|
||||||
|
UNSTABLE_HANDLER_CACHE.outOfOfficeCreate = (await import("./outOfOffice.handler")).outOfOfficeCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable code but required for type safety
|
||||||
|
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreate) {
|
||||||
|
throw new Error("Failed to load handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNSTABLE_HANDLER_CACHE.outOfOfficeCreate({ ctx, input });
|
||||||
|
}),
|
||||||
|
outOfOfficeEntriesList: authedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
|
||||||
|
UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList = (
|
||||||
|
await import("./outOfOffice.handler")
|
||||||
|
).outOfOfficeEntriesList;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable code but required for type safety
|
||||||
|
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
|
||||||
|
throw new Error("Failed to load handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList({ ctx });
|
||||||
|
}),
|
||||||
|
outOfOfficeEntryDelete: authedProcedure.input(ZOutOfOfficeDelete).mutation(async ({ ctx, input }) => {
|
||||||
|
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete) {
|
||||||
|
UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete = (
|
||||||
|
await import("./outOfOffice.handler")
|
||||||
|
).outOfOfficeEntryDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unreachable code but required for type safety
|
||||||
|
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete) {
|
||||||
|
throw new Error("Failed to load handler");
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete({ ctx, input });
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,239 @@
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import { sendBookingRedirectNotification } from "@calcom/emails";
|
||||||
|
import { getTranslation } from "@calcom/lib/server";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
|
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||||
|
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { TOutOfOfficeDelete, TOutOfOfficeInputSchema } from "./outOfOffice.schema";
|
||||||
|
|
||||||
|
type TBookingRedirect = {
|
||||||
|
ctx: {
|
||||||
|
user: NonNullable<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
input: TOutOfOfficeInputSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
|
||||||
|
if (!input.startDate || !input.endDate) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_and_end_date_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStartTime = dayjs(input.startDate).startOf("day");
|
||||||
|
const inputEndTime = dayjs(input.endDate).endOf("day");
|
||||||
|
const offset = dayjs(inputStartTime).utcOffset();
|
||||||
|
|
||||||
|
// If start date is after end date throw error
|
||||||
|
if (inputStartTime.isAfter(inputEndTime)) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_before_end_date" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If start date is before to today throw error
|
||||||
|
// Since this validation is done using server tz, we need to account for the offset
|
||||||
|
if (
|
||||||
|
inputStartTime.isBefore(
|
||||||
|
dayjs()
|
||||||
|
.startOf("day")
|
||||||
|
.subtract(Math.abs(offset) * 60, "minute")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_in_the_future" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let toUserId;
|
||||||
|
|
||||||
|
if (input.toTeamUserId) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.toTeamUserId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "user_not_found" });
|
||||||
|
}
|
||||||
|
toUserId = user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate if OOO entry for these dates already exists
|
||||||
|
const outOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ userId: ctx.user.id },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
start: {
|
||||||
|
lt: inputEndTime.toISOString(), //existing start is less than or equal to input end time
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
gt: inputStartTime.toISOString(), //existing end is greater than or equal to input start time
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
//existing start is within the new input range
|
||||||
|
start: {
|
||||||
|
gt: inputStartTime.toISOString(),
|
||||||
|
lt: inputEndTime.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
//existing end is within the new input range
|
||||||
|
end: {
|
||||||
|
gt: inputStartTime.toISOString(),
|
||||||
|
lt: inputEndTime.toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// don't allow overlapping entries
|
||||||
|
if (outOfOfficeEntry) {
|
||||||
|
throw new TRPCError({ code: "CONFLICT", message: "out_of_office_entry_already_exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent infinite redirects but consider time ranges
|
||||||
|
const existingOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
toUserId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
userId: toUserId,
|
||||||
|
toUserId: ctx.user.id,
|
||||||
|
// Check for time overlap or collision
|
||||||
|
OR: [
|
||||||
|
// Outside of range
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ start: { lte: inputEndTime.toISOString() } },
|
||||||
|
{ end: { gte: inputStartTime.toISOString() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Inside of range
|
||||||
|
{
|
||||||
|
AND: [
|
||||||
|
{ start: { gte: inputStartTime.toISOString() } },
|
||||||
|
{ end: { lte: inputEndTime.toISOString() } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// don't allow infinite redirects
|
||||||
|
if (existingOutOfOfficeEntry) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "booking_redirect_infinite_not_allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdRedirect = await prisma.outOfOfficeEntry.create({
|
||||||
|
data: {
|
||||||
|
uuid: uuidv4(),
|
||||||
|
start: dayjs(input.startDate).startOf("day").toISOString(),
|
||||||
|
end: dayjs(input.endDate).endOf("day").toISOString(),
|
||||||
|
userId: ctx.user.id,
|
||||||
|
toUserId: toUserId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toUserId) {
|
||||||
|
// await send email to notify user
|
||||||
|
const userToNotify = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: toUserId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||||
|
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(createdRedirect.start);
|
||||||
|
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(createdRedirect.end);
|
||||||
|
if (userToNotify?.email) {
|
||||||
|
await sendBookingRedirectNotification({
|
||||||
|
language: t,
|
||||||
|
fromEmail: ctx.user.email,
|
||||||
|
toEmail: userToNotify.email,
|
||||||
|
toName: ctx.user.username || "",
|
||||||
|
dates: `${formattedStartDate} - ${formattedEndDate}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
type TBookingRedirectDelete = {
|
||||||
|
ctx: {
|
||||||
|
user: NonNullable<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
input: TOutOfOfficeDelete;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDelete) => {
|
||||||
|
if (!input.outOfOfficeUid) {
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: "out_of_office_id_required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate outOfOfficeEntry belongs to the user deleting it
|
||||||
|
const outOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||||
|
select: {
|
||||||
|
uuid: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
uuid: input.outOfOfficeUid,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outOfOfficeEntry) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.outOfOfficeEntry.delete({
|
||||||
|
where: {
|
||||||
|
uuid: input.outOfOfficeUid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable<TrpcSessionUser> } }) => {
|
||||||
|
const outOfOfficeEntries = await prisma.outOfOfficeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId: ctx.user.id,
|
||||||
|
end: {
|
||||||
|
gte: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
start: "desc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uuid: true,
|
||||||
|
start: true,
|
||||||
|
end: true,
|
||||||
|
toUserId: true,
|
||||||
|
toUser: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return outOfOfficeEntries;
|
||||||
|
};
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const ZOutOfOfficeInputSchema = z.object({
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
toTeamUserId: z.number().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TOutOfOfficeInputSchema = z.infer<typeof ZOutOfOfficeInputSchema>;
|
||||||
|
|
||||||
|
export const ZOutOfOfficeDelete = z.object({
|
||||||
|
outOfOfficeUid: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TOutOfOfficeDelete = z.infer<typeof ZOutOfOfficeDelete>;
|
|
@ -77,6 +77,16 @@ export { TopBanner } from "./components/top-banner";
|
||||||
export type { TopBannerProps } from "./components/top-banner";
|
export type { TopBannerProps } from "./components/top-banner";
|
||||||
export { AnimatedPopover, MeetingTimeInTimezones } from "./components/popover";
|
export { AnimatedPopover, MeetingTimeInTimezones } from "./components/popover";
|
||||||
export { Table } from "./components/table/Table";
|
export { Table } from "./components/table/Table";
|
||||||
|
export {
|
||||||
|
Table as TableNew,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "./components/table/TableNew";
|
||||||
export { TableActions, DropdownActions } from "./components/table/TableActions";
|
export { TableActions, DropdownActions } from "./components/table/TableActions";
|
||||||
export type { ActionType } from "./components/table/TableActions";
|
export type { ActionType } from "./components/table/TableActions";
|
||||||
export { ErrorBoundary } from "./components/errorBoundary";
|
export { ErrorBoundary } from "./components/errorBoundary";
|
||||||
|
|
Loading…
Reference in New Issue
Block a user