feat: Manage Team ooo or crud (#16456)

* feature for team ooo crud and past records

* added tests for ooo handler, combined list components for mine,team

* nit: removed commented code

* added current-previous tab , not to show TeamOOO tab for nonadmin

* update for type checks

* updated tests after merge

* updated outofoffice handler after merge

* for alignment with outofoffice header column

* update after resolving merge conflicts

* nit/chore: spacing

* update after changes in router migration

* ui improvements rounded corner

* updated props

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* updated dialog subtitles, review comments

* Update packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

* Update packages/trpc/server/routers/loggedInViewer/outOfOffice.schema.ts

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

* refactored to split ooo handlers, review comments

* updated test.utils

* chore

* chore: unrelated changes may be merge

* optimized query

* update due to latest changes and merge conflicts

* updates to changes in people filter

* chore

* as now taken care in DataTable

* updates after merge

* updates after merge due tp changes in datatable and others

* update import due to file splitting and new name

* update for typecheck

* update for new add button when empty entries

* nit

* replace tests with e2e tests

* chore

* include only manage team ooo

* fix type check

* Update safe-parse.ts

* use invalidate instead of props, leaving main server components as it is

* nit

* update forMember div ht in edit mode

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
This commit is contained in:
Vijay
2025-02-26 11:39:35 +00:00
committed by GitHub
co-authored by Carina Wollendorfer Amit Sharma Anik Dhabal Babu Tushar Bhatt
parent caaecf2f94
commit 8612fa71c7
19 changed files with 1046 additions and 350 deletions
@@ -4,6 +4,7 @@ import { getTranslate } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import CreateNewOutOfOfficeEntryButton from "@calcom/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton";
import { OutOfOfficeEntriesList } from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
import { OutOfOfficeToggleGroup } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -18,7 +19,12 @@ const Page = async () => {
<SettingsHeader
title={t("out_of_office")}
description={t("out_of_office_description")}
CTA={<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />}>
CTA={
<div className="flex gap-2">
<OutOfOfficeToggleGroup />
<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />
</div>
}>
<OutOfOfficeEntriesList />
</SettingsHeader>
);
+119 -26
View File
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { test } from "./lib/fixtures";
import { submitAndWaitForResponse, localize } from "./lib/testUtils";
@@ -417,7 +418,7 @@ test.describe("Out of office", () => {
//As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", "member-1");
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
await expect(
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
@@ -430,7 +431,7 @@ test.describe("Out of office", () => {
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await selectDateAndCreateOOO(page, "4", "5", "owner");
await selectDateAndCreateOOO(page, "4", "5", owner.id);
await expect(page.locator(`data-testid=table-redirect-${owner.username ?? "n-a"}`).nth(0)).toBeVisible();
});
@@ -461,26 +462,111 @@ test.describe("Out of office", () => {
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
await addOOOButton.click();
await reasonListRespPromise;
await test.step("As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'", async () => {
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", member1User?.id);
await expect(
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
});
//As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", "member-1");
await expect(
page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
await test.step("As member1, expect error while OOO is created on Next month 4th - 5th, forwarding to 'owner'", async () => {
await member1User?.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await selectDateAndCreateOOO(page, "2", "5", owner.id, 400);
await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
});
});
//As member1, expect error while OOO is created on Next month 2nd - 5th, forwarding to 'owner'
await member1User?.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
await addOOOButton.click();
await reasonListRespPromise;
await dateButton.click();
await selectDateAndCreateOOO(page, "2", "5", "owner", 400);
await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
test.describe("Team OOO", () => {
test("Create, edit and delete", async ({ page, users }) => {
const t = await localize("en");
const teamMatesObj = [{ name: "member-1" }, { name: "member-2" }, { name: "member-3" }];
const teamAdmin = await users.create(
{ name: `team-owner-${Date.now()}` },
{
hasTeam: true,
isOrg: true,
teamRole: MembershipRole.ADMIN,
teammates: teamMatesObj,
}
);
const member1User = users.get().find((user) => user.name === "member-1");
const member2User = users.get().find((user) => user.name === "member-2");
const member3User = users.get().find((user) => user.name === "member-3");
await teamAdmin.apiLogin();
const entriesListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200
);
await page.goto("/settings/my-account/out-of-office?type=team");
await page.waitForLoadState("domcontentloaded");
await entriesListRespPromise;
const addOOOButton = page.getByTestId("add_entry_ooo");
const dateButton = page.locator('[data-testid="date-range"]');
const reasonListRespPromise = page.waitForResponse(
(response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200
);
const legacyListMembersRespPromise = page.waitForResponse(
(response) => response.url().includes("legacyListMembers") && response.status() === 200
);
await addOOOButton.click();
await reasonListRespPromise;
await legacyListMembersRespPromise;
await legacyListMembersRespPromise;
await test.step("Admin can create OOO for team member and add redirect", async () => {
//OOO is created for 'member-1' on Next month 1st - 3rd, forwarding to 'member-2'
await page.getByTestId(`ooofor_username_select_${member1User?.id}`).click();
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", member2User?.id, 200, true);
await expect(
page.locator(`data-testid=table-redirect-${member2User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
});
await test.step("Reverse redirect not allowed for team member", async () => {
//Try to create OOO for 'member-2' on Next month 1st - 3rd, forwarding to 'member-1'
await page.getByTestId("add_entry_ooo").click();
await reasonListRespPromise;
await legacyListMembersRespPromise;
await legacyListMembersRespPromise;
await page.getByTestId(`ooofor_username_select_${member2User?.id}`).click();
await dateButton.click();
await selectDateAndCreateOOO(page, "1", "3", member1User?.id, 400, true);
expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy();
await page.locator(`text=${t("cancel")}`).click();
});
await test.step("Edit OOO and change redirect member", async () => {
//Change redirect member to 'member-3' for OOO created in step 1
await page.getByTestId(`ooo-edit-${member2User?.username}`).click();
await reasonListRespPromise;
await legacyListMembersRespPromise;
await legacyListMembersRespPromise;
await page.getByTestId(`team_username_select_${member3User?.id}`).click();
await saveAndWaitForResponse(page);
await expect(
page.locator(`data-testid=table-redirect-${member3User?.username ?? "n-a"}`).nth(0)
).toBeVisible();
});
await test.step("Delete OOO successfully", async () => {
await page.getByTestId(`ooo-delete-${member3User?.username}`).click();
expect(page.locator(`text=${t("success_deleted_entry_out_of_office")}`)).toBeTruthy();
});
});
});
});
@@ -504,21 +590,28 @@ async function selectDateAndCreateOOO(
page: Page,
fromDate: string,
toDate: string,
redirectToUser?: string,
expectedStatusCode = 200
redirectToUserId?: number,
expectedStatusCode = 200,
forTeamMember = false,
month: "previous-month" | "next-month" = "next-month",
editMode = false
) {
const t = await localize("en");
await page.locator(`button[name="next-month"]`).click();
await page.locator(`button[name="${month}"]`).click();
await page.locator(`button[name="day"]:text-is("${fromDate}")`).nth(0).click();
await page.locator(`button[name="day"]:text-is("${toDate}")`).nth(0).click();
await page.locator(`text=${t("create_an_out_of_office")}`).click();
editMode
? await page.locator(`text=${t("edit_an_out_of_office")}`).click()
: forTeamMember
? await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click()
: await page.locator(`text=${t("create_an_out_of_office")}`).click();
await page.getByTestId("reason_select").click();
await page.getByTestId("select-option-4").click();
await page.getByTestId("notes_input").click();
await page.getByTestId("notes_input").fill("Demo notes");
if (redirectToUser) {
if (redirectToUserId) {
await page.getByTestId("profile-redirect-switch").click();
await page.locator(`text=${redirectToUser}`).click();
await page.getByTestId(`team_username_select_${redirectToUserId}`).click();
}
await saveAndWaitForResponse(page, expectedStatusCode);
}
@@ -2964,5 +2964,17 @@
"verify_email": "Verify email",
"verify_email_change": "Verify email change",
"end_date_not_selected": "You didn't set an end date for your OOO",
"my_ooo": "My OOO",
"team_ooo": "Team OOO",
"out_of_office_team_description": "Manage you're team's OOO.",
"create_ooo_dialog_team_title": "Create Out of Office",
"create_ooo_dialog_team_subtitle": "Create an OOO for a team member",
"edit_ooo_dialog_team_subtitle": "Edit an OOO for team member",
"ooo_team_empty_title": "Create OOO for your team",
"ooo_team_empty_description": "Communicate to bookers when your team members are not available to take bookings. They can still be booked upon their return or you can forward them to another team member.",
"forward_to_team_member_only": "OOO Forwarding is only allowed to a member of the same team.",
"ooo_team_redirect_infinite_not_allowed": "There is already a reverse forwarding between these users.",
"previous_ooo_empty_title": "No previous OOO found",
"previous": "Previous",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -278,7 +278,7 @@ function EventTypeSingleLayout({
<div className="flex flex-col xl:flex-row xl:space-x-6">
<div className="hidden xl:block">
<VerticalTabs
className="primary-navigation w-full max-w-64"
className="primary-navigation max-w-64 w-full"
tabs={EventTypeTabs}
sticky
linkShallow
@@ -3,6 +3,7 @@ import { Controller, useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { classNames } from "@calcom/lib";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
@@ -25,6 +26,8 @@ import {
Input,
} from "@calcom/ui";
import { OutOfOfficeTab } from "./OutOfOfficeToggleGroup";
export type BookingRedirectForm = {
dateRange: { startDate: Date; endDate: Date };
offset: number;
@@ -32,6 +35,10 @@ export type BookingRedirectForm = {
reasonId: number;
notes?: string;
uuid?: string | null;
forUserId: number | null;
forUserName?: string;
forUserAvatar?: string;
toUserName?: string;
};
type Option = { value: number; label: string };
@@ -47,42 +54,78 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const me = useMeQuery();
const [searchText, setSearchText] = useState("");
const debouncedSearch = useDebounce(searchText, 500);
const searchParams = useCompatSearchParams();
const oooType = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
const members = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{ limit: 10, searchText: debouncedSearch },
const [searchMember, setSearchMember] = useState("");
const debouncedSearchMember = useDebounce(searchMember, 500);
const oooForMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{ limit: 10, searchText: debouncedSearchMember },
{
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const oooMemberListOptions: {
value: number;
label: string;
avatarUrl: string | null;
}[] = !!currentlyEditingOutOfOfficeEntry
? [
{
value: currentlyEditingOutOfOfficeEntry.forUserId || -1,
label: currentlyEditingOutOfOfficeEntry.forUserName || "",
avatarUrl: currentlyEditingOutOfOfficeEntry.forUserAvatar || "",
},
]
: oooForMembers?.data?.pages
.flatMap((page) => page.members)
?.filter((member) => me?.data?.id !== member.id)
.map((member) => ({
value: member.id,
label: member.name || member.username || "",
avatarUrl: member.avatarUrl,
})) || [];
const { ref: observerRefMember } = useInViewObserver(() => {
if (oooForMembers.hasNextPage && !oooForMembers.isFetching) {
oooForMembers.fetchNextPage();
}
}, document.querySelector('[role="dialog"]'));
const me = useMeQuery();
const memberListOptions: {
const [searchRedirectMember, setSearchRedirectMember] = useState("");
const debouncedSearchRedirect = useDebounce(searchRedirectMember, 500);
const redirectMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{ limit: 10, searchText: debouncedSearchRedirect },
{
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const redirectToMemberListOptions: {
value: number;
label: string;
avatarUrl: string | null;
}[] =
members?.data?.pages
redirectMembers?.data?.pages
.flatMap((page) => page.members)
?.filter((member) => me?.data?.id !== member.id)
?.filter((member) =>
oooType === OutOfOfficeTab.MINE ? me?.data?.id !== member.id : oooType === OutOfOfficeTab.TEAM
)
.map((member) => ({
value: member.id,
label: member.name || member.username || "",
avatarUrl: member.avatarUrl,
})) || [];
const { ref: observerRef } = useInViewObserver(() => {
if (members.hasNextPage && !members.isFetching) {
members.fetchNextPage();
const { ref: observerRefRedirect } = useInViewObserver(() => {
if (redirectMembers.hasNextPage && !redirectMembers.isFetching) {
redirectMembers.fetchNextPage();
}
}, document.querySelector('[role="dialog"]'));
const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery();
const { data: outOfOfficeReasonList, isPending: isReasonListPending } =
trpc.viewer.outOfOfficeReasonList.useQuery();
const reasonList = (outOfOfficeReasonList || []).map((reason) => ({
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
value: reason.id,
@@ -99,6 +142,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
register,
watch,
formState: { isSubmitting },
getValues,
} = useForm<BookingRedirectForm>({
defaultValues: currentlyEditingOutOfOfficeEntry
? currentlyEditingOutOfOfficeEntry
@@ -110,10 +154,12 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
offset: dayjs().utcOffset(),
toTeamUserId: null,
reasonId: 1,
forUserId: null,
},
});
const watchedTeamUserId = watch("toTeamUserId");
const watchForUserId = watch("forUserId");
const createOrEditOutOfOfficeEntry = trpc.viewer.outOfOfficeCreateOrUpdate.useMutation({
onSuccess: () => {
@@ -156,9 +202,87 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
<div className="h-full px-1">
<DialogHeader
title={
currentlyEditingOutOfOfficeEntry ? t("edit_an_out_of_office") : t("create_an_out_of_office")
currentlyEditingOutOfOfficeEntry
? t("edit_an_out_of_office")
: oooType === "team"
? t("create_ooo_dialog_team_title")
: t("create_an_out_of_office")
}
subtitle={
oooType === "team"
? currentlyEditingOutOfOfficeEntry
? t("edit_ooo_dialog_team_subtitle")
: t("create_ooo_dialog_team_subtitle")
: undefined
}
/>
{/* In case of Team, Select Member for whom OOO is created */}
{oooType === "team" && (
<>
<div className="mb-4">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<div className="mt-2">
<Input
type="text"
placeholder={t("search")}
onChange={(e) => setSearchMember(e.target.value)}
value={searchMember}
disabled={!!currentlyEditingOutOfOfficeEntry}
/>
<div
className={`scroll-bar bg-default mt-2 flex ${
!!currentlyEditingOutOfOfficeEntry ? "h-[45px]" : "h-[150px]"
} flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-1`}>
{oooMemberListOptions.map((member) => (
<label
key={member.value}
aria-disabled={!!currentlyEditingOutOfOfficeEntry}
data-testid={`ooofor_username_select_${member.value}`}
tabIndex={watchForUserId === member.value ? -1 : 0}
role="radio"
aria-checked={watchForUserId === member.value}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setValue("forUserId", member.value, { shouldDirty: true });
}
}}
className={classNames(
"cursor-not-allowed items-center justify-between gap-0.5 rounded-sm py-2 outline-none",
watchForUserId === member.value && "bg-subtle",
!currentlyEditingOutOfOfficeEntry &&
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer focus:ring-2"
)}>
<div className="flex flex-1 items-center space-x-3">
<input
type="radio"
disabled={!!currentlyEditingOutOfOfficeEntry}
className="hidden"
checked={watchForUserId === member.value}
onChange={() => setValue("forUserId", member.value, { shouldDirty: true })}
/>
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
</div>
</label>
))}
{!currentlyEditingOutOfOfficeEntry && (
<div className="text-default text-center" ref={observerRefMember}>
<Button
color="minimal"
loading={oooForMembers.isFetchingNextPage}
disabled={!oooForMembers.hasNextPage}
onClick={() => oooForMembers.fetchNextPage()}>
{oooForMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
)}
</div>
</div>
</div>
</>
)}
<div>
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
<div>
@@ -247,45 +371,47 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
<Input
type="text"
placeholder={t("search")}
onChange={(e) => setSearchText(e.target.value)}
value={searchText}
onChange={(e) => setSearchRedirectMember(e.target.value)}
value={searchRedirectMember}
/>
<div className="scroll-bar bg-default mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-[10px] border p-1">
{memberListOptions.map((member) => (
<label
key={member.value}
data-testid={`team_username_select_${member.value}`}
tabIndex={watchedTeamUserId === member.value ? -1 : 0}
role="radio"
aria-checked={watchedTeamUserId === member.value}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setValue("toTeamUserId", member.value, { shouldDirty: true });
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring-2",
watchedTeamUserId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
<input
type="radio"
className="hidden"
checked={watchedTeamUserId === member.value}
onChange={() => setValue("toTeamUserId", member.value, { shouldDirty: true })}
/>
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
</div>
</label>
))}
<div className="text-default text-center" ref={observerRef}>
{redirectToMemberListOptions
.filter((member) => member.value !== getValues("forUserId"))
.map((member) => (
<label
key={member.value}
data-testid={`team_username_select_${member.value}`}
tabIndex={watchedTeamUserId === member.value ? -1 : 0}
role="radio"
aria-checked={watchedTeamUserId === member.value}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setValue("toTeamUserId", member.value, { shouldDirty: true });
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring-2",
watchedTeamUserId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
<input
type="radio"
className="hidden"
checked={watchedTeamUserId === member.value}
onChange={() => setValue("toTeamUserId", member.value, { shouldDirty: true })}
/>
<span className="text-emphasis w-full px-2 text-sm">{member.label}</span>
</div>
</label>
))}
<div className="text-default text-center" ref={observerRefRedirect}>
<Button
color="minimal"
loading={members.isFetchingNextPage}
disabled={!members.hasNextPage}
onClick={() => members.fetchNextPage()}>
{members.hasNextPage ? t("load_more_results") : t("no_more_results")}
loading={redirectMembers.isFetchingNextPage}
disabled={!redirectMembers.hasNextPage}
onClick={() => redirectMembers.fetchNextPage()}>
{redirectMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
@@ -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")}
</Button>
@@ -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<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const [deletedEntry, setDeletedEntry] = useState(0);
const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] =
useState<BookingRedirectForm | null>(null);
const [openModal, setOpenModal] = useState(false);
const editOutOfOfficeEntry = (entry: BookingRedirectForm) => {
setCurrentlyEditingOutOfOfficeEntry(entry);
setOpenModal(true);
};
if (data === null || data?.length === 0 || (data === undefined && !isPending))
return (
<EmptyScreen
className="mt-6"
headline={t("ooo_empty_title")}
description={t("ooo_empty_description")}
buttonRaw={<CreateNewOutOfOfficeEntryButton size="sm" />}
customIcon={
<div className="mt-4 h-[102px]">
<div className="flex h-full flex-col items-center justify-center p-2 md:mt-0 md:p-0">
<div className="relative">
<div className="dark:bg-darkgray-50 absolute -left-3 -top-3 -z-20 h-[70px] w-[70px] -rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-40 dark:opacity-80">
<div className="w-12" />
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<OutOfOfficeEntry>[] = [];
if (selectedTab === OutOfOfficeTab.TEAM) {
columns.push({
id: "member",
header: `Member`,
size: 300,
cell: ({ row }) => {
if (!row.original || !row.original.user || isPending || isFetching) {
return <SkeletonText className="h-8 w-full" />;
}
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 (
<div className="flex items-center gap-2">
<Avatar
size="sm"
alt={username || email}
imageSrc={getUserAvatarUrl({
avatarUrl,
})}
/>
<div className="">
<div
data-testid={`ooo-member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none">
{memberName}
</div>
<div className="dark:bg-darkgray-50 absolute -top-3 left-3 -z-10 h-[70px] w-[70px] rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-60 dark:opacity-90">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 text-inverted relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
<Icon name="clock" size={28} />
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
<div
data-testid={`ooo-member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none">
{email}
</div>
</div>
</div>
</div>
}
/>
);
return (
<div className="border-subtle mt-6 rounded-lg border">
<TableNew className="border-0">
<TableBody>
{data?.map((item) => (
<TableRow key={item.id} data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
<TableCell className="flex flex-row justify-between p-4">
);
},
});
}
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 ? (
<div
className="flex flex-row justify-between p-2"
data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
<div className="flex flex-row items-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-50">
{item?.reason?.emoji || "🏝️"}
@@ -117,81 +173,186 @@ export const OutOfOfficeEntriesList = () => {
)}
</div>
</div>
</div>
) : (
<SkeletonText className="h-8 w-full" />
)}
</>
);
},
});
columns.push({
id: "actions",
size: 90,
cell: ({ row }) => {
const item = row.original;
return (
<>
{row.original && !isPending && !isFetching ? (
<div className="flex flex-row items-center justify-end gap-x-2">
<Tooltip content={t("edit")}>
<Button
className="self-center rounded-lg border"
type="button"
color="secondary"
variant="icon"
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
StartIcon="pencil"
onClick={() => {
const offset = dayjs().utcOffset();
const outOfOfficeEntryData: BookingRedirectForm = {
uuid: item.uuid,
dateRange: {
startDate: dayjs(item.start).subtract(offset, "minute").toDate(),
endDate: dayjs(item.end).subtract(offset, "minute").startOf("d").toDate(),
},
offset,
toTeamUserId: item.toUserId,
reasonId: item.reason?.id ?? 1,
notes: item.notes ?? undefined,
forUserId: item.user?.id || null,
forUserName:
item.user?.name ||
(item.user?.email &&
(() => {
const emailName = item.user?.email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})()),
forUserAvatar: item.user?.avatarUrl,
toUserName: item.toUser?.name || item.toUser?.username,
};
editOutOfOfficeEntry(outOfOfficeEntryData);
}}
disabled={isPending || isFetching}
/>
</Tooltip>
<Tooltip content={t("delete")}>
<Button
className="self-center rounded-lg border"
type="button"
color="destructive"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending || isPending || isFetching}
StartIcon="trash-2"
data-testid={`ooo-delete-${item.toUser?.username || "n-a"}`}
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({
outOfOfficeUid: item.uuid,
userId: selectedTab === OutOfOfficeTab.TEAM ? item.user?.id : undefined,
});
}}
/>
</Tooltip>
</div>
) : (
<SkeletonText className="h-8 w-full" />
)}
</>
);
},
});
return columns;
}, [selectedTab, isPending, isFetching]);
<div className="flex flex-row items-center gap-x-2">
<Tooltip content={t("edit")}>
<Button
className="self-center rounded-lg border"
type="button"
color="secondary"
variant="icon"
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
StartIcon="pencil"
onClick={() => {
const offset = dayjs().utcOffset();
const outOfOfficeEntryData: BookingRedirectForm = {
uuid: item.uuid,
dateRange: {
startDate: dayjs(item.start).subtract(offset, "minute").toDate(),
endDate: dayjs(item.end).subtract(offset, "minute").startOf("d").toDate(),
},
offset,
toTeamUserId: item.toUserId,
reasonId: item.reason?.id ?? 1,
notes: item.notes ?? undefined,
};
editOutOfOfficeEntry(outOfOfficeEntryData);
}}
/>
</Tooltip>
<Tooltip content={t("delete")}>
<Button
className="self-center rounded-lg border"
type="button"
color="destructive"
variant="icon"
disabled={deleteOutOfOfficeEntryMutation.isPending}
StartIcon="trash-2"
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({ outOfOfficeUid: item.uuid });
}}
/>
</Tooltip>
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) ? (
<EmptyScreen
className="mt-6"
headline={selectedTab === OutOfOfficeTab.TEAM ? t("ooo_team_empty_title") : t("ooo_empty_title")}
description={
selectedTab === OutOfOfficeTab.TEAM ? t("ooo_team_empty_description") : t("ooo_empty_description")
}
buttonRaw={<CreateNewOutOfOfficeEntryButton size="sm" />}
customIcon={
<div className="mt-4 h-[102px]">
<div className="flex h-full flex-col items-center justify-center p-2 md:mt-0 md:p-0">
<div className="relative">
<div className="dark:bg-darkgray-50 absolute -left-3 -top-3 -z-20 h-[70px] w-[70px] -rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-40 dark:opacity-80">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 absolute -top-3 left-3 -z-10 h-[70px] w-[70px] rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-60 dark:opacity-90">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 text-inverted relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
<Icon name="clock" size={28} />
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold">z</span>
</div>
</div>
</TableCell>
</TableRow>
))}
{isPending && (
<>
{new Array(2).fill(0).map((_, index) => (
<TableRow key={index}>
<TableCell>
<SkeletonText className="h-12 w-full" />
</TableCell>
</TableRow>
))}
</>
)}
{!isPending && (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>
{openModal && (
<CreateOrEditOutOfOfficeEntryModal
openModal={openModal}
closeModal={() => {
setOpenModal(false);
setCurrentlyEditingOutOfOfficeEntry(null);
}}
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
</div>
</div>
}
/>
) : (
<div>
<DataTable
hideHeader={selectedTab === OutOfOfficeTab.MINE}
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
{selectedTab === OutOfOfficeTab.TEAM && (
<DataTableToolbar.Root>
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setSearchTerm(value)} />
</DataTableToolbar.Root>
)}
</DataTable>
{openModal && (
<CreateOrEditOutOfOfficeEntryModal
openModal={openModal}
closeModal={() => {
setOpenModal(false);
setCurrentlyEditingOutOfOfficeEntry(null);
}}
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
/>
)}
</div>
)}
</div>
</>
);
};
@@ -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 (
<ToggleGroup
className="hidden md:block"
defaultValue={selectedTab}
onValueChange={(value) => {
if (!value) return;
const newQuery = createQueryString("type", value);
router.push(`${pathname}?${newQuery}`);
}}
options={toggleGroupOptions}
disabled={!hasTeamOOOAccess}
/>
);
};
@@ -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: {
@@ -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;
}
@@ -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,
};
};
@@ -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;
};
@@ -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<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" });
}
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<TrpcSessionUser> } }) => {
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;
};
@@ -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<typeof ZOutOfOfficeInputSchema>;
export const ZOutOfOfficeDelete = z.object({
outOfOfficeUid: z.string(),
});
export type TOutOfOfficeDelete = z.infer<typeof ZOutOfOfficeDelete>;
@@ -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<TrpcSessionUser>;
};
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,
},
};
};
@@ -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<typeof ZOutOfOfficeEntriesListSchema>;
@@ -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<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" });
}
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 {};
};
@@ -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<typeof ZOutOfOfficeDelete>;
@@ -68,7 +68,8 @@ export function EmptyScreen({
<h2
className={classNames(
"text-semibold font-cal text-emphasis text-center text-xl normal-nums",
icon && "mt-6"
icon && "mt-6",
!description && "mb-8"
)}>
{headline}
</h2>
@@ -85,7 +85,9 @@ export function DatePickerWithRange({
<Popover.Content
className="bg-default text-emphasis z-50 w-auto rounded-md border p-0 outline-none"
align="start"
sideOffset={4}>
sideOffset={4}
side="bottom"
avoidCollisions={false}>
{calendar}
</Popover.Content>
</Popover.Root>