@@ -247,45 +371,47 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
setSearchText(e.target.value)}
- value={searchText}
+ onChange={(e) => setSearchRedirectMember(e.target.value)}
+ value={searchRedirectMember}
/>
- {memberListOptions.map((member) => (
-
- ))}
-
+ {redirectToMemberListOptions
+ .filter((member) => member.value !== getValues("forUserId"))
+ .map((member) => (
+
+ ))}
+
@@ -309,7 +435,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
form="create-or-edit-ooo-form"
color="primary"
type="submit"
- disabled={isSubmitting}
+ disabled={isSubmitting || isReasonListPending}
data-testid="create-or-edit-entry-ooo-redirect">
{currentlyEditingOutOfOfficeEntry ? t("save") : t("create")}
diff --git a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx
index 5ffb15d826..0dd5016b41 100644
--- a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx
+++ b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx
@@ -1,88 +1,144 @@
"use client";
+import { keepPreviousData } from "@tanstack/react-query";
+import { getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { Trans } from "next-i18next";
-import { useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFormState } from "react-hook-form";
import dayjs from "@calcom/dayjs";
+import { DataTable, DataTableToolbar } from "@calcom/features/data-table";
+import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
+import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
-import {
- Button,
- EmptyScreen,
- Icon,
- showToast,
- SkeletonText,
- TableBody,
- TableCell,
- TableNew,
- TableRow,
- Tooltip,
-} from "@calcom/ui";
+import { Avatar, Button, EmptyScreen, Icon, showToast, SkeletonText, Tooltip } from "@calcom/ui";
import CreateNewOutOfOfficeEntryButton from "./CreateNewOutOfOfficeEntryButton";
import { CreateOrEditOutOfOfficeEntryModal } from "./CreateOrEditOutOfOfficeModal";
import type { BookingRedirectForm } from "./CreateOrEditOutOfOfficeModal";
+import { OutOfOfficeTab } from "./OutOfOfficeToggleGroup";
+
+interface OutOfOfficeEntry {
+ id: number;
+ uuid: string;
+ start: Date;
+ end: Date;
+ toUserId: number | null;
+ toUser: {
+ username: string;
+ name: string;
+ } | null;
+ reason: {
+ id: number;
+ emoji: string;
+ reason: string;
+ userId: number;
+ } | null;
+ notes: string | null;
+ user: { id: number; avatarUrl: string; username: string; email: string; name: string } | null;
+}
export const OutOfOfficeEntriesList = () => {
const { t } = useLocale();
- const utils = trpc.useUtils();
- const { data, isPending } = 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 occurred`, "error");
- },
- });
-
+ const tableContainerRef = useRef
(null);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [deletedEntry, setDeletedEntry] = useState(0);
const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] =
useState(null);
const [openModal, setOpenModal] = useState(false);
-
const editOutOfOfficeEntry = (entry: BookingRedirectForm) => {
setCurrentlyEditingOutOfOfficeEntry(entry);
setOpenModal(true);
};
- if (data === null || data?.length === 0 || (data === undefined && !isPending))
- return (
- }
- customIcon={
-
-
-
-
-
+ const searchParams = useCompatSearchParams();
+ const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
+
+ const { data, isPending, fetchNextPage, isFetching, refetch } =
+ trpc.viewer.outOfOfficeEntriesList.useInfiniteQuery(
+ {
+ limit: 10,
+ fetchTeamMembersEntries: selectedTab === OutOfOfficeTab.TEAM,
+ searchTerm: selectedTab === OutOfOfficeTab.TEAM ? searchTerm : undefined,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ placeholderData: keepPreviousData,
+ }
+ );
+
+ useEffect(() => {
+ refetch();
+ if (selectedTab === OutOfOfficeTab.MINE) {
+ setSearchTerm("");
+ }
+ }, [deletedEntry, selectedTab, refetch]);
+
+ const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
+ const flatData = useMemo(
+ () =>
+ isPending || isFetching ? new Array(5).fill(null) : data?.pages?.flatMap((page) => page.rows) ?? [],
+ [data, selectedTab, isPending, isFetching, searchTerm]
+ ) as OutOfOfficeEntry[];
+ const totalFetched = flatData.length;
+
+ const memoColumns = useMemo(() => {
+ const columns: ColumnDef
[] = [];
+ if (selectedTab === OutOfOfficeTab.TEAM) {
+ columns.push({
+ id: "member",
+ header: `Member`,
+ size: 300,
+ cell: ({ row }) => {
+ if (!row.original || !row.original.user || isPending || isFetching) {
+ return ;
+ }
+ const { avatarUrl, username, email, name } = row.original.user;
+ const memberName =
+ name ||
+ (() => {
+ const emailName = email.split("@")[0];
+ return emailName.charAt(0).toUpperCase() + emailName.slice(1);
+ })();
+ return (
+
- }
- />
- );
- return (
-
-
-
- {data?.map((item) => (
-
-
+ );
+ },
+ });
+ }
+ columns.push({
+ id: "outOfOffice",
+ header: `${t("out_of_office")} (${totalDBRowCount})`,
+ size: selectedTab === OutOfOfficeTab.TEAM ? 370 : 660,
+ cell: ({ row }) => {
+ const item = row.original;
+ return (
+ <>
+ {row.original && !isPending && !isFetching ? (
+
{item?.reason?.emoji || "🏝️"}
@@ -117,81 +173,186 @@ export const OutOfOfficeEntriesList = () => {
)}
+
+ ) : (
+
+ )}
+ >
+ );
+ },
+ });
+ columns.push({
+ id: "actions",
+ size: 90,
+ cell: ({ row }) => {
+ const item = row.original;
+ return (
+ <>
+ {row.original && !isPending && !isFetching ? (
+
+
+
+
+
+
+ ) : (
+
+ )}
+ >
+ );
+ },
+ });
+ return columns;
+ }, [selectedTab, isPending, isFetching]);
-
-
-
-
-
+ const fetchMoreOnBottomReached = useCallback(
+ (containerRefElement?: HTMLDivElement | null) => {
+ if (containerRefElement) {
+ const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
+ if (scrollHeight - scrollTop - clientHeight < 100 && !isFetching && totalFetched < totalDBRowCount) {
+ fetchNextPage();
+ }
+ if (isFetching) {
+ containerRefElement.classList.add("cursor-wait");
+ } else {
+ containerRefElement.classList.remove("cursor-wait");
+ }
+ }
+ },
+ [fetchNextPage, isFetching, totalFetched, totalDBRowCount]
+ );
+
+ const table = useReactTable({
+ data: flatData,
+ columns: memoColumns,
+ enableRowSelection: false,
+ debugTable: true,
+ manualPagination: true,
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ useEffect(() => {
+ fetchMoreOnBottomReached(tableContainerRef.current);
+ }, [fetchMoreOnBottomReached]);
+
+ const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({
+ onSuccess: () => {
+ showToast(t("success_deleted_entry_out_of_office"), "success");
+ setDeletedEntry((previousValue) => previousValue + 1);
+ useFormState;
+ },
+ onError: () => {
+ showToast(`An error occurred`, "error");
+ },
+ });
+
+ return (
+ <>
+ {data === null ||
+ (data?.pages?.length !== 0 && data?.pages[0].meta.totalRowCount === 0 && searchTerm === "") ||
+ (data === undefined && !isPending) ? (
+
}
+ customIcon={
+
+
+
-
-
- ))}
- {isPending && (
- <>
- {new Array(2).fill(0).map((_, index) => (
-
-
-
-
-
- ))}
- >
- )}
-
- {!isPending && (data === undefined || data.length === 0) && (
-
-
- {t("no_redirects_found")}
-
-
- )}
-
-
- {openModal && (
-
{
- setOpenModal(false);
- setCurrentlyEditingOutOfOfficeEntry(null);
- }}
- currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
+
+
+ }
/>
+ ) : (
+
+ fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
+ {selectedTab === OutOfOfficeTab.TEAM && (
+
+ setSearchTerm(value)} />
+
+ )}
+
+ {openModal && (
+ {
+ setOpenModal(false);
+ setCurrentlyEditingOutOfOfficeEntry(null);
+ }}
+ currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
+ />
+ )}
+
)}
-
+ >
);
};
diff --git a/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx b/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx
new file mode 100644
index 0000000000..78d8162646
--- /dev/null
+++ b/packages/features/settings/outOfOffice/OutOfOfficeToggleGroup.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { usePathname, useRouter } from "next/navigation";
+import { useCallback } from "react";
+
+import { useCompatSearchParams } from "@calcom/embed-core/src/useCompatSearchParams";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { MembershipRole } from "@calcom/prisma/enums";
+import { trpc } from "@calcom/trpc/react";
+import { ToggleGroup } from "@calcom/ui";
+
+export enum OutOfOfficeTab {
+ MINE = "mine",
+ TEAM = "team",
+}
+
+export const OutOfOfficeToggleGroup = () => {
+ const { t } = useLocale();
+ const searchParams = useCompatSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const createQueryString = useCallback(
+ (name: string, value: string) => {
+ const params = new URLSearchParams(searchParams ?? undefined);
+ params.set(name, value);
+
+ return params.toString();
+ },
+ [searchParams]
+ );
+
+ const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
+
+ const { data: user } = trpc.viewer.me.useQuery();
+ const { data } = trpc.viewer.organizations.listCurrent.useQuery();
+ const isOrgAdminOrOwner =
+ data && (data.user.role === MembershipRole.OWNER || data.user.role === MembershipRole.ADMIN);
+ const toggleGroupOptions = [{ value: OutOfOfficeTab.MINE, label: t("my_ooo") }];
+ const hasTeamOOOAccess = isOrgAdminOrOwner || user?.isTeamAdminOrOwner;
+ if (hasTeamOOOAccess) {
+ toggleGroupOptions.push({ value: OutOfOfficeTab.TEAM, label: t("team_ooo") });
+ }
+
+ return (
+ {
+ if (!value) return;
+ const newQuery = createQueryString("type", value);
+ router.push(`${pathname}?${newQuery}`);
+ }}
+ options={toggleGroupOptions}
+ disabled={!hasTeamOOOAccess}
+ />
+ );
+};
diff --git a/packages/trpc/server/routers/loggedInViewer/__tests__/outOfOffice.handler.test.ts b/packages/trpc/server/routers/loggedInViewer/__tests__/outOfOffice.handler.test.ts
index 03535529a7..b693711849 100644
--- a/packages/trpc/server/routers/loggedInViewer/__tests__/outOfOffice.handler.test.ts
+++ b/packages/trpc/server/routers/loggedInViewer/__tests__/outOfOffice.handler.test.ts
@@ -3,7 +3,7 @@ import { vi } from "vitest";
import { prisma } from "@calcom/prisma";
-import { outOfOfficeCreateOrUpdate } from "../outOfOffice.handler";
+import { outOfOfficeCreateOrUpdate } from "../outOfOfficeCreateOrUpdate.handler";
const prismaMock = {
outOfOfficeEntry: {
diff --git a/packages/trpc/server/routers/loggedInViewer/_router.tsx b/packages/trpc/server/routers/loggedInViewer/_router.tsx
index 7b70a4a918..ff172c5eec 100644
--- a/packages/trpc/server/routers/loggedInViewer/_router.tsx
+++ b/packages/trpc/server/routers/loggedInViewer/_router.tsx
@@ -14,7 +14,9 @@ import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLi
import { ZIntegrationsInputSchema } from "./integrations.schema";
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
import { ZNoShowInputSchema } from "./markNoShow.schema";
-import { ZOutOfOfficeInputSchema, ZOutOfOfficeDelete } from "./outOfOffice.schema";
+import { ZOutOfOfficeInputSchema } from "./outOfOfficeCreateOrUpdate.schema";
+import { ZOutOfOfficeEntriesListSchema } from "./outOfOfficeEntriesList.schema";
+import { ZOutOfOfficeDelete } from "./outOfOfficeEntryDelete.schema";
import { me } from "./procedures/me";
import { myStats } from "./procedures/myStats";
import { platformMe } from "./procedures/platformMe";
@@ -59,9 +61,9 @@ type AppsRouterHandlerCache = {
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
- outOfOfficeCreateOrUpdate?: typeof import("./outOfOffice.handler").outOfOfficeCreateOrUpdate;
- outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
- outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
+ outOfOfficeCreateOrUpdate?: typeof import("./outOfOfficeCreateOrUpdate.handler").outOfOfficeCreateOrUpdate;
+ outOfOfficeEntriesList?: typeof import("./outOfOfficeEntriesList.handler").outOfOfficeEntriesList;
+ outOfOfficeEntryDelete?: typeof import("./outOfOfficeEntryDelete.handler").outOfOfficeEntryDelete;
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
getTravelSchedules?: typeof import("./getTravelSchedules.handler").getTravelSchedulesHandler;
outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList;
@@ -432,7 +434,7 @@ export const loggedInViewerRouter = router({
.mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate) {
UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate = (
- await import("./outOfOffice.handler")
+ await import("./outOfOfficeCreateOrUpdate.handler")
).outOfOfficeCreateOrUpdate;
}
@@ -443,10 +445,10 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.outOfOfficeCreateOrUpdate({ ctx, input });
}),
- outOfOfficeEntriesList: authedProcedure.query(async ({ ctx }) => {
+ outOfOfficeEntriesList: authedProcedure.input(ZOutOfOfficeEntriesListSchema).query(async (opts) => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList = (
- await import("./outOfOffice.handler")
+ await import("./outOfOfficeEntriesList.handler")
).outOfOfficeEntriesList;
}
@@ -455,12 +457,12 @@ export const loggedInViewerRouter = router({
throw new Error("Failed to load handler");
}
- return UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList({ ctx });
+ return UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList(opts);
}),
outOfOfficeEntryDelete: authedProcedure.input(ZOutOfOfficeDelete).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete) {
UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete = (
- await import("./outOfOffice.handler")
+ await import("./outOfOfficeEntryDelete.handler")
).outOfOfficeEntryDelete;
}
diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts
index 61cb3c977e..db90b4f318 100644
--- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts
+++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts
@@ -4,7 +4,7 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
-import { IdentityProvider } from "@calcom/prisma/enums";
+import { IdentityProvider, MembershipRole } from "@calcom/prisma/enums";
import { userMetadata } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
@@ -94,6 +94,18 @@ export const meHandler = async ({ ctx, input }: MeOptions) => {
organizationSettings: user?.profile?.organization?.organizationSettings,
};
+ const isTeamAdminOrOwner =
+ (await prisma.membership.findFirst({
+ where: {
+ userId: user.id,
+ accepted: true,
+ role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
+ },
+ select: {
+ id: true,
+ },
+ })) !== null;
+
return {
id: user.id,
name: user.name,
@@ -132,5 +144,6 @@ export const meHandler = async ({ ctx, input }: MeOptions) => {
secondaryEmails,
isPremium: userMetadataPrased?.isPremium,
...(passwordAdded ? { passwordAdded } : {}),
+ isTeamAdminOrOwner,
};
};
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOffice.utils.ts b/packages/trpc/server/routers/loggedInViewer/outOfOffice.utils.ts
new file mode 100644
index 0000000000..8642852f3a
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOffice.utils.ts
@@ -0,0 +1,34 @@
+import prisma from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+export const isAdminForUser = async (adminUserId: number, memberUserId: number) => {
+ const adminTeams = await prisma.membership.findMany({
+ where: {
+ userId: adminUserId,
+ accepted: true,
+ role: {
+ in: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ select: {
+ teamId: true,
+ },
+ });
+
+ const adminTeamIds = adminTeams?.map((team) => team.teamId);
+
+ const member = await prisma.membership.findFirst({
+ where: {
+ userId: memberUserId,
+ accepted: true,
+ teamId: {
+ in: adminTeamIds,
+ },
+ },
+ select: {
+ id: true,
+ },
+ });
+
+ return !!member?.id;
+};
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOffice.handler.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeCreateOrUpdate.handler.ts
similarity index 72%
rename from packages/trpc/server/routers/loggedInViewer/outOfOffice.handler.ts
rename to packages/trpc/server/routers/loggedInViewer/outOfOfficeCreateOrUpdate.handler.ts
index ecd80a9954..d237fdb2dd 100644
--- a/packages/trpc/server/routers/loggedInViewer/outOfOffice.handler.ts
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeCreateOrUpdate.handler.ts
@@ -15,7 +15,8 @@ import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
-import type { TOutOfOfficeDelete, TOutOfOfficeInputSchema } from "./outOfOffice.schema";
+import { isAdminForUser } from "./outOfOffice.utils";
+import { type TOutOfOfficeInputSchema } from "./outOfOfficeCreateOrUpdate.schema";
type TBookingRedirect = {
ctx: {
@@ -38,9 +39,36 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_before_end_date" });
}
+ let oooUserId = ctx.user.id;
+ let oooUserName = ctx.user.username;
+ let oooUserEmail = ctx.user.email;
+ let oooUserTimeZone = ctx.user.timeZone;
+ let oooUserOrgId = ctx.user.organizationId;
+ let oooUserFullName = ctx.user.name;
+
+ let isAdmin;
+ if (!!input.forUserId) {
+ isAdmin = await isAdminForUser(ctx.user.id, input.forUserId);
+ if (!isAdmin) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "only_admin_can_create_ooo" });
+ }
+ oooUserId = input.forUserId;
+ const oooForUser = await prisma.user.findUnique({
+ where: { id: input.forUserId },
+ select: { username: true, email: true, timeZone: true, organizationId: true, name: true },
+ });
+ if (oooForUser) {
+ oooUserEmail = oooForUser.email;
+ oooUserName = oooForUser.username;
+ oooUserFullName = oooForUser.name;
+ oooUserTimeZone = oooForUser.timeZone;
+ oooUserOrgId = oooForUser.organizationId;
+ }
+ }
+
let toUserId: number | null = null;
- if (input.toTeamUserId === ctx.user.id) {
+ if (input.toTeamUserId === oooUserId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "cannot_redirect_to_self" });
}
@@ -48,13 +76,13 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
const user = await prisma.user.findUnique({
where: {
id: input.toTeamUserId,
- /** You can only create OOO for members of teams you belong to */
+ /** You can only redirect OOO for members of teams you belong to */
teams: {
some: {
team: {
members: {
some: {
- userId: ctx.user.id,
+ userId: oooUserId,
accepted: true,
},
},
@@ -67,7 +95,10 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
},
});
if (!user) {
- throw new TRPCError({ code: "NOT_FOUND", message: "user_not_found" });
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: input.forUserId ? "forward_to_team_member_only" : "user_not_found",
+ });
}
toUserId = user?.id;
}
@@ -84,7 +115,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
},
where: {
...(toUserId && { userId: toUserId }),
- toUserId: ctx.user.id,
+ toUserId: oooUserId,
// Check for time overlap or collision
OR: [
// Outside of range
@@ -101,12 +132,17 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
// don't allow infinite redirects
if (existingOutOfOfficeEntry) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "booking_redirect_infinite_not_allowed" });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: input.forUserId
+ ? "ooo_team_redirect_infinite_not_allowed"
+ : "booking_redirect_infinite_not_allowed",
+ });
}
const isDuplicateOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
where: {
- userId: ctx.user.id,
+ userId: oooUserId,
start: startTimeUtc.toISOString(),
end: endTimeUtc.toISOString(),
},
@@ -136,14 +172,14 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
const createdOrUpdatedOutOfOffice = await prisma.outOfOfficeEntry.upsert({
where: {
uuid: input.uuid ?? "",
- userId: ctx.user.id,
+ userId: oooUserId,
},
create: {
uuid: uuidv4(),
start: startTimeUtc.toISOString(),
end: endTimeUtc.toISOString(),
notes: input.notes,
- userId: ctx.user.id,
+ userId: oooUserId,
reasonId: input.reasonId,
toUserId: toUserId,
createdAt: new Date(),
@@ -153,7 +189,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
start: startTimeUtc.toISOString(),
end: endTimeUtc.toISOString(),
notes: input.notes,
- userId: ctx.user.id,
+ userId: oooUserId,
reasonId: input.reasonId,
toUserId: toUserId ? toUserId : null,
},
@@ -225,8 +261,8 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
if (existingRedirectedUser && existingRedirectedUser?.email !== userToNotify?.email) {
await sendBookingRedirectNotification({
language: t,
- fromEmail: ctx.user.email,
- eventOwner: ctx.user.username || ctx.user.email,
+ fromEmail: oooUserEmail,
+ eventOwner: oooUserName || oooUserEmail,
toEmail: existingRedirectedUser.email,
toName: existingRedirectedUser.username || "",
dates: `${existingFormattedStartDate} - ${existingFormattedEndDate}`,
@@ -243,8 +279,8 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
) {
await sendBookingRedirectNotification({
language: t,
- fromEmail: ctx.user.email,
- eventOwner: ctx.user.username || ctx.user.email,
+ fromEmail: oooUserEmail,
+ eventOwner: oooUserName || oooUserEmail,
toEmail: userToNotify.email,
toName: userToNotify.username || "",
oldDates: `${existingFormattedStartDate} - ${existingFormattedEndDate}`,
@@ -258,8 +294,8 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
) {
await sendBookingRedirectNotification({
language: t,
- fromEmail: ctx.user.email,
- eventOwner: ctx.user.username || ctx.user.email,
+ fromEmail: oooUserEmail,
+ eventOwner: oooUserName || oooUserEmail,
toEmail: userToNotify.email,
toName: userToNotify.username || "",
dates: `${formattedStartDate} - ${formattedEndDate}`,
@@ -271,7 +307,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
const memberships = await prisma.membership.findMany({
where: {
- userId: ctx.user.id,
+ userId: oooUserId,
accepted: true,
},
});
@@ -280,9 +316,9 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
// Send webhook to notify other services
const subscriberOptions: GetSubscriberOptions = {
- userId: ctx.user.id,
+ userId: oooUserId,
teamId: teamIds,
- orgId: ctx.user.organizationId,
+ orgId: oooUserOrgId,
triggerEvent: WebhookTriggerEvents.OOO_CREATED,
};
@@ -292,9 +328,9 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
oooEntry: {
id: createdOrUpdatedOutOfOffice.id,
start: dayjs(createdOrUpdatedOutOfOffice.start)
- .tz(ctx.user.timeZone, true)
+ .tz(oooUserTimeZone, true)
.format("YYYY-MM-DDTHH:mm:ssZ"),
- end: dayjs(createdOrUpdatedOutOfOffice.end).tz(ctx.user.timeZone, true).format("YYYY-MM-DDTHH:mm:ssZ"),
+ end: dayjs(createdOrUpdatedOutOfOffice.end).tz(oooUserTimeZone, true).format("YYYY-MM-DDTHH:mm:ssZ"),
createdAt: createdOrUpdatedOutOfOffice.createdAt.toISOString(),
updatedAt: createdOrUpdatedOutOfOffice.updatedAt.toISOString(),
notes: createdOrUpdatedOutOfOffice.notes,
@@ -304,11 +340,11 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
},
reasonId: input.reasonId,
user: {
- id: ctx.user.id,
- name: ctx.user.name,
- username: ctx.user.username,
- email: ctx.user.email,
- timeZone: ctx.user.timeZone,
+ id: oooUserId,
+ name: oooUserFullName,
+ username: oooUserName,
+ email: oooUserEmail,
+ timeZone: oooUserTimeZone,
},
toUser: toUserId
? {
@@ -341,97 +377,3 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
return {};
};
-
-type TBookingRedirectDelete = {
- ctx: {
- user: NonNullable;
- };
- input: TOutOfOfficeDelete;
-};
-
-export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDelete) => {
- if (!input.outOfOfficeUid) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "out_of_office_id_required" });
- }
-
- const deletedOutOfOfficeEntry = await prisma.outOfOfficeEntry.delete({
- where: {
- uuid: input.outOfOfficeUid,
- /** Validate outOfOfficeEntry belongs to the user deleting it */
- userId: ctx.user.id,
- },
- select: {
- start: true,
- end: true,
- toUser: {
- select: {
- email: true,
- username: true,
- },
- },
- },
- });
-
- if (!deletedOutOfOfficeEntry) {
- throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
- }
-
- // Return early if no redirect user is set, and no email needs to be send.
- if (!deletedOutOfOfficeEntry.toUser) {
- return {};
- }
-
- const t = await getTranslation(ctx.user.locale ?? "en", "common");
-
- const formattedStartDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.start);
- const formattedEndDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.end);
-
- await sendBookingRedirectNotification({
- language: t,
- fromEmail: ctx.user.email,
- eventOwner: ctx.user.username || ctx.user.email,
- toEmail: deletedOutOfOfficeEntry.toUser.email,
- toName: deletedOutOfOfficeEntry.toUser.username || "",
- dates: `${formattedStartDate} - ${formattedEndDate}`,
- action: "cancel",
- });
-
- return {};
-};
-
-export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable } }) => {
- const outOfOfficeEntries = await prisma.outOfOfficeEntry.findMany({
- where: {
- userId: ctx.user.id,
- end: {
- gte: new Date().toISOString(),
- },
- },
- orderBy: {
- start: "asc",
- },
- select: {
- id: true,
- uuid: true,
- start: true,
- end: true,
- toUserId: true,
- toUser: {
- select: {
- username: true,
- },
- },
- reason: {
- select: {
- id: true,
- emoji: true,
- reason: true,
- userId: true,
- },
- },
- notes: true,
- },
- });
-
- return outOfOfficeEntries;
-};
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeCreateOrUpdate.schema.ts
similarity index 72%
rename from packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts
rename to packages/trpc/server/routers/loggedInViewer/outOfOfficeCreateOrUpdate.schema.ts
index 76fc6d1668..4ac0781c12 100644
--- a/packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeCreateOrUpdate.schema.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
export const ZOutOfOfficeInputSchema = z.object({
uuid: z.string().nullish(),
+ forUserId: z.number().nullish(),
dateRange: z.object({
startDate: z.date(),
endDate: z.date(),
@@ -13,9 +14,3 @@ export const ZOutOfOfficeInputSchema = z.object({
});
export type TOutOfOfficeInputSchema = z.infer;
-
-export const ZOutOfOfficeDelete = z.object({
- outOfOfficeUid: z.string(),
-});
-
-export type TOutOfOfficeDelete = z.infer;
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts
new file mode 100644
index 0000000000..dd3dd91f18
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.handler.ts
@@ -0,0 +1,147 @@
+import prisma from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
+
+import { TRPCError } from "@trpc/server";
+
+import { type TOutOfOfficeEntriesListSchema } from "./outOfOfficeEntriesList.schema";
+
+type GetOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TOutOfOfficeEntriesListSchema;
+};
+
+export const outOfOfficeEntriesList = async ({ ctx, input }: GetOptions) => {
+ const { cursor, limit, fetchTeamMembersEntries, searchTerm } = input;
+ let fetchOOOEntriesForIds = [ctx.user.id];
+
+ if (fetchTeamMembersEntries) {
+ // Get teams where context user is admin or owner
+ const teams = await prisma.membership.findMany({
+ where: {
+ userId: ctx.user.id,
+ role: {
+ in: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ accepted: true,
+ },
+ select: {
+ teamId: true,
+ },
+ });
+ if (teams.length === 0) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "user_not_admin_nor_owner" });
+ }
+
+ // Fetch team member userIds
+ const teamMembers = await prisma.team.findMany({
+ where: {
+ id: {
+ in: teams.map((team) => team.teamId),
+ },
+ },
+ select: {
+ members: {
+ select: {
+ userId: true,
+ accepted: true,
+ },
+ },
+ },
+ });
+ const userIds = teamMembers
+ .flatMap((team) => team.members.filter((member) => member.accepted).map((member) => member.userId))
+ .filter((id) => id !== ctx.user.id);
+ if (userIds.length === 0) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "no_team_members" });
+ }
+ fetchOOOEntriesForIds = userIds;
+ }
+
+ const whereClause = {
+ userId: {
+ in: fetchOOOEntriesForIds,
+ },
+ end: {
+ gte: new Date().toISOString(),
+ },
+ ...(searchTerm && {
+ user: {
+ OR: [
+ {
+ email: {
+ contains: searchTerm,
+ },
+ },
+ {
+ username: {
+ contains: searchTerm,
+ },
+ },
+ ],
+ },
+ }),
+ };
+
+ const getTotalEntries = await prisma.outOfOfficeEntry.count({
+ where: whereClause,
+ });
+
+ const outOfOfficeEntries = await prisma.outOfOfficeEntry.findMany({
+ where: whereClause,
+ select: {
+ id: true,
+ uuid: true,
+ start: true,
+ end: true,
+ toUserId: true,
+ toUser: {
+ select: {
+ username: true,
+ name: true,
+ },
+ },
+ reason: {
+ select: {
+ id: true,
+ emoji: true,
+ reason: true,
+ userId: true,
+ },
+ },
+ notes: true,
+ user: fetchTeamMembersEntries
+ ? {
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ avatarUrl: true,
+ name: true,
+ },
+ }
+ : false,
+ },
+ orderBy: {
+ start: "asc",
+ },
+ cursor: cursor ? { id: cursor } : undefined,
+ take: limit + 1,
+ });
+
+ let nextCursor: number | undefined = undefined;
+ if (outOfOfficeEntries && outOfOfficeEntries.length > limit) {
+ const nextItem = outOfOfficeEntries.pop();
+ nextCursor = nextItem?.id;
+ }
+
+ return {
+ rows: outOfOfficeEntries || [],
+ nextCursor,
+ meta: {
+ totalRowCount: getTotalEntries || 0,
+ },
+ };
+};
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.schema.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.schema.ts
new file mode 100644
index 0000000000..72b76b7e1f
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntriesList.schema.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+
+export const ZOutOfOfficeEntriesListSchema = z.object({
+ limit: z.number().min(1).max(100),
+ cursor: z.number().nullish(),
+ fetchTeamMembersEntries: z.boolean().optional().default(false),
+ searchTerm: z.string().optional(),
+});
+
+export type TOutOfOfficeEntriesListSchema = z.infer;
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntryDelete.handler.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntryDelete.handler.ts
new file mode 100644
index 0000000000..116fcc2220
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntryDelete.handler.ts
@@ -0,0 +1,86 @@
+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 { isAdminForUser } from "./outOfOffice.utils";
+import { type TOutOfOfficeDelete } from "./outOfOfficeEntryDelete.schema";
+
+type TBookingRedirectDelete = {
+ ctx: {
+ user: NonNullable;
+ };
+ input: TOutOfOfficeDelete;
+};
+
+export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDelete) => {
+ if (!input.outOfOfficeUid) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "out_of_office_id_required" });
+ }
+
+ let oooUserId = ctx.user.id;
+ let oooUserEmail = ctx.user.email;
+ let oooUserName = ctx.user.username;
+
+ if (input.userId && input.userId !== ctx.user.id) {
+ const isAdmin = await isAdminForUser(ctx.user.id, input.userId);
+ if (!isAdmin) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "only_admin_can_delete_ooo" });
+ }
+ oooUserId = input.userId;
+ const oooUser = await prisma.user.findUnique({
+ where: { id: input.userId },
+ select: { username: true, email: true },
+ });
+ if (oooUser) {
+ oooUserEmail = oooUser.email;
+ oooUserName = oooUser.username;
+ }
+ }
+
+ const deletedOutOfOfficeEntry = await prisma.outOfOfficeEntry.delete({
+ where: {
+ uuid: input.outOfOfficeUid,
+ /** Validate outOfOfficeEntry belongs to the user deleting it or is admin*/
+ userId: oooUserId,
+ },
+ select: {
+ start: true,
+ end: true,
+ toUser: {
+ select: {
+ email: true,
+ username: true,
+ },
+ },
+ },
+ });
+
+ if (!deletedOutOfOfficeEntry) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
+ }
+
+ // Return early if no redirect user is set, and no email needs to be send.
+ if (!deletedOutOfOfficeEntry.toUser) {
+ return {};
+ }
+
+ const t = await getTranslation(ctx.user.locale ?? "en", "common");
+
+ const formattedStartDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.start);
+ const formattedEndDate = new Intl.DateTimeFormat("en-US").format(deletedOutOfOfficeEntry.end);
+
+ await sendBookingRedirectNotification({
+ language: t,
+ fromEmail: oooUserEmail,
+ eventOwner: oooUserName || oooUserEmail,
+ toEmail: deletedOutOfOfficeEntry.toUser.email,
+ toName: deletedOutOfOfficeEntry.toUser.username || "",
+ dates: `${formattedStartDate} - ${formattedEndDate}`,
+ action: "cancel",
+ });
+
+ return {};
+};
diff --git a/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntryDelete.schema.ts b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntryDelete.schema.ts
new file mode 100644
index 0000000000..9afa398d97
--- /dev/null
+++ b/packages/trpc/server/routers/loggedInViewer/outOfOfficeEntryDelete.schema.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const ZOutOfOfficeDelete = z.object({
+ outOfOfficeUid: z.string(),
+ userId: z.number().nullable().optional(),
+});
+
+export type TOutOfOfficeDelete = z.infer;
diff --git a/packages/ui/components/empty-screen/EmptyScreen.tsx b/packages/ui/components/empty-screen/EmptyScreen.tsx
index f0db2616b2..e171a48804 100644
--- a/packages/ui/components/empty-screen/EmptyScreen.tsx
+++ b/packages/ui/components/empty-screen/EmptyScreen.tsx
@@ -68,7 +68,8 @@ export function EmptyScreen({
{headline}
diff --git a/packages/ui/components/form/date-range-picker/DateRangePicker.tsx b/packages/ui/components/form/date-range-picker/DateRangePicker.tsx
index b933fd38c6..2140697e8e 100644
--- a/packages/ui/components/form/date-range-picker/DateRangePicker.tsx
+++ b/packages/ui/components/form/date-range-picker/DateRangePicker.tsx
@@ -85,7 +85,9 @@ export function DatePickerWithRange({
+ sideOffset={4}
+ side="bottom"
+ avoidCollisions={false}>
{calendar}